import Foundation import Observation @MainActor @Observable final class HomeViewModel { var accounts: [BankAccount] = [] var isRefreshing = false var bankErrors: [String: String] = [:] var hideAmounts = false // Contacts var contacts: [BankContact] = [] var isLoadingContacts = false var contactsError: String? = nil // Foreign transaction limits (BML) var foreignLimits: [(userName: String, limits: [BmlForeignLimit])] = [] // MIB financing deals var mibFinancing: [MibFinanceDeal] = [] var totalPendingFinances: Double { mibFinancing.reduce(0) { $0 + $1.outstandingAmount } } // Set this to navigate Transfer tab pre-filled with a contact var pendingTransferContact: BankContact? = nil // Set this to navigate Transfer tab with QR-scanned data var pendingTransferQRData: PaymvQrResult? = nil // Set this to navigate Transfer tab with a BML gateway QR URL var pendingBmlQrUrl: String? = nil // The card account number that the QR was scanned from (card machine) var pendingQrFromCard: String? = nil private let cacheRefreshInterval: TimeInterval = 5 * 60 private var lastDashboardRefresh = Date.distantPast private var lastContactsRefresh = Date.distantPast private enum DashboardRefreshResult { case mib(accounts: [BankAccount], financing: [MibFinanceDeal], error: String?) case bml(accounts: [BankAccount], limits: [(userName: String, limits: [BmlForeignLimit])], error: String?) } init() { accounts = AccountCache.shared.load() contacts = Self.mergeContacts(ContactsCache.shared.load(), saved: SavedContactsStore.shared.load()) if let dashboard = DashboardCache.shared.load() { foreignLimits = dashboard.foreignLimits.map { (userName: $0.userName, limits: $0.limits) } mibFinancing = dashboard.mibFinancing } if !accounts.isEmpty && (!foreignLimits.isEmpty || !mibFinancing.isEmpty) { lastDashboardRefresh = Date() } if !contacts.isEmpty { lastContactsRefresh = Date() } } // MARK: - Grouped views var mibAccounts: [BankAccount] { casaAccounts.filter { $0.bank == "MIB" } } var bmlAccounts: [BankAccount] { casaAccounts.filter { $0.bank == "BML" } } var fahipayAccounts: [BankAccount] { accounts.filter { $0.bank == "FAHIPAY" } } var cardAccounts: [BankAccount] { accounts.filter { $0.profileType == "BML_CREDIT" || $0.profileType == "BML_DEBIT" || $0.profileType == "BML_PREPAID" || $0.profileType == "MIB_CARD" } } private var casaAccounts: [BankAccount] { accounts.filter { $0.profileType != "BML_CREDIT" && $0.profileType != "BML_DEBIT" && $0.profileType != "BML_PREPAID" && $0.profileType != "BML_LOAN" && $0.profileType != "MIB_CARD" } } // MARK: - Balance totals (CASA only) var totalMvrBalance: Double { casaAccounts.filter { $0.currencyName == "MVR" }.reduce(0) { $0 + $1.availableBalance } } var totalUsdBalance: Double { casaAccounts.filter { $0.currencyName == "USD" }.reduce(0) { $0 + $1.availableBalance } } // MARK: - Credit / prepaid totals var hasCreditAccounts: Bool { !bmlCardAccounts.isEmpty } var totalMvrCredit: Double { bmlCardAccounts.filter { $0.currencyName == "MVR" }.reduce(0) { $0 + $1.availableBalance } } var totalUsdCredit: Double { bmlCardAccounts.filter { $0.currencyName == "USD" }.reduce(0) { $0 + $1.availableBalance } } private var bmlCardAccounts: [BankAccount] { accounts.filter { $0.profileType == "BML_CREDIT" || $0.profileType == "BML_DEBIT" || $0.profileType == "BML_PREPAID" } } // MARK: - Blocked amounts var totalBlockedMvr: Double { accounts.filter { $0.currencyName == "MVR" }.reduce(0) { $0 + $1.blockedAmount } } var totalBlockedUsd: Double { accounts.filter { $0.currencyName == "USD" }.reduce(0) { $0 + $1.blockedAmount } } // MARK: - Formatting func fmt(_ amount: Double, currency: String) -> String { "\(currency) \(Self.balanceFmt.string(from: NSNumber(value: amount)) ?? "0.00")" } private static let balanceFmt: NumberFormatter = { let f = NumberFormatter() f.numberStyle = .decimal f.minimumFractionDigits = 2 f.maximumFractionDigits = 2 f.groupingSeparator = "," f.usesGroupingSeparator = true return f }() // MARK: - Refresh // MIB and BML run in parallel; within each bank one login/session is shared for // all sub-requests (accounts + financing for MIB, accounts + limits for BML). func refreshIfNeeded() async { guard accounts.isEmpty || Date().timeIntervalSince(lastDashboardRefresh) >= cacheRefreshInterval else { return } await refresh() } func refresh() async { guard !isRefreshing else { return } isRefreshing = true bankErrors = [:] var completedRefresh = false defer { if completedRefresh { lastDashboardRefresh = Date() } isRefreshing = false } await withTaskGroup(of: DashboardRefreshResult.self) { group in group.addTask { let result = await Self.fetchMibAll() return .mib(accounts: result.accounts, financing: result.financing, error: result.error) } group.addTask { let result = await Self.fetchBmlAll() return .bml(accounts: result.accounts, limits: result.limits, error: result.error) } for await result in group { guard !Task.isCancelled else { return } switch result { case .mib(let freshAccounts, let financing, let error): if let error { bankErrors["MIB"] = error } else { replaceAccounts(for: "MIB", with: freshAccounts) mibFinancing = financing DashboardCache.shared.save(foreignLimits: foreignLimits, mibFinancing: mibFinancing) } case .bml(let freshAccounts, let limits, let error): if let error { bankErrors["BML"] = error } else { replaceAccounts(for: "BML", with: freshAccounts) foreignLimits = limits DashboardCache.shared.save(foreignLimits: foreignLimits, mibFinancing: mibFinancing) } } } } completedRefresh = !Task.isCancelled } private func replaceAccounts(for bank: String, with freshAccounts: [BankAccount]) { guard !freshAccounts.isEmpty || !accounts.contains(where: { $0.bank == bank }) else { return } accounts.removeAll { $0.bank == bank } accounts += freshAccounts AccountCache.shared.save(accounts) } // MARK: - MIB: one login per user → accounts + financing with the same session private static func fetchMibAll() async -> (accounts: [BankAccount], financing: [MibFinanceDeal], error: String?) { let ids = CredentialStore.shared.loginIds(for: "MIB") var allAccounts: [BankAccount] = [] var allFinancing: [MibFinanceDeal] = [] var lastErr: String? await withTaskGroup(of: (accounts: [BankAccount], financing: [MibFinanceDeal], error: String?).self) { group in for username in ids { group.addTask { await fetchMibUser(username: username) } } for await result in group { allAccounts += result.accounts allFinancing += result.financing if let e = result.error { lastErr = e } } } return (allAccounts, allFinancing, lastErr) } private static func fetchMibUser(username: String) async -> (accounts: [BankAccount], financing: [MibFinanceDeal], error: String?) { guard let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username)), let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username)) else { return ([], [], "MIB credentials missing for \(username). Please log in again.") } do { let flow = MibLoginFlow() let accs = try await flow.login(username: username, passwordHash: hash, otpSeed: seed) // Reuse the established session — no second login needed guard let session = await flow.lastSession else { return (accs, [], nil) } let deals = (try? await MibFinancingClient(session: session).fetchFinancing()) ?? [] return (accs, deals, nil) } catch { if Self.isCancellation(error) { return ([], [], nil) } return ([], [], error.localizedDescription) } } // MARK: - BML: one session per user → accounts + limits + userinfo in parallel private static func fetchBmlAll() async -> (accounts: [BankAccount], limits: [(String, [BmlForeignLimit])], error: String?) { let ids = CredentialStore.shared.loginIds(for: "BML") var allAccounts: [BankAccount] = [] var allLimits: [(String, [BmlForeignLimit])] = [] var lastErr: String? for username in ids { let r = await fetchBmlUserAll(username: username) allAccounts += r.accounts if let lim = r.limits { allLimits.append(lim) } if let e = r.error { lastErr = e } } return (allAccounts, allLimits, lastErr) } private static func fetchBmlUserAll(username: String) async -> (accounts: [BankAccount], limits: (String, [BmlForeignLimit])?, error: String?) { let loginTag = "bml_\(username)" let pids = CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles(username)) // Try cached profiles first if !pids.isEmpty { // Load first valid session to use for limits/userinfo var firstSession: BmlSession? = nil var accountsAll: [BankAccount] = [] var accountsError: String? = nil for pid in pids { guard let token = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlAccessToken(pid)), !token.isEmpty else { // Token missing — fall back to full login return await bmlFullLoginAll(username: username) } let expStr = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlExpTime(pid)) ?? "0" let expiresAt = Int64(expStr) ?? 0 let now = Int64(Date().timeIntervalSince1970 * 1000) let deviceId = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlDeviceId(pid)) ?? "" let refreshTok = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlRefreshToken(pid)) ?? "" var sess = BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt) if expiresAt > 0 && now >= expiresAt - 60_000 { do { let fresh = try await BmlLoginFlow().refreshSession(sess) saveBmlSession(fresh, profileId: pid) sess = fresh } catch { if Self.isCancellation(error) { return ([], nil, nil) } return await bmlFullLoginAll(username: username) } } if firstSession == nil { firstSession = sess } do { let accs = try await BmlAccountClient(session: sess) .fetchAccounts(loginTag: loginTag, profileId: pid) accountsAll += accs } catch { if Self.isCancellation(error) { return ([], nil, nil) } if case BmlError.sessionExpired = error { return await bmlFullLoginAll(username: username) } accountsError = error.localizedDescription } } // Fetch limits + userinfo using first session, in parallel with nothing else to wait on var limitsTuple: (String, [BmlForeignLimit])? = nil if let sess = firstSession { limitsTuple = await fetchBmlLimits(session: sess, username: username) } return (accountsAll, limitsTuple, accountsError) } // No cached profiles → full login return await bmlFullLoginAll(username: username) } private static func bmlFullLoginAll(username: String) async -> (accounts: [BankAccount], limits: (String, [BmlForeignLimit])?, error: String?) { guard let pw = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlPassword(username)), let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlOtpSeed(username)) else { return ([], nil, "BML credentials missing for \(username). Please log in again.") } do { let flow = BmlLoginFlow() let loginTag = "bml_\(username)" let profiles = try await flow.login(username: username, password: pw, otpSeed: seed) var allAccounts: [BankAccount] = [] var firstSession: BmlSession? = nil for p in profiles where p.profileType != "business" { if case .success(let sess, let accs) = try await flow.activateProfile(p, loginTag: loginTag) { allAccounts += accs saveBmlSession(sess, profileId: p.profileId) if firstSession == nil { firstSession = sess } } } try? CredentialStore.shared.saveStringArray( profiles.map { $0.profileId }, forKey: CredentialStore.Keys.bmlProfiles(username) ) var limitsTuple: (String, [BmlForeignLimit])? = nil if let sess = firstSession { limitsTuple = await fetchBmlLimits(session: sess, username: username) } return (allAccounts, limitsTuple, nil) } catch { if Self.isCancellation(error) { return ([], nil, nil) } return ([], nil, error.localizedDescription) } } // Fetch limits + userinfo in parallel using an already-established session private static func fetchBmlLimits(session: BmlSession, username: String) async -> (String, [BmlForeignLimit])? { do { async let limitsTask = BmlForeignLimitsClient().fetchForeignLimits(session: session) async let userInfoTask = BmlAccountClient(session: session).fetchUserInfo() let (limits, userInfo) = try await (limitsTask, userInfoTask) let displayName = userInfo?.fullName.isEmpty == false ? userInfo!.fullName : username return (displayName, limits) } catch { return nil } } nonisolated private static func isCancellation(_ error: Error) -> Bool { if error is CancellationError { return true } if let urlError = error as? URLError, urlError.code == .cancelled { return true } let nsError = error as NSError return nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled } // MARK: - Contacts func fetchContactsIfNeeded() async { guard contacts.isEmpty || Date().timeIntervalSince(lastContactsRefresh) >= cacheRefreshInterval else { return } await fetchContacts() } func fetchContacts() async { guard !isLoadingContacts else { return } isLoadingContacts = true contactsError = nil var completedFetch = false defer { if completedFetch { lastContactsRefresh = Date() } isLoadingContacts = false } // MIB and BML contacts fetched in parallel async let mibResult = Self.fetchMibContacts() async let bmlResult = Self.fetchBmlContacts() let (mibC, bmlC) = await (mibResult, bmlResult) let fetched = mibC.contacts + bmlC.contacts if !fetched.isEmpty || contacts.isEmpty { contacts = Self.mergeContacts(fetched, saved: SavedContactsStore.shared.load()) ContactsCache.shared.save(contacts) } if let e = mibC.error { contactsError = e } if let e = bmlC.error { contactsError = (contactsError.map { $0 + "; " } ?? "") + e } completedFetch = true } private static func mergeContacts(_ base: [BankContact], saved: [BankContact]) -> [BankContact] { var merged = base for contact in saved where !merged.contains(where: { $0.benefAccount == contact.benefAccount && $0.source == contact.source }) { merged.append(contact) } return merged } private static func fetchMibContacts() async -> (contacts: [BankContact], error: String?) { let ids = CredentialStore.shared.loginIds(for: "MIB") var all: [BankContact] = [] var lastErr: String? // Each MIB user runs in parallel; credentials captured before entering the task await withTaskGroup(of: (contacts: [BankContact], error: String?).self) { group in for username in ids { let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username)) let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username)) let loginTag = "mib_\(username)" guard let hash, let seed else { continue } group.addTask { do { let flow = MibLoginFlow() _ = try await flow.login(username: username, passwordHash: hash, otpSeed: seed) guard let session = await flow.lastSession else { return ([], nil) } let contacts = try await MibContactsClient(session: session).fetchContacts(loginTag: loginTag) return (contacts, nil) } catch { if Self.isCancellation(error) { return ([], nil) } return ([], error.localizedDescription) } } } for await result in group { all += result.contacts if let e = result.error { lastErr = e } } } return (all, lastErr) } private static func fetchBmlContacts() async -> (contacts: [BankContact], error: String?) { let ids = CredentialStore.shared.loginIds(for: "BML") var all: [BankContact] = [] var lastErr: String? for username in ids { let loginTag = "bml_\(username)" let pids = CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles(username)) guard let pid = pids.first, let token = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlAccessToken(pid)), !token.isEmpty else { continue } let deviceId = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlDeviceId(pid)) ?? "" let refreshTok = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlRefreshToken(pid)) ?? "" let expiresAt = Int64(CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlExpTime(pid)) ?? "0") ?? 0 let sess = BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt) do { let contacts = try await BmlContactsClient(bmlSession: sess).fetchContacts(loginTag: loginTag) all += contacts } catch { if Self.isCancellation(error) { continue } lastErr = error.localizedDescription } } return (all, lastErr) } func saveContact(_ contact: BankContact) { SavedContactsStore.shared.save(contact) contacts = Self.mergeContacts(contacts, saved: SavedContactsStore.shared.load()) ContactsCache.shared.save(contacts) } // MARK: - Grouped contacts var mibContacts: [BankContact] { contacts.filter { $0.source == "MIB" } } var bmlContacts: [BankContact] { contacts.filter { $0.source == "BML" } } var fahipayContacts: [BankContact] { contacts.filter { $0.source == "FAHIPAY" } } func contacts(matching query: String) -> [BankContact] { guard !query.isEmpty else { return contacts } let q = query.lowercased() return contacts.filter { $0.displayName.lowercased().contains(q) || $0.benefAccount.contains(q) || ($0.benefNickName?.lowercased().contains(q) ?? false) } } private static func saveBmlSession(_ sess: BmlSession, profileId: String) { try? CredentialStore.shared.save(sess.accessToken, forKey: CredentialStore.Keys.bmlAccessToken(profileId)) try? CredentialStore.shared.save(sess.deviceId, forKey: CredentialStore.Keys.bmlDeviceId(profileId)) if !sess.refreshToken.isEmpty { try? CredentialStore.shared.save(sess.refreshToken, forKey: CredentialStore.Keys.bmlRefreshToken(profileId)) } if sess.expiresAt > 0 { try? CredentialStore.shared.save(String(sess.expiresAt), forKey: CredentialStore.Keys.bmlExpTime(profileId)) } } }