Probably more than you wanted, but better too much than too little.
import Foundation
import SwiftUI
import UserNotifications
struct Periodical: Codable, Identifiable
{
  var id: UUID = UUID()
  let name: String          // name of the bill or periodical
  var dueDate: Date         // due date
  let price: Double?        // amount due
  let notes: String         // any comments
  var timestamp: Date? = Date.now
  var itemOptions: ItemOptions?
  var dateExpired: Bool     // Is the current date at or past due date?
  { dueDate.removeTime() <= Date.now.removeTime() }
  var daysTillDue: Int
  {
    let dateOnlyDueDate = dueDate.removeTime()
    let dateOnlyToday = Date.now.removeTime()
    let myCalendar = Calendar.current
    return myCalendar.dateComponents([.day], from: dateOnlyToday, to: dateOnlyDueDate).day!
  }
  var buttonLabel: String
  { price == nil ? "Done" : "Paid" }
}
final class Periodicals: ObservableObject
{
  // Error handling
  
  struct AppError: Identifiable
  {
    let id = UUID().uuidString
    let errorString: String
  }
  
  var appError: AppError? = nil
  var errorHeading = ""
  
  // End of error handling
  
  @AppStorage("iconBadge") var iconBadge = false
  @AppStorage("notifications") var notifications = false
  
  static let itemKey = "Items"
  static let fileExt = ".txt"
  
  @Published private(set) var items = [Periodical]()
  
  // Computed property which is the number
  // of items that are past due.
  
  var numberPastDue: Int
  { items.filter { $0.dateExpired }.count}
  
  // Computed property which is the total amount that's past due.
  
  var totalPastDue: Double
  {
    items.filter()
    { $0.dateExpired && $0.price != nil }
    .reduce(0){ $0 + $1.price! }
  }
  
  // Computed property which is the total due within the
  // specified number of days, including past due.
  
  var dueInXDays: Double
  {
    items.filter()
    { $0.daysTillDue <= summaryDays && $0.price != nil }
    .reduce(0){ $0 + $1.price! }
  }
  // Items are added and replaced using currentItem.
  // If the id in currentItem matches an existing item,
  // the original item is replaced, otherwise it is
  // appended to the array.  In any case, the array is
  // then sorted by due date, earliest first, and saved.
  var currentItem: Periodical?
  {
    didSet
    {
      let index = itemIndex(id: currentItem!.id)
      if index == -1    // This is a new item being added.
      {
        cancelUndo()
        items.append(currentItem!)
      }
      else              // This is an existing item being edited.
      {
        previousItem = items[index]   // save for undo
        items[index] = currentItem!
      }
      save()            // Save the list.
    }
  }
  // If there is an item in 'previousItem', then it is
  // used for undo by exchanging it with 'currentItem'.
  var previousItem: Periodical?
  func undo()
  { currentItem = previousItem }
  func cancelUndo()
  { previousItem = nil }
  init()
  { load() }
  // Returns 'true' if the provided item name matches the
  // name of another item, otherwise returns 'false'.
  func duplicateItem(name: String, id: UUID) -> Bool
  { items.firstIndex(where: { $0.name == name && $0.id != id }) != nil }
  // Encode and save the data.
  func save()
  {
    let encoder = JSONEncoder()
    sortByDueDate()
    if let encoded = try? encoder.encode(items)
    { saveFile(data: encoded) }
  }
  private func sortByDueDate()
  { items.sort() { $0.dueDate < $1.dueDate };}
  // Load and decode the data file.
  func load()
  {
    if let json = loadFile()
    {
      let decoder = JSONDecoder()
      do
      {
        let decoded = try decoder.decode([Periodical].self, from: json)
        items = decoded
        return
      }
      catch 
      {
        print(error.localizedDescription)
        print(error)
        errorHeading = "Error decoding data"
        appError = AppError(errorString: error.localizedDescription)
      }
    }
    items = []
  }
  // ***************************************
  // *** Import/Export support functions ***
  // ***************************************
  // Returns a String representation of the data. Used by Export.
  func getDataAsString() -> String?
  {
    let data = loadFile() ?? Data()
    return String(data: data, encoding: String.Encoding.utf8)
  }
  // Replaces list with contents of data. Used by Import.
  func updateListFromData(_ json: Data, replace: Bool) throws
  {
    let decoder = JSONDecoder()
    do
    {
      let decoded = try decoder.decode([Periodical].self, from: json)
      
      if replace
      { items = decoded }
      else
      { items = removeDuplicates(items + decoded) }
      
      save()
    }
  }
  
  // - A periodical is a duplicate if their id's are equal.
  // - The duplicate with the earliest timestamp is removed.
  // - A timestamp of nil is earlier than any other timestamp.
  // - If two items both have a nil timestamp, the item with
  //   the earliest due date is removed.
  
  func removeDuplicates(_ list: [Periodical]) -> [Periodical]
  {
    let uniques = list.reduce(into: Dictionary<String, Periodical>())
    { result, currentVal in
      // Do we already have this periodical in our result?
      if let existingVal = result[currentVal.id.uuidString]
      {
        // Yes we do, so compare the timestamps.
        switch (currentVal.timestamp, existingVal.timestamp)
        {
          case (nil, nil):
            // Both currentVal.timestamp and existingVal.timestamp are nil.
            // Save the one with the latest due date.
            if currentVal.dueDate > existingVal.dueDate
            { result[currentVal.id.uuidString] = currentVal }
        
          case (nil, .some(_)):
            // currentVal.timestamp == nil, so do nothing.
            break
          
          case (.some(_), nil):
            // existingVal.timestamp == nil so replace with currentVal.
            result[currentVal.id.uuidString] = currentVal
          
          default:
            // Both currentVal.timestamp and existingVal.timestamp have values,
            // so compare them and keep the latest one.
            if currentVal.timestamp! > existingVal.timestamp!
            { result[currentVal.id.uuidString] = currentVal }
        }
      }
      else
      {
        // No we do not, so add it.
        result[currentVal.id.uuidString] = currentVal
      }
    }
    
    return Array(uniques.values)
  }
  // **********************************************
  // *** End of Import/Export support functions ***
  // **********************************************
  // Remove a single periodical from the list, based on the ID.
  func removeItem(id: UUID)
  {
    let index = items.firstIndex { $0.id == id }
    previousItem = items[index!]
    items.remove(at: index!)
    save()
  }
  // 1) Set the icon badge number to the number of items with expired due dates.
  // 2) Set up notifications to increase the icon badge number for future items.
  func setNotifications()
  {
    var badgeValue = 0
    // 1)
    if iconBadge
    { badgeValue = numberPastDue }
    UIApplication.shared.applicationIconBadgeNumber = badgeValue
    // 2)
    let center = UNUserNotificationCenter.current()
    center.removeAllPendingNotificationRequests()
    if iconBadge || notifications
    {
      // There is a limit of 64 pending notifications.  If this limit
      // is exceeded, the 64 soonest notifications are supposed to
      // survive.  This doesn't seem to be the case.  What actually
      // happens is that the last 64 notifications that were activated
      // are the ones that survive.  To get around this, I'm cutting the
      // unexpired items array down to the first 30, which means there
      // won't be any more than 60 notifications activated.
      let unexpiredItems = items.filter { !$0.dateExpired }.prefix(30)
      for item in unexpiredItems
      {
        if notifications
        { addNotification(for: item) }
        if iconBadge
        {
          badgeValue += 1
          setFutureIconBadgeValue(for: item, badgeValue: badgeValue)
        }
      }
    }
    print("List of Requests")
    center.getPendingNotificationRequests(completionHandler:
                                            { notifications in
                                              for notification in notifications
                                              { print(notification) }
                                            })
  }
  // Activate a notification for the item's due date.
  private func addNotification(for item: Periodical)
  {
    let center = UNUserNotificationCenter.current()
    let content = UNMutableNotificationContent()
    content.title = "'\(item.name)' is due today."
    content.sound = UNNotificationSound.default
    if #available(iOS 15, *)
    { content.interruptionLevel = .active }
    var triggerDate = Calendar.current.dateComponents([.year, .month, .day], from: item.dueDate)
    triggerDate.hour = 9
    let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false)
    let request = UNNotificationRequest(identifier: item.id.uuidString, content: content, trigger: trigger)
    center.add(request)
  }
  // Activate a notification for future icon badge value.
  private func setFutureIconBadgeValue(for item: Periodical, badgeValue: Int)
  {
    var iconBadgeID: String
    {
      let formatter = DateFormatter()
      formatter.dateStyle = .short
      let idModifier = formatter.string(from: item.dueDate)
      return "iconBadge." + idModifier
    }
    let center = UNUserNotificationCenter.current()
    let content = UNMutableNotificationContent()
    content.badge = badgeValue as NSNumber
    var triggerDate = Calendar.current.dateComponents([.year, .month, .day], from: item.dueDate)
    triggerDate.hour = 0
    triggerDate.minute = 0
    let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false)
    let request = UNNotificationRequest(identifier: iconBadgeID, content: content, trigger: trigger)
    center.add(request)
  }
  // Returns the index of the item whose id matches the
  // supplied UUID or -1 if the item is not found.
  private func itemIndex(id: UUID) -> Int
  { items.firstIndex(where: { $0.id == id }) ?? -1;}
  // Return the URL of the app's documents directory.
  private func getDocumentsDirectory() -> URL
  {
    // Find all possible documents directories for this user.
    let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
    // Just return the first one, which ought to be the only one.
    return paths[0]
  }
  // Save the data to the Documents dirctory.
  private func saveFile(data: Data)
  {
    let url = getDocumentsDirectory().appendingPathComponent(Self.itemKey + Self.fileExt)
    do
    { try data.write(to: url, options: [.atomicWrite, .completeFileProtection]) }
    catch
    {
      print(error.localizedDescription)
      print(error)
      errorHeading = "Error saving data"
      appError = AppError(errorString: error.localizedDescription)
    }
  }
  // Load the data from the Documents directory.
  private func loadFile() -> Data?
  {
    let url = getDocumentsDirectory().appendingPathComponent(Self.itemKey + Self.fileExt)
    if !FileManager.default.fileExists(atPath: url.relativePath)
    { return nil }
    do
    {
      let data = try Data(contentsOf: url)
      return data
    }
    catch
    {
      print(error.localizedDescription)
      print(error)
      errorHeading = "Error reading data"
      appError = AppError(errorString: error.localizedDescription)
      return nil
    }
  }
}