import Foundation import Observation // Pending BML transfer kept between step 1 (initiate) and step 2 (OTP confirm). struct BmlPendingTransfer: Sendable { let fromAccount: BankAccount let toAccount: String let toName: String let amount: Double let currency: String let remarks: String let trnType: String let bank: String? let bmlSession: BmlSession } @MainActor @Observable final class TransferViewModel { // MARK: - Form state var fromAccount: BankAccount? = nil var toInput: String = "" var amountInput: String = "" var purposeInput: String = "" // MARK: - Lookup enum LookupState { case idle case loading case found(MibAccountLookupResult) // MIB lookup result with full details case bmlReady(String) // BML: account name returned by server case error(String) } var lookupState: LookupState = .idle // MARK: - Transfer flow enum TransferState: Equatable { case idle case bmlStep2 // BML 2-step: awaiting OTP entry case processing case success(TransferReceiptData) case failure(String) } var transferState: TransferState = .idle private var bmlPending: BmlPendingTransfer? = nil var bmlTrnType: String = "IAT" // set from lookup result; "IAT" BML-to-BML, "QTR" Favara, "DOT" other bank var bmlBank: String? = nil // Kotlin sends bank="MIB" for MVR BML-to-MIB DOT transfers var bmlCreditAccountOverride: String? = nil // MARK: - Derived var parsedAmount: Double { Double(amountInput.replacingOccurrences(of: ",", with: "")) ?? 0 } var canLookup: Bool { guard let bank = fromAccount?.bank else { return false } return (bank == "MIB" || bank == "BML") && !toInput.trimmingCharacters(in: .whitespaces).isEmpty } var canTransfer: Bool { fromAccount != nil && parsedAmount > 0 && hasValidLookup } private var hasValidLookup: Bool { switch lookupState { case .found, .bmlReady: return true default: // BML: allow submit without explicit lookup (server validates) return fromAccount?.bank == "BML" && !toInput.isEmpty } } // MARK: - Beneficiary lookup func lookupBeneficiary() async { guard let from = fromAccount else { return } let query = toInput.trimmingCharacters(in: .whitespaces) guard !query.isEmpty else { return } lookupState = .loading if from.bank == "MIB" { do { let session = try await loadMibSession(loginTag: from.loginTag) let client = MibTransferClient(session: session) let result = try await client.lookupAccount(query) lookupState = .found(result) } catch { lookupState = .error(error.localizedDescription) } } else if from.bank == "BML" { do { let sess = try await loadBmlSession(loginTag: from.loginTag) let client = BmlTransferClient(bmlSession: sess) let result = try await client.lookupAccount(query) bmlTrnType = result.trnType bmlBank = result.bank toInput = result.accountNumber // use resolved account number lookupState = .bmlReady(result.accountName) } catch { lookupState = .error(error.localizedDescription) } } } // MARK: - Submit transfer func submitTransfer() async { guard let from = fromAccount, parsedAmount > 0 else { return } transferState = .processing do { switch from.bank { case "MIB": let receipt = try await performMibTransfer(from: from) transferState = .success(receipt) case "BML": try await initiateBmlTransfer(from: from) transferState = .bmlStep2 default: transferState = .failure("Transfers not yet supported for \(from.bank)") } } catch { transferState = .failure(error.localizedDescription) } } // MARK: - BML OTP confirm func confirmBmlOtp(otpSeed: String) async { guard let pending = bmlPending else { return } transferState = .processing let otp = Totp.generate(otpSeed) do { let receipt = try await finalizeBmlTransfer(pending: pending, otp: otp) transferState = .success(receipt) bmlPending = nil } catch { transferState = .failure(error.localizedDescription) } } func reset() { fromAccount = nil toInput = "" amountInput = "" purposeInput = "" lookupState = .idle transferState = .idle bmlPending = nil bmlTrnType = "IAT" bmlBank = nil bmlCreditAccountOverride = nil } // MARK: - MIB transfer private func performMibTransfer(from: BankAccount) async throws -> TransferReceiptData { guard case .found(let lookup) = lookupState else { throw MibError.invalidResponse } let username = from.loginTag.replacingOccurrences(of: "mib_", with: "") guard let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username)), let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username)) else { throw MibError.serverError("MIB credentials not found. Please log in again.") } // Generate TOTP on @MainActor before entering actor context. let otp = Totp.generate(seed) let flow = MibLoginFlow() _ = try await flow.login(username: username, passwordHash: hash, otpSeed: seed) guard let session = await flow.lastSession else { throw MibError.sessionExpired } let client = MibTransferClient(session: session) let result = try await client.executeTransfer( from: from, to: lookup, amount: parsedAmount, currency: from.currencyName, purpose: purposeInput, otp: otp ) return TransferReceiptData( fromAccountNumber: from.accountNumber, fromBankName: "Maldives Islamic Bank", toAccountNumber: lookup.accountNumber, toAccountName: lookup.accountName, amount: parsedAmount, currency: from.currencyName, reference: result.trxId, date: result.date, message: "Transfer successful" ) } // MARK: - BML transfer private func initiateBmlTransfer(from: BankAccount) async throws { guard from.profileType == "BML" else { throw BmlError.serverError("BML card accounts cannot be used as the debit account for transfers. Select a BML account instead.") } let sess = try await loadBmlSession(loginTag: from.loginTag) let source = try await resolveBmlDebitSource(from, session: sess) let client = BmlTransferClient(bmlSession: sess) let overrideAccount = bmlCreditAccountOverride?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let toAcc = overrideAccount.isEmpty ? toInput.trimmingCharacters(in: .whitespaces) : overrideAccount let toName: String = { if case .bmlReady(let n) = lookupState, !n.isEmpty { return n } return "" }() _ = try await client.initiateTransfer( fromAccount: source, toAccount: toAcc, amount: parsedAmount, currency: source.currencyName, trnType: bmlTrnType, bank: bmlBank ) bmlPending = BmlPendingTransfer( fromAccount: source, toAccount: toAcc, toName: toName, amount: parsedAmount, currency: source.currencyName, remarks: purposeInput, trnType: bmlTrnType, bank: bmlBank, bmlSession: sess ) } private func resolveBmlDebitSource(_ account: BankAccount, session: BmlSession) async throws -> BankAccount { let currentId = (account.internalId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if !currentId.isEmpty { return account } let freshAccounts = try await BmlAccountClient(session: session).fetchAccounts( loginTag: account.loginTag, profileId: account.profileId ?? "" ) if let match = freshAccounts.first(where: { $0.accountNumber == account.accountNumber && $0.profileType == account.profileType && !(($0.internalId ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }) { return match } throw BmlError.serverError("BML debit account id missing. Refresh dashboard and select the account again.") } private func finalizeBmlTransfer(pending: BmlPendingTransfer, otp: String) async throws -> TransferReceiptData { let client = BmlTransferClient(bmlSession: pending.bmlSession) let receipt = try await client.confirmTransfer( fromAccount: pending.fromAccount, toAccount: pending.toAccount, amount: pending.amount, currency: pending.currency, trnType: pending.trnType, remarks: pending.remarks, otp: otp, bank: pending.bank ) return TransferReceiptData( fromAccountNumber: pending.fromAccount.accountNumber, fromBankName: "Bank of Maldives", toAccountNumber: pending.toAccount, toAccountName: pending.toName, amount: pending.amount, currency: pending.currency, reference: receipt.reference, date: receipt.timestamp, message: receipt.message ) } // MARK: - Session loaders private func loadMibSession(loginTag: String) async throws -> MibSession { let username = loginTag.replacingOccurrences(of: "mib_", with: "") guard let hash = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibPassword(username)), let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.mibOtpSeed(username)) else { throw MibError.serverError("MIB credentials not found") } let flow = MibLoginFlow() _ = try await flow.login(username: username, passwordHash: hash, otpSeed: seed) guard let session = await flow.lastSession else { throw MibError.sessionExpired } return session } private func loadBmlSession(loginTag: String) async throws -> BmlSession { let username = loginTag.replacingOccurrences(of: "bml_", with: "") let pids = CredentialStore.shared.loadStringArray(forKey: CredentialStore.Keys.bmlProfiles(username)) for pid in pids { guard let token = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlAccessToken(pid)), !token.isEmpty else { continue } 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)) ?? "" if expiresAt == 0 || now < expiresAt - 60_000 { return BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt) } // Token near expiry — refresh let stale = BmlSession(accessToken: token, deviceId: deviceId, refreshToken: refreshTok, expiresAt: expiresAt) return try await BmlLoginFlow().refreshSession(stale) } // Fall back to full re-login guard let pw = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlPassword(username)), let seed = CredentialStore.shared.load(forKey: CredentialStore.Keys.bmlOtpSeed(username)) else { throw BmlError.serverError("BML credentials not found") } let flow = BmlLoginFlow() let profiles = try await flow.login(username: username, password: pw, otpSeed: seed) guard let profile = profiles.first else { throw BmlError.serverError("No BML profile found") } if case .success(let sess, _) = try await flow.activateProfile(profile, loginTag: loginTag) { return sess } throw BmlError.serverError("BML session could not be established") } }