import Foundation struct BmlAccountLookupResult: Sendable { let accountNumber: String let accountName: String let bankName: String let trnType: String // "IAT" BML-to-BML, "QTR" Favara, "DOT" other bank let bank: String? } struct BmlTransferInit: Sendable { let code: Int // 22 = OTP required } struct BmlTransferReceipt: Sendable { let reference: String let timestamp: String let message: String } // BML 2-step transfer: // Step 1 — POST /api/mobile/transfer with transfer details (no otp). // Server responds {"success":true,"code":22} when OTP is required. // Step 2 — POST same endpoint adding otp field. // Server responds {"success":true,"payload":{"reference":...,"timestamp":...}}. // // Field names from Kotlin: debitAccount, creditAccount, debitAmount, transfertype, currency, channel, otp, remarks final class BmlTransferClient { private let bmlSession: BmlSession private let urlSession: URLSession private let base = "https://www.bankofmaldives.com.mv/internetbanking" private let ua = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)" private let ver = "2.1.44.348" init(bmlSession: BmlSession) { self.bmlSession = bmlSession let cfg = URLSessionConfiguration.default cfg.timeoutIntervalForRequest = 30 self.urlSession = URLSession(configuration: cfg) } // MARK: - Account lookup func lookupAccount(_ input: String) async throws -> BmlAccountLookupResult { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) if Self.isMibAccount(trimmed) { return try await lookupMibAccount(trimmed) } // Kotlin uses /api/mobile/validate/account/ for BML/Favara validation. let data = try await apiGet("/api/mobile/validate/account/\(Self.pathComponent(trimmed))") guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], root["success"] as? Bool == true, let payload = root["payload"] as? [String: Any] else { throw BmlError.serverError("Account not found") } return Self.lookupResult(from: payload, fallbackAccount: trimmed, defaultType: "IAT", defaultBank: nil) } private func lookupMibAccount(_ account: String) async throws -> BmlAccountLookupResult { // Kotlin BmlValidateClient verifies MIB accounts through the Favara account-verification endpoint. let data = try await apiGet("/api/mobile/favara/account-verification/\(Self.pathComponent(account))/MIB") guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], root["success"] as? Bool == true else { throw BmlError.serverError("Account not found") } let payload = root["payload"] as? [String: Any] ?? root return Self.lookupResult(from: payload, fallbackAccount: account, defaultType: "DOT", defaultBank: "MIB") } private static func lookupResult( from payload: [String: Any], fallbackAccount: String, defaultType: String, defaultBank: String? ) -> BmlAccountLookupResult { let trnType = payload["trnType"] as? String ?? payload["transferType"] as? String ?? defaultType let name = payload["name"] as? String ?? payload["account_name"] as? String ?? payload["accountName"] as? String ?? payload["contact_name"] as? String ?? payload["BfyNm"] as? String ?? "" let cdtr = payload["CdtrAcct"] as? [String: Any] let account = cdtr?["Acct"] as? String ?? payload["account"] as? String ?? payload["accountNumber"] as? String ?? payload["benefAccount"] as? String ?? fallbackAccount let bank = payload["bank"] as? String ?? defaultBank let bankName = bank == "MIB" ? "Maldives Islamic Bank" : "Bank of Maldives" return BmlAccountLookupResult( accountNumber: account, accountName: name, bankName: bankName, trnType: trnType, bank: bank ) } private static func isMibAccount(_ value: String) -> Bool { value.count == 17 && value.hasPrefix("9") && value.allSatisfy(\.isNumber) } private static func pathComponent(_ value: String) -> String { value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value } // MARK: - Step 1: Initiate (no OTP) func initiateTransfer( fromAccount: BankAccount, toAccount: String, amount: Double, currency: String, trnType: String, bank: String? ) async throws -> BmlTransferInit { let debitAccount = try debitAccountCode(fromAccount) var body: [String: Any] = [ "debitAccount": debitAccount, "creditAccount": toAccount, "debitAmount": amount, "transfertype": trnType, "currency": currency, "channel": "token" ] if let bank, !bank.isEmpty { body["bank"] = bank } let data = try await apiPost("/api/mobile/transfer", body: body) guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw BmlError.invalidResponse } guard root["success"] as? Bool == true else { let msg = root["message"] as? String ?? "" let code = root["code"] as? Int let extra = (root["payload"] as? [String: Any])?["message"] as? String ?? "" let full = [msg, extra].filter { !$0.isEmpty }.joined(separator: " — ") let suffix = code.map { " (code \($0))" } ?? "" throw BmlError.serverError(full.isEmpty ? "Transfer failed\(suffix)" : "\(full)\(suffix)") } return BmlTransferInit(code: root["code"] as? Int ?? 0) } // MARK: - Step 2: Confirm with OTP func confirmTransfer( fromAccount: BankAccount, toAccount: String, amount: Double, currency: String, trnType: String, remarks: String, otp: String, bank: String? ) async throws -> BmlTransferReceipt { let debitAccount = try debitAccountCode(fromAccount) var body: [String: Any] = [ "debitAccount": debitAccount, "creditAccount": toAccount, "debitAmount": amount, "transfertype": trnType, "currency": currency, "channel": "token", "otp": otp ] if !remarks.isEmpty { body["remarks"] = remarks } if let bank, !bank.isEmpty { body["bank"] = bank } let data = try await apiPost("/api/mobile/transfer", body: body) guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw BmlError.invalidResponse } guard root["success"] as? Bool == true else { let msg = root["message"] as? String ?? "" let code = root["code"] as? Int let extra = (root["payload"] as? [String: Any])?["message"] as? String ?? "" let full = [msg, extra].filter { !$0.isEmpty }.joined(separator: " — ") let suffix = code.map { " (code \($0))" } ?? "" throw BmlError.serverError(full.isEmpty ? "Transfer confirmation failed\(suffix)" : "\(full)\(suffix)") } let payload = root["payload"] as? [String: Any] ?? [:] return BmlTransferReceipt( reference: payload["reference"] as? String ?? "", timestamp: payload["timestamp"] as? String ?? "", message: root["message"] as? String ?? "Transfer successful" ) } private func debitAccountCode(_ account: BankAccount) throws -> String { let code = (account.internalId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !code.isEmpty else { throw BmlError.serverError("BML debit account id missing. Refresh dashboard and select the account again.") } return code } // MARK: - HTTP private func apiGet(_ path: String) async throws -> Data { var req = URLRequest(url: URL(string: "\(base)\(path)")!) req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization") req.setValue(ua, forHTTPHeaderField: "User-Agent") req.setValue(ver, forHTTPHeaderField: "x-app-version") let (data, resp) = try await urlSession.data(for: req) let code = (resp as? HTTPURLResponse)?.statusCode ?? 0 if code == 401 || code == 419 { throw BmlError.sessionExpired } if code >= 500 { throw BmlError.networkError("HTTP \(code)") } return data } private func apiPost(_ path: String, body: [String: Any]) async throws -> Data { var req = URLRequest(url: URL(string: "\(base)\(path)")!) req.httpMethod = "POST" req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization") req.setValue(ua, forHTTPHeaderField: "User-Agent") req.setValue(ver, forHTTPHeaderField: "x-app-version") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: body) let (data, resp) = try await urlSession.data(for: req) let code = (resp as? HTTPURLResponse)?.statusCode ?? 0 if code == 401 || code == 419 { throw BmlError.sessionExpired } if code >= 500 { throw BmlError.networkError("HTTP \(code)") } return data } }