239 lines
9.6 KiB
Swift
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
|
|
}
|
|
}
|