328 lines
13 KiB
Swift
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")
|
|
}
|
|
}
|