Implement bank transfer app flows
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user