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 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) 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) } 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 : "Personal" 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) -> [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 name = allowMultipleAccounts ? rawName : "Personal" 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 ) } return [ ImportedAccount( name: "Personal", 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 errors: [String] = [] var categoryLookup = buildCategoryLookup(from: categoryRepository.categories) 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] = [:] for importedAccount in accounts { let existingAccount = accountRepository.accounts.first(where: { $0.name == importedAccount.name }) let account = existingAccount ?? accountRepository.createAccount( name: importedAccount.name, currency: importedAccount.currency, inputMode: importedAccount.inputMode, notificationFrequency: importedAccount.notificationFrequency, customFrequencyMonths: importedAccount.customFrequencyMonths ) if existingAccount == nil { 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 existingSource = sourceRepository.sources.first(where: { $0.name == importedSource.name && $0.account?.id == account.id }) 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 { 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, 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 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 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] } }