Implement bank transfer app flows
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import Foundation
|
||||
|
||||
private let BML_API_BASE = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||
private let BML_API_UA = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
|
||||
private let BML_API_VERSION = "2.1.44.348"
|
||||
|
||||
final class BmlAccountClient {
|
||||
|
||||
private let session: BmlSession
|
||||
private let urlSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
init(session: BmlSession) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
// MARK: - Dashboard / Accounts
|
||||
|
||||
func fetchAccounts(
|
||||
loginTag: String,
|
||||
profileName: String = "Personal",
|
||||
profileId: String = ""
|
||||
) async throws -> [BankAccount] {
|
||||
let data = try await apiGet("/api/mobile/dashboard")
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
root["success"] as? Bool == true,
|
||||
let payload = root["payload"] as? [String: Any],
|
||||
let dashboard = payload["dashboard"] as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
return parseDashboard(dashboard, loginTag: loginTag, profileName: profileName, profileId: profileId)
|
||||
}
|
||||
|
||||
// MARK: - Profile check (lightweight ping to verify session)
|
||||
|
||||
func checkProfile() async throws {
|
||||
let data = try await apiGet("/api/mobile/profile")
|
||||
if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
root["success"] as? Bool == false {
|
||||
throw BmlError.sessionExpired
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User info
|
||||
|
||||
func fetchUserInfo() async throws -> BmlUserInfo? {
|
||||
let data = try await apiGet("/api/mobile/userinfo")
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
root["success"] as? Bool == true,
|
||||
let payload = root["payload"] as? [String: Any],
|
||||
let user = payload["user"] as? [String: Any] else { return nil }
|
||||
return BmlUserInfo(
|
||||
fullName: (user["fullname"] as? String ?? "").trimmingCharacters(in: .whitespaces),
|
||||
email: (user["email"] as? String ?? "").trimmingCharacters(in: .whitespaces),
|
||||
mobile: (user["mobile_phone"] as? String ?? "").trimmingCharacters(in: .whitespaces),
|
||||
customerId: (user["customer_number"] as? String ?? "").trimmingCharacters(in: .whitespaces),
|
||||
idCard: (user["idcard"] as? String ?? "").trimmingCharacters(in: .whitespaces),
|
||||
birthdate: (user["birthdate"] as? String ?? "").trimmingCharacters(in: .whitespaces)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Loan detail
|
||||
|
||||
func fetchLoanDetail(internalId: String) async throws -> BmlLoanDetail? {
|
||||
let data = try await apiGet("/api/mobile/account/\(internalId)")
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
root["success"] as? Bool == true,
|
||||
let p = root["payload"] as? [String: Any] else { return nil }
|
||||
return BmlLoanDetail(
|
||||
loanAmount: p["loanAmount"] as? Double ?? 0,
|
||||
outstandingAmt: p["outstandingAmt"] as? Double ?? 0,
|
||||
repayAmount: p["repayAmount"] as? Double ?? 0,
|
||||
intRate: p["intRate"] as? Double ?? 0,
|
||||
loanStatus: p["loanStatus"] as? String ?? "",
|
||||
startDate: p["startDate"] as? String ?? "",
|
||||
endDate: p["endDate"] as? String ?? "",
|
||||
noOfRepayOverdue: p["noOfRepayOverdue"] as? Int ?? 0,
|
||||
overdueAmount: p["overdueAmount"] as? Double ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Transfer OTP channels
|
||||
|
||||
func fetchTransferChannels() async throws -> [BmlOtpChannel] {
|
||||
let data = try await apiGet("/api/mobile/transfer")
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
root["success"] as? Bool == true,
|
||||
let arr = (root["payload"] as? [String: Any])?["transfer"] as? [String: Any],
|
||||
let channels = arr["otpChannel"] as? [[String: Any]] else { return [] }
|
||||
return channels.map {
|
||||
BmlOtpChannel(
|
||||
channel: $0["channel"] as? String ?? "",
|
||||
description: $0["description"] as? String ?? "",
|
||||
masked: $0["masked"] as? String ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dashboard parsing
|
||||
|
||||
private func parseDashboard(
|
||||
_ dashboard: [[String: Any]],
|
||||
loginTag: String,
|
||||
profileName: String,
|
||||
profileId: String
|
||||
) -> [BankAccount] {
|
||||
var casaAccounts: [BankAccount] = []
|
||||
var cardAccounts: [BankAccount] = []
|
||||
var loanAccounts: [BankAccount] = []
|
||||
|
||||
for item in dashboard {
|
||||
let currency = item["currency"] as? String ?? "MVR"
|
||||
let accountType = item["account_type"] as? String ?? "CASA"
|
||||
let product = item["product"] as? String ?? ""
|
||||
let accountNum = Self.stringValue(item["account"])
|
||||
let status = item["account_status"] as? String ?? "Active"
|
||||
let internalId = Self.stringValue(item["id"])
|
||||
|
||||
switch accountType {
|
||||
case "CASA":
|
||||
let avail = item["availableBalance"] as? Double ?? 0
|
||||
casaAccounts.append(BankAccount(
|
||||
id: "BML_\(accountNum)_\(loginTag)",
|
||||
bank: "BML",
|
||||
profileName: profileName,
|
||||
profileType: "BML",
|
||||
productCode: item["product_code"] as? String ?? "",
|
||||
accountNumber: accountNum,
|
||||
accountBriefName: item["alias"] as? String ?? "",
|
||||
currencyName: currency,
|
||||
accountTypeName: product,
|
||||
availableBalance: avail,
|
||||
currentBalance: item["ledgerBalance"] as? Double ?? 0,
|
||||
blockedAmount: item["lockedAmount"] as? Double ?? 0,
|
||||
mvrBalance: currency == "MVR" ? avail : nil,
|
||||
statusDesc: status,
|
||||
profileImageHash: nil,
|
||||
loginTag: loginTag,
|
||||
profileId: profileId,
|
||||
internalId: internalId
|
||||
))
|
||||
|
||||
case "Loan":
|
||||
let outstanding = abs(item["availableBalance"] as? Double ?? 0)
|
||||
loanAccounts.append(BankAccount(
|
||||
id: "BML_LOAN_\(accountNum)_\(loginTag)",
|
||||
bank: "BML",
|
||||
profileName: profileName,
|
||||
profileType: "BML_LOAN",
|
||||
productCode: item["product_code"] as? String ?? "",
|
||||
accountNumber: accountNum,
|
||||
accountBriefName: item["alias"] as? String ?? "",
|
||||
currencyName: currency,
|
||||
accountTypeName: product,
|
||||
availableBalance: outstanding,
|
||||
currentBalance: outstanding,
|
||||
blockedAmount: 0,
|
||||
mvrBalance: nil,
|
||||
statusDesc: status,
|
||||
profileImageHash: nil,
|
||||
loginTag: loginTag,
|
||||
profileId: profileId,
|
||||
internalId: internalId
|
||||
))
|
||||
|
||||
case "Card":
|
||||
let isPrepaid = item["prepaid_card"] as? Bool ?? false
|
||||
let isVisible = item["account_visible"] as? Bool ?? false
|
||||
let cardBalance = item["cardBalance"] as? [String: Any]
|
||||
let avail = cardBalance?["AvailableLimit"] as? Double ?? 0
|
||||
let current = cardBalance?["CurrentBalance"] as? Double ?? 0
|
||||
let cardType: String = isPrepaid ? "BML_PREPAID" : isVisible ? "BML_CREDIT" : "BML_DEBIT"
|
||||
let alias = (item["alias"] as? String ?? "").isEmpty ? product : (item["alias"] as? String ?? product)
|
||||
cardAccounts.append(BankAccount(
|
||||
id: "BML_CARD_\(accountNum)_\(loginTag)",
|
||||
bank: "BML",
|
||||
profileName: profileName,
|
||||
profileType: cardType,
|
||||
productCode: item["product_code"] as? String ?? "",
|
||||
accountNumber: accountNum,
|
||||
accountBriefName: alias,
|
||||
currencyName: currency,
|
||||
accountTypeName: product,
|
||||
availableBalance: avail,
|
||||
currentBalance: current,
|
||||
blockedAmount: 0,
|
||||
mvrBalance: currency == "MVR" ? avail : nil,
|
||||
statusDesc: status,
|
||||
profileImageHash: nil,
|
||||
loginTag: loginTag,
|
||||
profileId: profileId,
|
||||
internalId: internalId
|
||||
))
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return casaAccounts + cardAccounts + loanAccounts
|
||||
}
|
||||
|
||||
private static func stringValue(_ value: Any?) -> String {
|
||||
switch value {
|
||||
case let string as String:
|
||||
return string
|
||||
case let int as Int:
|
||||
return String(int)
|
||||
case let int64 as Int64:
|
||||
return String(int64)
|
||||
case let double as Double:
|
||||
return double.rounded() == double ? String(Int64(double)) : String(double)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTTP
|
||||
|
||||
private func apiGet(_ path: String) async throws -> Data {
|
||||
var req = URLRequest(url: URL(string: "\(BML_API_BASE)\(path)")!)
|
||||
req.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue(BML_API_UA, forHTTPHeaderField: "User-Agent")
|
||||
req.setValue(BML_API_VERSION, forHTTPHeaderField: "x-app-version")
|
||||
|
||||
let (data, response) = try await urlSession.data(for: req)
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if code == 401 || code == 419 { throw BmlError.sessionExpired }
|
||||
if code >= 500 { throw BmlError.networkError("Server error HTTP \(code)") }
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
// Mirrors Kotlin BmlContactsClient exactly.
|
||||
// Endpoint: GET /api/mobile/contacts
|
||||
// Response: { success: true, payload: [ { id, name, alias, account, status, currency }, ... ] }
|
||||
final class BmlContactsClient {
|
||||
|
||||
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"
|
||||
|
||||
private let bmlSession: BmlSession
|
||||
private let urlSession: URLSession
|
||||
|
||||
init(bmlSession: BmlSession) {
|
||||
self.bmlSession = bmlSession
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
self.urlSession = URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
func fetchContacts(loginTag: String) async throws -> [BankContact] {
|
||||
var req = URLRequest(url: URL(string: "\(base)/api/mobile/contacts")!)
|
||||
req.setValue("Bearer \(bmlSession.accessToken)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue(ua, forHTTPHeaderField: "User-Agent")
|
||||
req.setValue(ver, forHTTPHeaderField: "x-app-version")
|
||||
|
||||
let (data, response) = try await urlSession.data(for: req)
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if code == 401 || code == 419 { throw BmlError.sessionExpired }
|
||||
if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
|
||||
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
root["success"] as? Bool == true,
|
||||
let payload = root["payload"] as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
|
||||
return payload.compactMap { item -> BankContact? in
|
||||
let account = item["account"] as? String ?? ""
|
||||
guard !account.isEmpty else { return nil }
|
||||
|
||||
let id = item["id"] as? Int ?? 0
|
||||
let name = item["name"] as? String ?? ""
|
||||
let alias = (item["alias"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? name
|
||||
let status = item["status"] as? String ?? "S"
|
||||
let currency = item["currency"] as? String ?? "MVR"
|
||||
|
||||
return BankContact(
|
||||
id: "BML_bml_\(id)_\(loginTag)",
|
||||
benefNo: "bml_\(id)",
|
||||
benefName: name,
|
||||
benefNickName: alias == name ? nil : alias,
|
||||
benefAccount: account,
|
||||
benefType: "I",
|
||||
bankColor: "#0066A1",
|
||||
benefBankName: "Bank of Maldives",
|
||||
bankCode: "",
|
||||
benefStatus: status,
|
||||
transferCyDesc: currency,
|
||||
customerImgHash: nil,
|
||||
benefCategoryId: "BML",
|
||||
profileId: loginTag,
|
||||
source: "BML"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
// Mirrors Kotlin BmlForeignLimitsClient exactly.
|
||||
// Endpoint: GET https://app.bankofmaldives.com.mv/api/v2/foreign-limits
|
||||
final class BmlForeignLimitsClient {
|
||||
|
||||
private let base = "https://app.bankofmaldives.com.mv/api/v2"
|
||||
private let ua = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
|
||||
private let ver = "2.1.44.348"
|
||||
|
||||
private let urlSession: URLSession = {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
return URLSession(configuration: cfg)
|
||||
}()
|
||||
|
||||
func fetchForeignLimits(session: BmlSession) async throws -> [BmlForeignLimit] {
|
||||
var req = URLRequest(url: URL(string: "\(base)/foreign-limits")!)
|
||||
req.setValue("Bearer \(session.accessToken)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue(ua, "User-Agent")
|
||||
req.setValue(ver, "x-app-version")
|
||||
req.setValue("application/json","Accept")
|
||||
|
||||
let (data, response) = try await urlSession.data(for: req)
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
if code == 401 || code == 419 { throw BmlError.sessionExpired }
|
||||
if code >= 500 { throw BmlError.networkError("HTTP \(code)") }
|
||||
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
root["success"] as? Bool == true,
|
||||
let payload = root["payload"] as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
|
||||
return payload.map { item in
|
||||
let usage = item["usageByCategory"] as? [String: Any] ?? [:]
|
||||
let atm = usage["ATM"] as? [String: Any] ?? [:]
|
||||
let ecom = usage["ECOM"] as? [String: Any] ?? [:]
|
||||
let pos = usage["POS"] as? [String: Any] ?? [:]
|
||||
return BmlForeignLimit(
|
||||
type: item["type"] as? String ?? "Debit",
|
||||
used: item["used"] as? Double ?? 0,
|
||||
totalLimit: item["totalLimit"] as? Double ?? 0,
|
||||
generalCap: item["generalCap"] as? Double ?? 0,
|
||||
generalRemaining: item["generalRemaining"] as? Double ?? 0,
|
||||
medicalRemaining: item["medicalRemaining"] as? Double ?? 0,
|
||||
isAtmEnabled: item["isAtmEnabled"] as? Bool ?? false,
|
||||
isPosEnabled: item["isPosEnabled"] as? Bool ?? false,
|
||||
atmRemaining: atm["remaining"] as? Double ?? 0,
|
||||
atmLimit: atm["limit"] as? Double ?? 0,
|
||||
ecomRemaining: ecom["remaining"] as? Double ?? 0,
|
||||
ecomLimit: ecom["limit"] as? Double ?? 0,
|
||||
posRemaining: pos["remaining"] as? Double ?? 0,
|
||||
posLimit: pos["limit"] as? Double ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension URLRequest {
|
||||
mutating func setValue(_ value: String, _ field: String) {
|
||||
setValue(value, forHTTPHeaderField: field)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
private let BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||
private let BML_CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
||||
private let BML_REDIRECT = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||
private let BML_APP_UA = "bml-mobile-banking/348 (Apple; iOS 17.0; iPhone)"
|
||||
private let BML_APP_VERSION = "2.1.44.348"
|
||||
private let BML_WEB_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
||||
|
||||
// Prevents URLSession from following redirects and captures Set-Cookie headers from
|
||||
// redirect responses. The 302 response is only fully accessible here, before completionHandler(nil)
|
||||
// is called — by the time data(for:delegate:) returns, the redirect response headers may be gone.
|
||||
//
|
||||
// Also stores lastRedirectLocation so callers can reliably read the Location header even when
|
||||
// the async data(for:) return value loses it after redirect cancellation.
|
||||
private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate {
|
||||
var onRedirectResponse: (@Sendable (HTTPURLResponse) -> Void)?
|
||||
// Written synchronously inside willPerformHTTPRedirection before completionHandler(nil);
|
||||
// read by BmlLoginFlow after data(for:) returns — sequential by design, no data race.
|
||||
var lastRedirectLocation = ""
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest,
|
||||
completionHandler: @escaping @Sendable (URLRequest?) -> Void
|
||||
) {
|
||||
lastRedirectLocation = response.value(forHTTPHeaderField: "Location") ?? ""
|
||||
onRedirectResponse?(response)
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
final class BmlLoginFlow {
|
||||
|
||||
private let noRedirect = NoRedirectDelegate()
|
||||
|
||||
// Ephemeral session with automatic cookie handling.
|
||||
// noRedirect.onRedirectResponse feeds Set-Cookie headers from every 302 response
|
||||
// into forceCookies() — the only reliable place to see redirect response headers,
|
||||
// since data(for:delegate:) receives a stripped response after the redirect is cancelled.
|
||||
// Declared as `let` (not lazy) to avoid @MainActor inference on the accessing methods.
|
||||
private let webSession: URLSession
|
||||
|
||||
init() {
|
||||
let cfg = URLSessionConfiguration.ephemeral
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
webSession = URLSession(configuration: cfg, delegate: noRedirect, delegateQueue: nil)
|
||||
noRedirect.onRedirectResponse = { [weak self] redirectResp in
|
||||
self?.forceCookies(from: redirectResp)
|
||||
self?.captureRedirectCookies(from: redirectResp)
|
||||
}
|
||||
}
|
||||
|
||||
// Extra store for cookies captured from redirect responses.
|
||||
// Keyed by name; injected explicitly into the authorize request header.
|
||||
private var redirectCookieStore: [String: String] = [:]
|
||||
|
||||
private var codeVerifier = ""
|
||||
private var codeChallenge = ""
|
||||
private var deviceId = ""
|
||||
|
||||
private(set) var lastProfiles: [BmlProfile] = []
|
||||
|
||||
// MARK: - Login
|
||||
|
||||
func login(username: String, password: String, otpSeed: String) async throws -> [BmlProfile] {
|
||||
codeVerifier = generateCodeVerifier()
|
||||
codeChallenge = generateCodeChallenge(codeVerifier)
|
||||
deviceId = generateDeviceId()
|
||||
|
||||
// Step 1: GET /web/login — seeds XSRF-TOKEN + blaze_session cookies
|
||||
try await webGet("\(BML_BASE_URL)/web/login")
|
||||
guard let xsrf = xsrfToken() else { throw BmlError.loginFailed("Could not fetch login page") }
|
||||
|
||||
// Step 2: POST credentials
|
||||
let credBody = try jsonBody(["username": username, "password": password, "code": ""])
|
||||
let credResp = try await webPost("\(BML_BASE_URL)/web/login", body: credBody, xsrf: xsrf)
|
||||
guard credResp.status == 302 else {
|
||||
throw BmlError.loginFailed("Login failed — check your username/password")
|
||||
}
|
||||
|
||||
// Step 3: GET 2FA page — follow any canonical redirect so we land on the actual page,
|
||||
// which sets a fresh XSRF-TOKEN cookie needed for the OTP POST.
|
||||
let twoFaGetResp = try await webGet("\(BML_BASE_URL)/web/login/2fa")
|
||||
if twoFaGetResp.status == 302 {
|
||||
let twoFaLoc = twoFaGetResp.location.isEmpty ? noRedirect.lastRedirectLocation : twoFaGetResp.location
|
||||
if !twoFaLoc.isEmpty {
|
||||
let twoFaUrl = twoFaLoc.hasPrefix("http") ? twoFaLoc : "https://www.bankofmaldives.com.mv\(twoFaLoc)"
|
||||
try await webGet(twoFaUrl)
|
||||
}
|
||||
}
|
||||
let xsrf2 = xsrfToken() ?? xsrf
|
||||
|
||||
// Step 4: POST TOTP — try current window, then ±1 windows to handle device clock skew.
|
||||
// A successful 2FA POST redirects somewhere other than /web/login (e.g. /web/profile).
|
||||
// A failed OTP redirects back to /web/login or /web/login/2fa.
|
||||
// Re-fetch XSRF before each attempt in case the server rotates it on a failed try.
|
||||
var twoFaSucceeded = false
|
||||
var lastOtp = ""
|
||||
var lastRedirectLoc = ""
|
||||
for timeStep in [0, -1, 1] {
|
||||
// Refresh XSRF before each attempt — server may invalidate it after a failed POST.
|
||||
let freshXsrf = xsrfToken() ?? xsrf2
|
||||
let otp = Totp.generate(otpSeed, timeStep: timeStep)
|
||||
lastOtp = otp
|
||||
let body = try jsonBody(["code": otp, "channel": "authenticator"])
|
||||
let resp = try await webPost("\(BML_BASE_URL)/web/login/2fa", body: body, xsrf: freshXsrf)
|
||||
let loc = resp.location.isEmpty ? noRedirect.lastRedirectLocation : resp.location
|
||||
lastRedirectLoc = loc
|
||||
if resp.status == 302 && !loc.contains("/web/login") {
|
||||
twoFaSucceeded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
guard twoFaSucceeded else {
|
||||
throw BmlError.otpFailed("OTP verification failed (code tried: \(lastOtp), server redirected to: \(lastRedirectLoc)). If the code matches your authenticator, the XSRF-TOKEN or session cookie may be invalid.")
|
||||
}
|
||||
|
||||
// Step 5: GET /web/profile — 302 = single profile, 200 = picker.
|
||||
// For 302, follow the redirect (e.g. to /web/redirect) so the server-side OAuth
|
||||
// session state is established before we call /oauth/authorize.
|
||||
// NOTE: The async data(for:delegate:) API may not return the Location header on the
|
||||
// response object after redirect cancellation. We fall back to noRedirect.lastRedirectLocation
|
||||
// which is written synchronously inside willPerformHTTPRedirection before the task completes.
|
||||
let profileResp = try await webGet("\(BML_BASE_URL)/web/profile")
|
||||
if profileResp.status == 302 {
|
||||
let loc = profileResp.location.isEmpty ? noRedirect.lastRedirectLocation : profileResp.location
|
||||
if !loc.isEmpty {
|
||||
let redirectUrl = loc.hasPrefix("http") ? loc : "https://www.bankofmaldives.com.mv\(loc)"
|
||||
try await webGet(redirectUrl)
|
||||
}
|
||||
lastProfiles = [BmlProfile(profileId: username, name: "Personal", type: "Profile", profileType: "default", autoActivated: true)]
|
||||
} else {
|
||||
lastProfiles = parseProfiles(profileResp.body)
|
||||
}
|
||||
return lastProfiles
|
||||
}
|
||||
|
||||
// MARK: - Profile activation
|
||||
|
||||
func activateProfile(_ profile: BmlProfile, loginTag: String) async throws -> BmlActivationResult {
|
||||
if profile.autoActivated {
|
||||
let (session, accounts) = try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
|
||||
return .success(session, accounts)
|
||||
}
|
||||
|
||||
var req = makeWebRequest(url: "\(BML_BASE_URL)/web/profile/\(profile.profileId)")
|
||||
if let xsrf = xsrfToken() { req.setValue(xsrf, forHTTPHeaderField: "X-XSRF-TOKEN") }
|
||||
let (_, resp) = try await webSession.data(for: req, delegate: noRedirect)
|
||||
let http = resp as! HTTPURLResponse
|
||||
forceCookies(from: http)
|
||||
let loc = http.value(forHTTPHeaderField: "Location") ?? ""
|
||||
|
||||
let needsBusinessOtp = http.statusCode == 302 && loc.contains("/web/profile/2fa/business")
|
||||
if needsBusinessOtp {
|
||||
let channels = try await fetchBusinessOtpChannels()
|
||||
return .needsBusinessOtp(channels)
|
||||
} else if http.statusCode == 409 || http.statusCode == 302 {
|
||||
// Follow the redirect to establish server-side OAuth session state
|
||||
if !loc.isEmpty {
|
||||
let redirectUrl = loc.hasPrefix("http") ? loc : "https://www.bankofmaldives.com.mv\(loc)"
|
||||
try await webGet(redirectUrl)
|
||||
}
|
||||
let (session, accounts) = try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
|
||||
return .success(session, accounts)
|
||||
} else {
|
||||
throw BmlError.profileActivationFailed("HTTP \(http.statusCode)")
|
||||
}
|
||||
}
|
||||
|
||||
func requestBusinessOtp(channel: String) async throws {
|
||||
guard let xsrf = xsrfToken() else { throw BmlError.sessionExpired }
|
||||
let body = try jsonBody(["code": "", "channel": channel])
|
||||
let resp = try await webPost("\(BML_BASE_URL)/web/profile/2fa/business", body: body, xsrf: xsrf)
|
||||
guard resp.status == 302 else { throw BmlError.loginFailed("Failed to request OTP (HTTP \(resp.status))") }
|
||||
}
|
||||
|
||||
func submitBusinessOtp(channel: String, code: String, profile: BmlProfile, loginTag: String) async throws -> (BmlSession, [BankAccount]) {
|
||||
try await webGet("\(BML_BASE_URL)/web/profile/2fa/business")
|
||||
guard let xsrf = xsrfToken() else { throw BmlError.sessionExpired }
|
||||
let body = try jsonBody(["code": code, "channel": channel])
|
||||
let resp = try await webPost("\(BML_BASE_URL)/web/profile/2fa/business", body: body, xsrf: xsrf)
|
||||
let loc = resp.location
|
||||
if resp.status == 409 || (resp.status == 302 && loc.contains("/web/redirect")) {
|
||||
return try await doOAuthAndFetchAccounts(loginTag: loginTag, profileName: profile.name, profileId: profile.profileId)
|
||||
} else if resp.status == 302 {
|
||||
throw BmlError.otpFailed("Invalid OTP — please try again")
|
||||
} else {
|
||||
throw BmlError.otpFailed("Business OTP failed (HTTP \(resp.status))")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token refresh
|
||||
|
||||
func refreshSession(_ session: BmlSession) async throws -> BmlSession {
|
||||
var req = URLRequest(url: URL(string: "\(BML_BASE_URL)/oauth/token")!)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
|
||||
let fields = [
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", session.refreshToken),
|
||||
("client_id", BML_CLIENT_ID),
|
||||
("Device-ID", session.deviceId),
|
||||
("User-Agent", BML_APP_UA),
|
||||
("x-app-version", BML_APP_VERSION)
|
||||
]
|
||||
req.httpBody = Data(fields.map { "\($0.0)=\($0.1)" }.joined(separator: "&").utf8)
|
||||
req.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let (data, _) = try await makeApiSession().data(for: req)
|
||||
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let newAccess = (obj["access_token"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
|
||||
throw BmlError.sessionExpired
|
||||
}
|
||||
let newRefresh = (obj["refresh_token"] as? String ?? "").isEmpty ? session.refreshToken : obj["refresh_token"] as! String
|
||||
let expiresIn = obj["expires_in"] as? Int64 ?? 0
|
||||
let expiresAt = expiresIn > 0 ? Int64(Date().timeIntervalSince1970 * 1000) + expiresIn * 1000 : 0
|
||||
return BmlSession(accessToken: newAccess, deviceId: session.deviceId, refreshToken: newRefresh, expiresAt: expiresAt)
|
||||
}
|
||||
|
||||
// MARK: - OAuth + account fetch
|
||||
|
||||
private func doOAuthAndFetchAccounts(
|
||||
loginTag: String, profileName: String, profileId: String
|
||||
) async throws -> (BmlSession, [BankAccount]) {
|
||||
// Device-ID, User-Agent (APP UA), x-app-version are query params — same as Android.
|
||||
var comps = URLComponents(string: "\(BML_BASE_URL)/oauth/authorize")!
|
||||
comps.queryItems = [
|
||||
.init(name: "redirect_uri", value: BML_REDIRECT),
|
||||
.init(name: "client_id", value: BML_CLIENT_ID),
|
||||
.init(name: "response_type", value: "code"),
|
||||
.init(name: "state", value: randomUrlSafe(16)),
|
||||
.init(name: "nonce", value: randomUrlSafe(12)),
|
||||
.init(name: "code_challenge", value: codeChallenge),
|
||||
.init(name: "code_challenge_method", value: "S256"),
|
||||
.init(name: "Device-ID", value: deviceId),
|
||||
.init(name: "User-Agent", value: BML_APP_UA),
|
||||
.init(name: "x-app-version", value: BML_APP_VERSION),
|
||||
]
|
||||
// Sync any cookies captured from redirect responses into the session cookie storage.
|
||||
// This covers blaze_identity and any other cookies that forceCookies() may have missed
|
||||
// due to redirect responses being cancelled before URLSession auto-processes them.
|
||||
if let storage = webSession.configuration.httpCookieStorage {
|
||||
for (name, value) in redirectCookieStore where !name.hasPrefix("__REDIRECT__") {
|
||||
if let cookie = HTTPCookie(properties: [
|
||||
.name: name,
|
||||
.value: value,
|
||||
.domain: "www.bankofmaldives.com.mv",
|
||||
.path: "/",
|
||||
.secure: "TRUE"
|
||||
]) {
|
||||
storage.setCookie(cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build authorize request with all session cookies (now including redirect-captured ones).
|
||||
var authorizeReq = makeWebRequest(url: comps.url!.absoluteString)
|
||||
let allCookies = webSession.configuration.httpCookieStorage?.cookies ?? []
|
||||
let cookieDict: [String: String] = Dictionary(
|
||||
uniqueKeysWithValues: allCookies.map { ($0.name, $0.value) }
|
||||
)
|
||||
if !cookieDict.isEmpty {
|
||||
authorizeReq.setValue(
|
||||
cookieDict.map { "\($0.key)=\($0.value)" }.joined(separator: "; "),
|
||||
forHTTPHeaderField: "Cookie"
|
||||
)
|
||||
authorizeReq.httpShouldHandleCookies = false
|
||||
}
|
||||
let sentCookieNames = cookieDict.keys.joined(separator: ",")
|
||||
let redirectMarkers = redirectCookieStore.keys.filter { $0.hasPrefix("__REDIRECT__") }.sorted()
|
||||
.compactMap { redirectCookieStore[$0] }.joined(separator: "→")
|
||||
|
||||
let (_, authorizeRaw) = try await webSession.data(for: authorizeReq, delegate: noRedirect)
|
||||
let authorizeHttp = authorizeRaw as! HTTPURLResponse
|
||||
forceCookies(from: authorizeHttp)
|
||||
let loc = authorizeHttp.value(forHTTPHeaderField: "Location") ?? ""
|
||||
|
||||
guard !loc.isEmpty,
|
||||
let urlComps = URLComponents(string: loc),
|
||||
let code = urlComps.queryItems?.first(where: { $0.name == "code" })?.value else {
|
||||
let rStore = redirectCookieStore.keys.filter { !$0.hasPrefix("__REDIRECT__") }.sorted().joined(separator: ",")
|
||||
throw BmlError.oauthFailed("OAuth authorize failed (HTTP \(authorizeHttp.statusCode), location: \(loc), cookies: [\(sentCookieNames)], redirectPaths: [\(redirectMarkers)], redirectStore: [\(rStore)])")
|
||||
}
|
||||
|
||||
let tokenFields = [
|
||||
("Device-ID", deviceId),
|
||||
("code", code),
|
||||
("grant_type", "authorization_code"),
|
||||
("User-Agent", BML_APP_UA),
|
||||
("redirect_uri", BML_REDIRECT),
|
||||
("code_verifier", codeVerifier),
|
||||
("client_id", BML_CLIENT_ID),
|
||||
("x-app-version", BML_APP_VERSION)
|
||||
]
|
||||
var tokenReq = URLRequest(url: URL(string: "\(BML_BASE_URL)/oauth/token")!)
|
||||
tokenReq.httpMethod = "POST"
|
||||
tokenReq.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
|
||||
tokenReq.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
tokenReq.httpBody = Data(tokenFields.map { "\($0.0)=\($0.1)" }.joined(separator: "&").utf8)
|
||||
|
||||
let (tokenData, _) = try await webSession.data(for: tokenReq, delegate: noRedirect)
|
||||
guard let tokenObj = try? JSONSerialization.jsonObject(with: tokenData) as? [String: Any],
|
||||
let accessToken = (tokenObj["access_token"] as? String).flatMap({ $0.isEmpty ? nil : $0 }) else {
|
||||
throw BmlError.oauthFailed("Token exchange failed")
|
||||
}
|
||||
|
||||
let refreshToken = tokenObj["refresh_token"] as? String ?? ""
|
||||
let expiresIn = tokenObj["expires_in"] as? Int64 ?? 0
|
||||
let expiresAt = expiresIn > 0 ? Int64(Date().timeIntervalSince1970 * 1000) + expiresIn * 1000 : 0
|
||||
|
||||
let session = BmlSession(accessToken: accessToken, deviceId: deviceId, refreshToken: refreshToken, expiresAt: expiresAt)
|
||||
let client = BmlAccountClient(session: session)
|
||||
let accounts = try await client.fetchAccounts(loginTag: loginTag, profileName: profileName, profileId: profileId)
|
||||
return (session, accounts)
|
||||
}
|
||||
|
||||
// MARK: - Business OTP channels
|
||||
|
||||
private func fetchBusinessOtpChannels() async throws -> [BmlOtpChannel] {
|
||||
let resp = try await webGet("\(BML_BASE_URL)/web/profile/2fa/business")
|
||||
return parseBusinessOtpChannels(resp.body)
|
||||
}
|
||||
|
||||
// MARK: - Inertia.js parsing
|
||||
|
||||
private func extractInertiaJson(_ html: String) -> [String: Any]? {
|
||||
guard let range = html.range(of: #"data-page="([^"]+)""#, options: .regularExpression) else { return nil }
|
||||
var escaped = String(html[range])
|
||||
escaped = escaped.replacingOccurrences(of: #"data-page=""#, with: "").dropLast().description
|
||||
let unescaped = escaped
|
||||
.replacingOccurrences(of: """, with: "\"")
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "'", with: "'")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
return try? JSONSerialization.jsonObject(with: Data(unescaped.utf8)) as? [String: Any]
|
||||
}
|
||||
|
||||
private func parseProfiles(_ html: String) -> [BmlProfile] {
|
||||
guard let root = extractInertiaJson(html),
|
||||
let props = root["props"] as? [String: Any],
|
||||
let profiles = props["profiles"] as? [[String: Any]] else { return [] }
|
||||
return profiles.compactMap { p in
|
||||
guard let profileObj = p["profile"] as? [String: Any] else { return nil }
|
||||
return BmlProfile(
|
||||
profileId: p["profile_id"] as? String ?? "",
|
||||
name: p["name"] as? String ?? "",
|
||||
type: p["type"] as? String ?? "",
|
||||
profileType: profileObj["profile_type"] as? String ?? "default",
|
||||
autoActivated: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseBusinessOtpChannels(_ html: String) -> [BmlOtpChannel] {
|
||||
guard let root = extractInertiaJson(html),
|
||||
let props = root["props"] as? [String: Any],
|
||||
let channels = props["channels"] as? [[String: Any]] else { return [] }
|
||||
return channels.map { c in
|
||||
BmlOtpChannel(
|
||||
channel: c["channel"] as? String ?? "",
|
||||
description: c["description"] as? String ?? "",
|
||||
masked: c["masked"] as? String ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cookie helpers
|
||||
|
||||
// Stores raw name=value pairs from redirect response cookies into redirectCookieStore.
|
||||
// Uses direct string parsing of allHeaderFields to bypass HTTPCookie parsing quirks.
|
||||
// Also records a __REDIRECT__{n} marker so we know if this was ever called at all.
|
||||
private func captureRedirectCookies(from response: HTTPURLResponse) {
|
||||
let path = response.url?.path ?? "nil"
|
||||
let location = response.value(forHTTPHeaderField: "Location") ?? "?"
|
||||
let markerKey = "__REDIRECT__\(redirectCookieStore.keys.filter { $0.hasPrefix("__REDIRECT__") }.count)"
|
||||
redirectCookieStore[markerKey] = "\(path)→\(location)" // source path → redirect destination
|
||||
|
||||
for (key, value) in response.allHeaderFields {
|
||||
guard let k = key.base as? String,
|
||||
k.lowercased() == "set-cookie",
|
||||
let v = value as? String else { continue }
|
||||
// URLSession may combine multiple Set-Cookie headers with "\n"
|
||||
for cookieLine in v.components(separatedBy: "\n") {
|
||||
guard let nameValuePart = cookieLine.split(separator: ";").first else { continue }
|
||||
let parts = nameValuePart.split(separator: "=", maxSplits: 1)
|
||||
guard parts.count == 2 else { continue }
|
||||
let name = parts[0].trimmingCharacters(in: .whitespaces)
|
||||
let val = String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||
redirectCookieStore[name] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After every response, force-write Set-Cookie headers into the session's ephemeral
|
||||
// cookie storage. URLSession may skip automatic Set-Cookie processing for redirect
|
||||
// responses when the redirect is cancelled via NoRedirectDelegate.completionHandler(nil).
|
||||
//
|
||||
// IMPORTANT: allHeaderFields is a [AnyHashable: Any] dict — if the server sends multiple
|
||||
// Set-Cookie headers, URLSession joins them with "\n" into a single entry. Building a
|
||||
// plain [String: String] and passing it to HTTPCookie.cookies() would only parse the
|
||||
// first cookie. We split on "\n" and call HTTPCookie.cookies() once per line instead.
|
||||
private func forceCookies(from response: HTTPURLResponse) {
|
||||
guard let storage = webSession.configuration.httpCookieStorage,
|
||||
let url = response.url else { return }
|
||||
for (key, value) in response.allHeaderFields {
|
||||
guard let k = key.base as? String,
|
||||
k.caseInsensitiveCompare("set-cookie") == .orderedSame,
|
||||
let v = value as? String else { continue }
|
||||
for line in v.components(separatedBy: "\n") {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
let cookies = HTTPCookie.cookies(
|
||||
withResponseHeaderFields: ["Set-Cookie": trimmed],
|
||||
for: url
|
||||
)
|
||||
cookies.forEach { storage.setCookie($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func xsrfToken() -> String? {
|
||||
guard let url = URL(string: BML_BASE_URL) else { return nil }
|
||||
return webSession.configuration.httpCookieStorage?
|
||||
.cookies(for: url)?.first { $0.name == "XSRF-TOKEN" }?.value
|
||||
}
|
||||
|
||||
// MARK: - HTTP helpers
|
||||
|
||||
@discardableResult
|
||||
private func webGet(_ urlString: String) async throws -> (status: Int, body: String, location: String) {
|
||||
let req = makeWebRequest(url: urlString)
|
||||
let (data, resp) = try await webSession.data(for: req, delegate: noRedirect)
|
||||
let http = resp as! HTTPURLResponse
|
||||
forceCookies(from: http)
|
||||
return (http.statusCode, String(data: data, encoding: .utf8) ?? "", http.value(forHTTPHeaderField: "Location") ?? "")
|
||||
}
|
||||
|
||||
private func webPost(_ urlString: String, body: Data, xsrf: String) async throws -> (status: Int, body: String, location: String) {
|
||||
var req = makeWebRequest(url: urlString)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue(xsrf, forHTTPHeaderField: "X-XSRF-TOKEN")
|
||||
req.httpBody = body
|
||||
let (data, resp) = try await webSession.data(for: req, delegate: noRedirect)
|
||||
let http = resp as! HTTPURLResponse
|
||||
forceCookies(from: http)
|
||||
return (http.statusCode, String(data: data, encoding: .utf8) ?? "", http.value(forHTTPHeaderField: "Location") ?? "")
|
||||
}
|
||||
|
||||
private func makeWebRequest(url: String) -> URLRequest {
|
||||
var req = URLRequest(url: URL(string: url)!)
|
||||
req.setValue(BML_WEB_UA, forHTTPHeaderField: "User-Agent")
|
||||
return req
|
||||
}
|
||||
|
||||
private func jsonBody(_ dict: [String: String]) throws -> Data {
|
||||
try JSONSerialization.data(withJSONObject: dict)
|
||||
}
|
||||
|
||||
private func makeApiSession() -> URLSession {
|
||||
let cfg = URLSessionConfiguration.default
|
||||
cfg.timeoutIntervalForRequest = 30
|
||||
return URLSession(configuration: cfg)
|
||||
}
|
||||
|
||||
// MARK: - PKCE / crypto
|
||||
|
||||
private func generateCodeVerifier() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 72)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, 72, &bytes)
|
||||
return Data(bytes).base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private func generateCodeChallenge(_ verifier: String) -> String {
|
||||
let hash = SHA256.hash(data: Data(verifier.utf8))
|
||||
return Data(hash).base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private func randomUrlSafe(_ byteCount: Int) -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: byteCount)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes)
|
||||
return Data(bytes).base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private func generateDeviceId() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 8)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, 8, &bytes)
|
||||
return bytes.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
|
||||
struct BmlSession: Codable {
|
||||
let accessToken: String
|
||||
let deviceId: String
|
||||
let refreshToken: String
|
||||
let expiresAt: Int64 // Unix millis; 0 = unknown
|
||||
|
||||
var isExpired: Bool {
|
||||
expiresAt > 0 && Int64(Date().timeIntervalSince1970 * 1000) >= expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
struct BmlProfile: Codable, Identifiable {
|
||||
let profileId: String
|
||||
let name: String
|
||||
let type: String // "Profile" or "Business"
|
||||
let profileType: String // "default" or "business"
|
||||
let autoActivated: Bool
|
||||
|
||||
var id: String { profileId }
|
||||
}
|
||||
|
||||
struct BmlOtpChannel: Codable {
|
||||
let channel: String
|
||||
let description: String
|
||||
let masked: String
|
||||
}
|
||||
|
||||
enum BmlActivationResult {
|
||||
case success(BmlSession, [BankAccount])
|
||||
case needsBusinessOtp([BmlOtpChannel])
|
||||
}
|
||||
|
||||
struct BmlUserInfo {
|
||||
let fullName: String
|
||||
let email: String
|
||||
let mobile: String
|
||||
let customerId: String
|
||||
let idCard: String
|
||||
let birthdate: String
|
||||
}
|
||||
|
||||
struct BmlLoanDetail {
|
||||
let loanAmount: Double
|
||||
let outstandingAmt: Double
|
||||
let repayAmount: Double
|
||||
let intRate: Double
|
||||
let loanStatus: String
|
||||
let startDate: String
|
||||
let endDate: String
|
||||
let noOfRepayOverdue: Int
|
||||
let overdueAmount: Double
|
||||
}
|
||||
|
||||
struct BmlWalletToken {
|
||||
let token: String
|
||||
let expiry: String
|
||||
let appCode: String
|
||||
let serviceCode: String
|
||||
let data: String
|
||||
let validUntil: String
|
||||
}
|
||||
|
||||
struct BmlForeignLimit: Codable {
|
||||
let type: String
|
||||
let used: Double
|
||||
let totalLimit: Double
|
||||
let generalCap: Double
|
||||
let generalRemaining: Double
|
||||
let medicalRemaining: Double
|
||||
let isAtmEnabled: Bool
|
||||
let isPosEnabled: Bool
|
||||
let atmRemaining: Double
|
||||
let atmLimit: Double
|
||||
let ecomRemaining: Double
|
||||
let ecomLimit: Double
|
||||
let posRemaining: Double
|
||||
let posLimit: Double
|
||||
}
|
||||
|
||||
enum BmlError: LocalizedError {
|
||||
case networkError(String)
|
||||
case loginFailed(String)
|
||||
case otpFailed(String)
|
||||
case serverError(String)
|
||||
case sessionExpired
|
||||
case profileActivationFailed(String)
|
||||
case oauthFailed(String)
|
||||
case invalidResponse
|
||||
case endpointNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .networkError(let msg): return "Network error: \(msg)"
|
||||
case .loginFailed(let msg): return "Login failed: \(msg)"
|
||||
case .otpFailed(let msg): return "OTP failed: \(msg)"
|
||||
case .serverError(let msg): return msg
|
||||
case .sessionExpired: return "Session expired."
|
||||
case .profileActivationFailed(let msg): return "Profile activation failed: \(msg)"
|
||||
case .oauthFailed(let msg): return "OAuth failed: \(msg)"
|
||||
case .invalidResponse: return "Unexpected server response."
|
||||
case .endpointNotFound: return "Endpoint not found."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user