import Foundation import CoreData import Combine class ImportService { static let shared = ImportService() private init() {} struct ImportResult { let accountsCreated: Int let sourcesCreated: Int let snapshotsCreated: Int let snapshotsUpdated: Int let errors: [String] } struct ImportProgress { let completed: Int let total: Int let message: String var fraction: Double { guard total > 0 else { return 0 } return min(max(Double(completed) / Double(total), 0), 1) } } enum ImportFormat { case csv case json } struct ImportedAccount { let name: String let currency: String? let inputMode: InputMode let notificationFrequency: NotificationFrequency let customFrequencyMonths: Int let categories: [ImportedCategory] } struct ImportedCategory { let name: String let colorHex: String? let icon: String? let sources: [ImportedSource] } struct ImportedSource { let name: String let snapshots: [ImportedSnapshot] } struct ImportedSnapshot { let date: Date let value: Decimal let contribution: Decimal? let notes: String? } func importData( content: String, format: ImportFormat, allowMultipleAccounts: Bool, defaultAccountName: String? = nil ) -> ImportResult { switch format { case .csv: let parsed = parseCSV( content, allowMultipleAccounts: allowMultipleAccounts, defaultAccountName: defaultAccountName ) return applyImport(parsed, context: CoreDataStack.shared.viewContext) case .json: let parsed = parseJSON( content, allowMultipleAccounts: allowMultipleAccounts, defaultAccountName: defaultAccountName ) return applyImport(parsed, context: CoreDataStack.shared.viewContext) } } func importDataAsync( content: String, format: ImportFormat, allowMultipleAccounts: Bool, defaultAccountName: String? = nil, progress: @escaping (ImportProgress) -> Void ) async -> ImportResult { await withCheckedContinuation { continuation in CoreDataStack.shared.performBackgroundTask { context in let parsed: [ImportedAccount] switch format { case .csv: parsed = self.parseCSV( content, allowMultipleAccounts: allowMultipleAccounts, defaultAccountName: defaultAccountName ) case .json: parsed = self.parseJSON( content, allowMultipleAccounts: allowMultipleAccounts, defaultAccountName: defaultAccountName ) } let totalSnapshots = parsed.reduce(0) { total, account in total + account.categories.reduce(0) { subtotal, category in subtotal + category.sources.reduce(0) { sourceTotal, source in sourceTotal + source.snapshots.count } } } DispatchQueue.main.async { progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data")) } let result = self.applyImport(parsed, context: context) { completed in DispatchQueue.main.async { progress(ImportProgress( completed: completed, total: totalSnapshots, message: "Imported \(completed) of \(totalSnapshots) snapshots" )) } } continuation.resume(returning: result) } } } static func sampleCSV() -> String { return """ Account,Category,Source,Date,Value,Contribution,Notes Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term Personal,Crypto,BTC,2024-01-01,3200,,Cold storage Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value """ } static func sampleJSON() -> String { return """ { "version": 2, "currency": "EUR", "accounts": [{ "name": "Personal", "inputMode": "simple", "notificationFrequency": "monthly", "categories": [{ "name": "Stocks", "color": "#3B82F6", "icon": "chart.line.uptrend.xyaxis", "sources": [{ "name": "Index Fund", "snapshots": [{ "date": "2024-01-01T00:00:00Z", "value": 15000, "contribution": 12000 }] }] }] }] } """ } // MARK: - Parsing private func parseCSV( _ content: String, allowMultipleAccounts: Bool, defaultAccountName: String? ) -> [ImportedAccount] { let rows = parseCSVRows(content) guard rows.count > 1 else { return [] } let headers = rows[0].map { $0.lowercased().trimmingCharacters(in: .whitespaces) } let indexOfAccount = headers.firstIndex(of: "account") let indexOfCategory = headers.firstIndex(of: "category") let indexOfSource = headers.firstIndex(of: "source") let indexOfDate = headers.firstIndex(of: "date") let indexOfValue = headers.firstIndex(where: { $0.hasPrefix("value") }) let indexOfContribution = headers.firstIndex(where: { $0.hasPrefix("contribution") }) let indexOfNotes = headers.firstIndex(of: "notes") var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:] for row in rows.dropFirst() { let providedAccount = indexOfAccount.flatMap { row.safeValue(at: $0) } let fallbackAccount = defaultAccountName ?? "Personal" let normalizedAccount = (providedAccount ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) let rawAccountName = normalizedAccount.isEmpty ? fallbackAccount : normalizedAccount let accountName = allowMultipleAccounts ? rawAccountName : fallbackAccount guard let categoryName = indexOfCategory.flatMap({ row.safeValue(at: $0) }), !categoryName.isEmpty, let sourceName = indexOfSource.flatMap({ row.safeValue(at: $0) }), !sourceName.isEmpty, let dateString = indexOfDate.flatMap({ row.safeValue(at: $0) }), let valueString = indexOfValue.flatMap({ row.safeValue(at: $0) }) else { continue } guard let date = parseDate(dateString), let value = parseDecimal(valueString) else { continue } let contribution = indexOfContribution .flatMap { row.safeValue(at: $0) } .flatMap(parseDecimal) let notes = indexOfNotes .flatMap { row.safeValue(at: $0) } .flatMap { $0.isEmpty ? nil : $0 } let snapshot = ImportedSnapshot( date: date, value: value, contribution: contribution, notes: notes ) grouped[accountName, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot) } return grouped.map { accountName, categories in let importedCategories = categories.map { categoryName, sources in let importedSources = sources.map { sourceName, snapshots in ImportedSource(name: sourceName, snapshots: snapshots) } return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources) } return ImportedAccount( name: accountName, currency: nil, inputMode: .simple, notificationFrequency: .monthly, customFrequencyMonths: 1, categories: importedCategories ) } } private func parseJSON( _ content: String, allowMultipleAccounts: Bool, defaultAccountName: String? ) -> [ImportedAccount] { guard let data = content.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] } if let accountsArray = json["accounts"] as? [[String: Any]] { return accountsArray.compactMap { accountDict in let rawName = accountDict["name"] as? String ?? "Personal" let fallbackName = defaultAccountName ?? "Personal" let name = allowMultipleAccounts ? rawName : fallbackName let currency = accountDict["currency"] as? String let inputMode = InputMode(rawValue: accountDict["inputMode"] as? String ?? "") ?? .simple let notificationFrequency = NotificationFrequency( rawValue: accountDict["notificationFrequency"] as? String ?? "" ) ?? .monthly let customFrequencyMonths = accountDict["customFrequencyMonths"] as? Int ?? 1 let categoriesArray = accountDict["categories"] as? [[String: Any]] ?? [] let categories = categoriesArray.map { categoryDict in let categoryName = categoryDict["name"] as? String ?? "Uncategorized" let colorHex = categoryDict["color"] as? String let icon = categoryDict["icon"] as? String let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? [] let sources = sourcesArray.map { sourceDict in let sourceName = sourceDict["name"] as? String ?? "Source" let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? [] let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in guard let dateString = snapshotDict["date"] as? String, let date = ISO8601DateFormatter().date(from: dateString), let value = snapshotDict["value"] as? Double else { return nil } let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) } let notes = snapshotDict["notes"] as? String return ImportedSnapshot( date: date, value: Decimal(value), contribution: contribution, notes: notes ) } return ImportedSource(name: sourceName, snapshots: snapshots) } return ImportedCategory( name: categoryName, colorHex: colorHex, icon: icon, sources: sources ) } return ImportedAccount( name: name, currency: currency, inputMode: inputMode, notificationFrequency: notificationFrequency, customFrequencyMonths: customFrequencyMonths, categories: categories ) } } // Legacy JSON: categories only if let categoriesArray = json["categories"] as? [[String: Any]] { let categories = categoriesArray.map { categoryDict in let categoryName = categoryDict["name"] as? String ?? "Uncategorized" let colorHex = categoryDict["color"] as? String let icon = categoryDict["icon"] as? String let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? [] let sources = sourcesArray.map { sourceDict in let sourceName = sourceDict["name"] as? String ?? "Source" let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? [] let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in guard let dateString = snapshotDict["date"] as? String, let date = ISO8601DateFormatter().date(from: dateString), let value = snapshotDict["value"] as? Double else { return nil } let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) } let notes = snapshotDict["notes"] as? String return ImportedSnapshot( date: date, value: Decimal(value), contribution: contribution, notes: notes ) } return ImportedSource(name: sourceName, snapshots: snapshots) } return ImportedCategory( name: categoryName, colorHex: colorHex, icon: icon, sources: sources ) } let fallbackName = allowMultipleAccounts ? "Personal" : (defaultAccountName ?? "Personal") return [ ImportedAccount( name: fallbackName, currency: json["currency"] as? String, inputMode: .simple, notificationFrequency: .monthly, customFrequencyMonths: 1, categories: categories ) ] } return [] } // MARK: - Apply Import private func applyImport( _ accounts: [ImportedAccount], context: NSManagedObjectContext, snapshotProgress: ((Int) -> Void)? = nil ) -> ImportResult { let accountRepository = AccountRepository(context: context) let categoryRepository = CategoryRepository(context: context) let sourceRepository = InvestmentSourceRepository(context: context) let snapshotRepository = SnapshotRepository(context: context) var accountsCreated = 0 var sourcesCreated = 0 var snapshotsCreated = 0 var snapshotsUpdated = 0 var errors: [String] = [] let existingCategories = fetchCategories(in: context) var categoryLookup = buildCategoryLookup(from: existingCategories) let otherCategory = resolveExistingCategory(named: "Other", lookup: categoryLookup) ?? categoryRepository.createCategory( name: "Other", colorHex: "#64748B", icon: "ellipsis.circle.fill" ) categoryLookup[normalizedCategoryName(otherCategory.name)] = otherCategory var completionDatesByMonth: [String: Date] = [:] // Build lookup of existing accounts by fetching directly from database let existingAccountsLookup = fetchAccountsLookup(in: context) for importedAccount in accounts { let existingAccount = existingAccountsLookup[importedAccount.name] let account: Account if let existing = existingAccount { account = existing } else { account = accountRepository.createAccount( name: importedAccount.name, currency: importedAccount.currency, inputMode: importedAccount.inputMode, notificationFrequency: importedAccount.notificationFrequency, customFrequencyMonths: importedAccount.customFrequencyMonths ) accountsCreated += 1 } for importedCategory in importedAccount.categories { let existingCategory = resolveExistingCategory( named: importedCategory.name, lookup: categoryLookup ) let shouldUseOther = existingCategory == nil && importedCategory.colorHex == nil && importedCategory.icon == nil let resolvedName = canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name let category = existingCategory ?? (shouldUseOther ? otherCategory : categoryRepository.createCategory( name: resolvedName, colorHex: importedCategory.colorHex ?? "#3B82F6", icon: importedCategory.icon ?? "chart.pie.fill" )) categoryLookup[normalizedCategoryName(category.name)] = category for importedSource in importedCategory.sources { let accountId = account.safeId let existingSource = sourceRepository.sources.first(where: { $0.name == importedSource.name && $0.account?.id == accountId }) let source = existingSource ?? sourceRepository.createSource( name: importedSource.name, category: category, notificationFrequency: importedAccount.notificationFrequency, customFrequencyMonths: importedAccount.customFrequencyMonths ) source.account = account if existingSource == nil { sourcesCreated += 1 } for snapshot in importedSource.snapshots { // Check if a snapshot with the same date already exists for this source let existingSnapshot = fetchSnapshot(for: source, date: snapshot.date, in: context) if let existing = existingSnapshot { // Update existing snapshot instead of creating a duplicate existing.value = NSDecimalNumber(decimal: snapshot.value) if let contribution = snapshot.contribution { existing.contribution = NSDecimalNumber(decimal: contribution) } if let notes = snapshot.notes, !notes.isEmpty { existing.notes = notes } snapshotsUpdated += 1 } else { // Create new snapshot snapshotRepository.createSnapshot( for: source, date: snapshot.date, value: snapshot.value, contribution: snapshot.contribution, notes: snapshot.notes ) snapshotsCreated += 1 } snapshotProgress?(snapshotsCreated) let monthKey = MonthlyCheckInStore.monthKey(for: snapshot.date) if let existingDate = completionDatesByMonth[monthKey] { if snapshot.date > existingDate { completionDatesByMonth[monthKey] = snapshot.date } } else { completionDatesByMonth[monthKey] = snapshot.date } } } } } if context.hasChanges { do { try context.save() } catch { errors.append("Failed to save imported data.") } } if !completionDatesByMonth.isEmpty { for (monthKey, completionDate) in completionDatesByMonth { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM" if let monthDate = formatter.date(from: monthKey) { MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate) } } } return ImportResult( accountsCreated: accountsCreated, sourcesCreated: sourcesCreated, snapshotsCreated: snapshotsCreated, snapshotsUpdated: snapshotsUpdated, errors: errors ) } private func resolveExistingCategory( named rawName: String, lookup: [String: Category] ) -> Category? { if let canonical = canonicalCategoryName(for: rawName) { let canonicalKey = normalizedCategoryName(canonical) if let match = lookup[canonicalKey] { return match } } return lookup[normalizedCategoryName(rawName)] } private func buildCategoryLookup(from categories: [Category]) -> [String: Category] { var lookup: [String: Category] = [:] for category in categories { lookup[normalizedCategoryName(category.name)] = category } return lookup } private func fetchCategories(in context: NSManagedObjectContext) -> [Category] { let request: NSFetchRequest = Category.fetchRequest() request.sortDescriptors = [ NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true), NSSortDescriptor(keyPath: \Category.name, ascending: true) ] return (try? context.fetch(request)) ?? [] } private func fetchAccountsLookup(in context: NSManagedObjectContext) -> [String: Account] { let request: NSFetchRequest = Account.fetchRequest() request.sortDescriptors = [NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)] let accounts = (try? context.fetch(request)) ?? [] var lookup: [String: Account] = [:] for account in accounts { lookup[account.name] = account } return lookup } /// Fetch a snapshot for a given source and date (same day) private func fetchSnapshot( for source: InvestmentSource, date: Date, in context: NSManagedObjectContext ) -> Snapshot? { let request: NSFetchRequest = Snapshot.fetchRequest() // Match snapshots on the same day (ignore time component) let calendar = Calendar.current let startOfDay = calendar.startOfDay(for: date) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! request.predicate = NSPredicate( format: "source == %@ AND date >= %@ AND date < %@", source, startOfDay as NSDate, endOfDay as NSDate ) request.fetchLimit = 1 return try? context.fetch(request).first } private func canonicalCategoryName(for rawName: String) -> String? { let normalized = normalizedCategoryName(rawName) for mapping in categoryAliasMappings { if mapping.aliases.contains(where: { normalizedCategoryName($0) == normalized }) { return mapping.canonical } } return nil } private func normalizedCategoryName(_ value: String) -> String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = trimmed.folding( options: [.diacriticInsensitive, .caseInsensitive], locale: .current ) return normalized.replacingOccurrences( of: "\\s+", with: " ", options: .regularExpression ) } private var categoryAliasMappings: [(canonical: String, aliases: [String])] { [ ( canonical: "Stocks", aliases: ["Stocks", "category_stocks", String(localized: "category_stocks"), "Acciones"] ), ( canonical: "Bonds", aliases: ["Bonds", "category_bonds", String(localized: "category_bonds"), "Bonos"] ), ( canonical: "Real Estate", aliases: ["Real Estate", "category_real_estate", String(localized: "category_real_estate"), "Inmobiliario"] ), ( canonical: "Crypto", aliases: ["Crypto", "category_crypto", String(localized: "category_crypto"), "Cripto"] ), ( canonical: "Cash", aliases: ["Cash", "category_cash", String(localized: "category_cash"), "Efectivo"] ), ( canonical: "ETFs", aliases: ["ETFs", "category_etfs", String(localized: "category_etfs"), "ETF"] ), ( canonical: "Retirement", aliases: ["Retirement", "category_retirement", String(localized: "category_retirement"), "Jubilación"] ), ( canonical: "Other", aliases: [ "Other", "category_other", String(localized: "category_other"), "Uncategorized", "uncategorized", String(localized: "uncategorized"), "Otros", "Sin categoría" ] ) ] } // MARK: - CSV Column Mapping /// Parsed preview data for the mapping UI struct CSVPreview { let rows: [[String]] let columnCount: Int func headers(hasHeaderRow: Bool) -> [String] { if hasHeaderRow, let first = rows.first { return first } return (0.. [String]? { let start = hasHeaderRow ? 1 : 0 return rows.count > start ? rows[start] : nil } } func previewCSV(_ content: String) -> CSVPreview { let rows = parseCSVRows(content) let columnCount = rows.first?.count ?? 0 return CSVPreview(rows: rows, columnCount: columnCount) } // Maps each app field to a CSV column index or a constant value struct CSVMappingConfig { // -1 = not mapped, -2 = constant, -3 = use today (date only), 0+ = column index static let notMapped = -1 static let constant = -2 static let useToday = -3 var hasHeaderRow: Bool = true var sourceIndex: Int = notMapped var sourceConstant: String = "" var valueIndex: Int = notMapped var dateIndex: Int = useToday // default: use today var categoryIndex: Int = constant var categoryConstant: String = "Other" // default category name when constant var contributionIndex: Int = notMapped var notesIndex: Int = notMapped var isValid: Bool { let sourceOk: Bool = { if sourceIndex == Self.constant { return !sourceConstant.isEmpty } return sourceIndex >= 0 }() let valueOk = valueIndex >= 0 return sourceOk && valueOk } } func importCSVWithMapping( content: String, mapping: CSVMappingConfig, defaultAccountName: String? ) -> ImportResult { let preview = previewCSV(content) let accounts = parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName) return applyImport(accounts, context: CoreDataStack.shared.viewContext) } func importCSVWithMappingAsync( content: String, mapping: CSVMappingConfig, defaultAccountName: String?, progress: @escaping (ImportProgress) -> Void ) async -> ImportResult { await withCheckedContinuation { continuation in CoreDataStack.shared.performBackgroundTask { context in let preview = self.previewCSV(content) let accounts = self.parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName) let totalSnapshots = accounts.reduce(0) { t, a in t + a.categories.reduce(0) { s, c in s + c.sources.reduce(0) { $0 + $1.snapshots.count } } } DispatchQueue.main.async { progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data")) } let result = self.applyImport(accounts, context: context) { completed in DispatchQueue.main.async { progress(ImportProgress( completed: completed, total: totalSnapshots, message: "Imported \(completed) of \(totalSnapshots) snapshots" )) } } continuation.resume(returning: result) } } } private func parseCSVWithMapping( preview: CSVPreview, mapping: CSVMappingConfig, defaultAccountName: String? ) -> [ImportedAccount] { let dataRows = mapping.hasHeaderRow ? Array(preview.rows.dropFirst()) : preview.rows let fallbackAccount = defaultAccountName ?? "Personal" var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:] for row in dataRows { // Source name let sourceName: String if mapping.sourceIndex == CSVMappingConfig.constant { guard !mapping.sourceConstant.isEmpty else { continue } sourceName = mapping.sourceConstant } else if mapping.sourceIndex >= 0 { guard let v = row.safeValue(at: mapping.sourceIndex), !v.isEmpty else { continue } sourceName = v } else { continue } // Value guard mapping.valueIndex >= 0, let valStr = row.safeValue(at: mapping.valueIndex), let value = parseDecimal(valStr) else { continue } // Date let date: Date if mapping.dateIndex == CSVMappingConfig.useToday { date = Calendar.current.startOfDay(for: Date()) } else if mapping.dateIndex >= 0, let dateStr = row.safeValue(at: mapping.dateIndex), let parsed = parseDate(dateStr) { date = parsed } else { date = Calendar.current.startOfDay(for: Date()) } // Category let categoryName: String if mapping.categoryIndex == CSVMappingConfig.constant { categoryName = mapping.categoryConstant.isEmpty ? "Other" : mapping.categoryConstant } else if mapping.categoryIndex >= 0, let v = row.safeValue(at: mapping.categoryIndex), !v.isEmpty { categoryName = v } else { categoryName = "Other" } // Contribution let contribution: Decimal? = mapping.contributionIndex >= 0 ? row.safeValue(at: mapping.contributionIndex).flatMap(parseDecimal) : nil // Notes let notes: String? = mapping.notesIndex >= 0 ? row.safeValue(at: mapping.notesIndex).flatMap { $0.isEmpty ? nil : $0 } : nil let snapshot = ImportedSnapshot(date: date, value: value, contribution: contribution, notes: notes) grouped[fallbackAccount, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot) } return grouped.map { accountName, categories in let importedCategories = categories.map { categoryName, sources in let importedSources = sources.map { sourceName, snapshots in ImportedSource(name: sourceName, snapshots: snapshots) } return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources) } return ImportedAccount( name: accountName, currency: nil, inputMode: .simple, notificationFrequency: .monthly, customFrequencyMonths: 1, categories: importedCategories ) } } // MARK: - CSV Helpers private func parseCSVRows(_ content: String) -> [[String]] { var rows: [[String]] = [] var currentRow: [String] = [] var currentField = "" var insideQuotes = false for char in content { if char == "\"" { insideQuotes.toggle() continue } if char == "," && !insideQuotes { currentRow.append(currentField) currentField = "" continue } if char == "\n" && !insideQuotes { currentRow.append(currentField) rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) }) currentRow = [] currentField = "" continue } currentField.append(char) } if !currentField.isEmpty || !currentRow.isEmpty { currentRow.append(currentField) rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) }) } return rows } private func parseDate(_ value: String) -> Date? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } if let iso = ISO8601DateFormatter().date(from: trimmed) { return iso } let formats = [ "yyyy-MM-dd", "yyyy/MM/dd", "dd/MM/yyyy", "MM/dd/yyyy", "dd-MM-yyyy", "MM-dd-yyyy", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss", "yyyy/MM/dd HH:mm", "yyyy/MM/dd HH:mm:ss", "dd/MM/yyyy HH:mm", "dd/MM/yyyy HH:mm:ss", "MM/dd/yyyy HH:mm", "MM/dd/yyyy HH:mm:ss", "dd-MM-yyyy HH:mm", "dd-MM-yyyy HH:mm:ss", "MM-dd-yyyy HH:mm", "MM-dd-yyyy HH:mm:ss", "dd/MM/yyyy h:mm a", "dd/MM/yyyy h:mm:ss a", "MM/dd/yyyy h:mm a", "MM/dd/yyyy h:mm:ss a", "dd-MM-yyyy h:mm a", "dd-MM-yyyy h:mm:ss a", "MM-dd-yyyy h:mm a", "MM-dd-yyyy h:mm:ss a" ] let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = .current for format in formats { formatter.dateFormat = format if let date = formatter.date(from: trimmed) { return date } } return nil } private func parseDecimal(_ value: String) -> Decimal? { let cleaned = value .replacingOccurrences(of: ",", with: ".") .trimmingCharacters(in: .whitespaces) guard !cleaned.isEmpty else { return nil } return Decimal(string: cleaned) } } private extension Array where Element == String { func safeValue(at index: Int) -> String? { guard index >= 0, index < count else { return nil } return self[index] } }