Files

223 lines
9.2 KiB
Swift

import Foundation
struct MibAccountLookupResult: Sendable {
let accountNumber: String
let accountName: String
let bankName: String
let bankCode: String
let aliasId: String?
let network: TransferNetwork
}
// Handles MIB beneficiary lookups and transfer submission via the WebView API domain.
// Session identity requires the Cookie header (mbmodel, xxid, IBSID, mbnonce) on every request.
actor MibTransferClient {
private let wvBase = "https://faisamobilex-wv.mib.com.mv"
private let session: MibSession
private let urlSession: URLSession = {
let cfg = URLSessionConfiguration.ephemeral
cfg.timeoutIntervalForRequest = 30
cfg.timeoutIntervalForResource = 60
cfg.httpCookieAcceptPolicy = .never
cfg.httpShouldSetCookies = false
return URLSession(configuration: cfg)
}()
init(session: MibSession) {
self.session = session
}
// MARK: - Account lookup
func lookupAccount(_ query: String) async throws -> MibAccountLookupResult {
let trimmed = query.trimmingCharacters(in: .whitespaces)
// 17-digit starting with 9 → MIB internal
if trimmed.count == 17 && trimmed.hasPrefix("9") {
return try await lookupMibInternal(trimmed)
}
// 13-digit starting with 7 → IPS/local (BML etc.)
if trimmed.count == 13 && trimmed.hasPrefix("7") {
return try await lookupIPS(trimmed)
}
// Everything else → Favara alias
return try await lookupFavara(trimmed)
}
private func lookupMibInternal(_ number: String) async throws -> MibAccountLookupResult {
let resp = try await postFormJSON(
path: "ajaxBeneficiary/getAccountName",
fields: [("accountNo", number)]
)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Account not found")
}
// Server returns accountName at root level or inside data dict/array
let name: String
if let n = resp["accountName"] as? String, !n.isEmpty {
name = n
} else if let dataDict = resp["data"] as? [String: Any],
let n = dataDict["accountName"] as? String {
name = n
} else if let dataArr = resp["data"] as? [[String: Any]],
let n = dataArr.first?["accountName"] as? String {
name = n
} else {
throw MibError.serverError("Account not found")
}
return MibAccountLookupResult(
accountNumber: number,
accountName: name,
bankName: "Maldives Islamic Bank",
bankCode: "2",
aliasId: nil,
network: .mibInternal
)
}
private func lookupIPS(_ number: String) async throws -> MibAccountLookupResult {
// Kotlin field name is benefAccount, not accountNo
let resp = try await postFormJSON(
path: "AjaxAlias/getIPSAccount",
fields: [("benefAccount", number)]
)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Account not found")
}
let name = resp["accountName"] as? String
?? (resp["data"] as? [[String: Any]])?.first?["accountName"] as? String
?? "Unknown"
let bankName = resp["bankName"] as? String ?? "Local Bank"
return MibAccountLookupResult(
accountNumber: number,
accountName: name,
bankName: bankName,
bankCode: "3",
aliasId: nil,
network: .local
)
}
private func lookupFavara(_ alias: String) async throws -> MibAccountLookupResult {
// Kotlin field name is aliasName, not alias
let resp = try await postFormJSON(
path: "AjaxAlias/getAlias",
fields: [("aliasName", alias)]
)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Alias not found")
}
// Response: { data: { BfyNm, CdtrAcct: { Acct, FinInstnId } } }
guard let dataDict = resp["data"] as? [String: Any] else {
throw MibError.serverError("Alias not found")
}
let name = dataDict["BfyNm"] as? String ?? "Unknown"
let cdtrAcct = dataDict["CdtrAcct"] as? [String: Any]
let accountNum = cdtrAcct?["Acct"] as? String ?? alias
let bic = cdtrAcct?["FinInstnId"] as? String ?? ""
// Determine network based on account number format
let network: TransferNetwork = accountNum.count == 17 && accountNum.hasPrefix("9")
? .mibInternal : .local
let bankCode = network == .mibInternal ? "2" : "3"
return MibAccountLookupResult(
accountNumber: accountNum,
accountName: name,
bankName: bic.isEmpty ? "Maldives Islamic Bank" : bic,
bankCode: bankCode,
aliasId: alias,
network: network
)
}
// MARK: - Transfer
func executeTransfer(
from fromAccount: BankAccount,
to lookup: MibAccountLookupResult,
amount: Double,
currency: String,
purpose: String,
otp: String // caller generates TOTP before entering actor context
) async throws -> MibTransferResult {
let endpoint = lookup.network == .mibInternal
? "ajaxTransfer/transferInternal"
: "ajaxTransfer/transferLocal"
// Kotlin maps currency name → ISO 4217 numeric code
let currencyCode: String
switch currency.uppercased() {
case "MVR": currencyCode = "462"
case "USD": currencyCode = "840"
default: currencyCode = currency
}
let fields: [(String, String)] = [
("benefName", lookup.accountName.isEmpty ? "Recipient" : lookup.accountName),
("benefNo", "0"), // Kotlin always sends "0"
("fromAccountNo", fromAccount.accountNumber),
("benefAccountNo", lookup.accountNumber),
("transferCy", currencyCode),
("benefCurrencyCode", currencyCode),
("amount", String(format: "%.2f", amount)),
("bankNo", lookup.bankCode), // "2" MIB internal, "3" local
("purpose", purpose.isEmpty ? "-" : purpose),
("otp", otp),
("otpType", "3")
]
let resp = try await postFormJSON(path: endpoint, fields: fields)
guard resp["success"] as? Bool == true else {
throw MibError.serverError(resp["reasonText"] as? String ?? "Transfer failed")
}
// Response: { success: true, data: [{ trxId, date }] }
let dataArr = resp["data"] as? [[String: Any]]
let trxId = dataArr?.first?["trxId"] as? String
?? resp["trxId"] as? String ?? ""
let date = dataArr?.first?["date"] as? String
?? resp["transactionDate"] as? String ?? ""
return MibTransferResult(success: true, trxId: trxId, date: date, errorMessage: "")
}
// MARK: - HTTP
private var cookieHeader: String {
"mbmodel=IOS-1.0; xxid=\(session.xxid); IBSID=\(session.xxid); mbnonce=\(session.nonceGenerator); time-tracker=597"
}
private func postFormJSON(path: String, fields: [(String, String)]) async throws -> [String: Any] {
guard let url = URL(string: "\(wvBase)/\(path)") else {
throw MibError.networkError("Bad URL: \(path)")
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.setValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
forHTTPHeaderField: "User-Agent"
)
request.setValue("*/*", forHTTPHeaderField: "Accept")
request.setValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
request.setValue(wvBase, forHTTPHeaderField: "Origin")
request.setValue("\(wvBase)/transfer/quick", forHTTPHeaderField: "Referer")
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
let body = fields.map { k, v in
"\(k.addingPercentEncoding(withAllowedCharacters: allowed) ?? k)=\(v.addingPercentEncoding(withAllowedCharacters: allowed) ?? v)"
}.joined(separator: "&")
request.httpBody = Data(body.utf8)
let (data, response) = try await urlSession.data(for: request)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code == 419 { throw MibError.sessionExpired }
if code >= 500 { throw MibError.networkError("HTTP \(code)") }
guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw MibError.invalidResponse
}
return obj
}
}