Files

239 lines
9.6 KiB
Swift

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/<input> 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
}
}