Implement bank transfer app flows

This commit is contained in:
2026-06-08 00:03:57 +05:00
parent ef877217ad
commit 9b281d48a7
127 changed files with 8008 additions and 90 deletions
@@ -0,0 +1,500 @@
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
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))
}
}
}