223 lines
9.2 KiB
Swift
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
|
|
}
|
|
}
|