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,59 @@
import Foundation
import Observation
@Observable
final class AppViewModel {
enum Route: Equatable {
case onboarding
case login
case lock
case home
}
private(set) var route: Route
init() {
route = AppViewModel.initialRoute()
}
// MARK: - Route transitions (called by child views)
func completeOnboarding() {
UserDefaults.standard.set(true, forKey: "onboarding_done")
route = .login
}
func loginSucceeded() {
route = AppViewModel.requiresLock() ? .lock : .home
}
func unlock() {
route = .home
}
func lock() {
route = .lock
}
func logout() {
route = .login
}
// MARK: - Private
private static func initialRoute() -> Route {
guard UserDefaults.standard.bool(forKey: "onboarding_done") else {
return .onboarding
}
guard CredentialStore.shared.hasAnyCredentials() else {
return .login
}
return requiresLock() ? .lock : .home
}
private static func requiresLock() -> Bool {
let method = UserDefaults.standard.string(forKey: "security_method") ?? ""
return !method.isEmpty && method != "none"
}
}
@@ -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))
}
}
}
@@ -0,0 +1,85 @@
import Foundation
import Observation
@Observable
final class LockViewModel {
private static let maxAttempts = 5
private static let lockoutDuration = 30.0
var pinDigits: [Int] = []
var hintMessage = ""
var isVerifying = false
private let autoUnlock: Bool
private(set) var pinLength: Int
init() {
self.autoUnlock = UserDefaults.standard.bool(forKey: "auto_unlock_pin")
self.pinLength = max(4, UserDefaults.standard.integer(forKey: "pin_length"))
}
// MARK: - Display
var dotsDisplay: String {
let n = pinDigits.count
let total = autoUnlock ? pinLength : max(n, 4)
return String(repeating: "", count: n)
+ String(repeating: "", count: max(total - n, 0))
}
// MARK: - Lockout
var lockoutRemaining: TimeInterval {
let fails = UserDefaults.standard.integer(forKey: "lock_fail_count")
guard fails >= Self.maxAttempts else { return 0 }
let lastFail = UserDefaults.standard.double(forKey: "lock_last_fail_time")
let elapsed = Date().timeIntervalSince1970 - lastFail
return max(0, Self.lockoutDuration - elapsed)
}
var isLockedOut: Bool { lockoutRemaining > 0 }
func resetFailures() {
UserDefaults.standard.removeObject(forKey: "lock_fail_count")
UserDefaults.standard.removeObject(forKey: "lock_last_fail_time")
}
@discardableResult
func incrementFailures() -> Int {
let fails = UserDefaults.standard.integer(forKey: "lock_fail_count") + 1
UserDefaults.standard.set(fails, forKey: "lock_fail_count")
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lock_last_fail_time")
return fails
}
// MARK: - Key handling
// Returns true when PIN should be submitted
func handleKey(_ key: String) -> Bool {
guard !isVerifying else { return false }
switch key {
case "":
if !pinDigits.isEmpty { pinDigits.removeLast() }
case "":
if pinDigits.count >= 4 { return true }
default:
if let d = Int(key), pinDigits.count < 8 {
pinDigits.append(d)
if autoUnlock && pinDigits.count == pinLength { return true }
}
}
return false
}
func currentPin() -> String { pinDigits.map(String.init).joined() }
func clearPin() { pinDigits = [] }
func failureHint() -> String {
let fails = incrementFailures()
let remaining = Self.maxAttempts - fails
if remaining <= 0 {
return "Too many attempts. Try again in \(Int(Self.lockoutDuration))s."
}
return "\(remaining) attempt\(remaining == 1 ? "" : "s") remaining"
}
}
@@ -0,0 +1,138 @@
import Foundation
import Observation
@Observable
final class LoginViewModel {
enum LoginState {
case idle
case loading(String)
case fahipayNeedTotp
case error(String)
}
var state: LoginState = .idle
// Persisted across navigation for Fahipay 2-step flow
private var fahipayFlow: FahipayLoginFlow?
private var fahipayIdCard = ""
private var fahipayPassword = ""
// MARK: - MIB
func loginMib(username: String, password: String, otpSeed: String) async throws -> [BankAccount] {
let hash = MibCrypto.hashPassword(password)
let flow = MibLoginFlow()
let accounts = try await flow.login(username: username, passwordHash: hash, otpSeed: otpSeed)
try? CredentialStore.shared.save(hash, forKey: CredentialStore.Keys.mibPassword(username))
try? CredentialStore.shared.save(otpSeed, forKey: CredentialStore.Keys.mibOtpSeed(username))
try? CredentialStore.shared.addLoginId(username, toBank: "MIB")
return accounts
}
// MARK: - BML
func loginBml(username: String, password: String, otpSeed: String) async throws -> [BankAccount] {
let loginTag = "bml_\(username)"
let flow = BmlLoginFlow()
let profiles = try await flow.login(username: username, password: password, otpSeed: otpSeed)
guard !profiles.isEmpty else { throw BmlError.loginFailed("No profiles found for this account") }
var accumulated: [BankAccount] = []
for profile in profiles where profile.profileType != "business" {
let result = try await flow.activateProfile(profile, loginTag: loginTag)
if case .success(let session, let accs) = result {
accumulated += accs
saveSession(session, profileId: profile.profileId)
}
}
try? CredentialStore.shared.save(password, forKey: CredentialStore.Keys.bmlPassword(username))
try? CredentialStore.shared.save(otpSeed, forKey: CredentialStore.Keys.bmlOtpSeed(username))
try? CredentialStore.shared.addLoginId(username, toBank: "BML")
let ids = profiles.map { $0.profileId }
try? CredentialStore.shared.saveStringArray(ids, forKey: CredentialStore.Keys.bmlProfiles(username))
return accumulated
}
private func saveSession(_ session: BmlSession, profileId: String) {
try? CredentialStore.shared.save(session.accessToken, forKey: CredentialStore.Keys.bmlAccessToken(profileId))
try? CredentialStore.shared.save(session.deviceId, forKey: CredentialStore.Keys.bmlDeviceId(profileId))
if !session.refreshToken.isEmpty {
try? CredentialStore.shared.save(session.refreshToken, forKey: CredentialStore.Keys.bmlRefreshToken(profileId))
}
if session.expiresAt > 0 {
try? CredentialStore.shared.save(String(session.expiresAt), forKey: CredentialStore.Keys.bmlExpTime(profileId))
}
}
// MARK: - Fahipay step 1
func loginFahipay(idCard: String, password: String) async throws -> FahipayLoginStep {
let flow = FahipayLoginFlow()
fahipayFlow = flow
fahipayIdCard = idCard
fahipayPassword = password
return try await flow.login(idCard: idCard, password: password, deviceUuid: fahipayDeviceUuid())
}
// MARK: - Fahipay step 2 (TOTP)
func verifyFahipayTotp(_ code: String) async throws {
guard let flow = fahipayFlow else { throw FahipayError.sessionExpired }
let authId = try await flow.verifyTotp(code: code, deviceUuid: fahipayDeviceUuid())
let cookie = flow.getSessionCookieValue() ?? ""
saveFahipaySession(authId: authId, sessionCookie: cookie)
}
// Called when no 2FA is needed (authId already returned from step 1)
func completeFahipayLogin(authId: String) {
let cookie = fahipayFlow?.getSessionCookieValue() ?? ""
saveFahipaySession(authId: authId, sessionCookie: cookie)
}
private func saveFahipaySession(authId: String, sessionCookie: String) {
// Use authId as the persisted login identifier
let loginId = authId
try? CredentialStore.shared.save(fahipayIdCard, forKey: CredentialStore.Keys.fahipayIdCard(loginId))
try? CredentialStore.shared.save(fahipayPassword, forKey: CredentialStore.Keys.fahipayPassword(loginId))
try? CredentialStore.shared.save(sessionCookie, forKey: CredentialStore.Keys.fahipaySessionCookie(loginId))
try? CredentialStore.shared.save(authId, forKey: CredentialStore.Keys.fahipayAuthId(loginId))
try? CredentialStore.shared.addLoginId(loginId, toBank: "FAHIPAY")
}
// MARK: - Helpers
private func fahipayDeviceUuid() -> String {
let key = "fahipay_device_uuid"
if let existing = CredentialStore.shared.load(forKey: key) { return existing }
let uuid = FahipayLoginFlow.generateDeviceUuid()
try? CredentialStore.shared.save(uuid, forKey: key)
return uuid
}
// Strips otpauth:// URI and whitespace/dashes, uppercases mirrors Android resolveOtpSeed
static func resolveOtpSeed(_ raw: String) -> String {
var secret = raw.trimmingCharacters(in: .whitespaces)
if secret.lowercased().hasPrefix("otpauth://totp/"),
let url = URLComponents(string: secret),
let s = url.queryItems?.first(where: { $0.name == "secret" })?.value {
secret = s
}
return secret.replacingOccurrences(of: " ", with: "")
.replacingOccurrences(of: "-", with: "")
.uppercased()
}
// Returns true if the string looks like a valid Base32 OTP seed (not a 6-digit code)
static func isValidOtpSeed(_ raw: String) -> Bool {
let seed = resolveOtpSeed(raw)
if seed.isEmpty { return false }
if seed.count == 6 && seed.allSatisfy({ $0.isNumber }) { return false } // reject plain codes
let base32 = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=")
return seed.unicodeScalars.allSatisfy { base32.contains($0) }
}
}
@@ -0,0 +1,327 @@
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")
}
}