Files

328 lines
13 KiB
Swift

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")
}
}