forked from thijooree/android
inital tests for pay via card
This commit is contained in:
@@ -7,6 +7,8 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
|
||||
/** Browser User-Agent used for BML's web/Cloudflare-fronted endpoints (login, merchant pay page, ACS). */
|
||||
internal val BML_WEB_USER_AGENT = "Mozilla/5.0 (Android ${Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||
internal const val BML_APP_VERSION = "2.1.44.348"
|
||||
|
||||
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
|
||||
@@ -27,7 +27,7 @@ class BmlLoginFlow {
|
||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
|
||||
private val APP_VERSION = "2.1.44.348"
|
||||
private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||
private val WEB_USER_AGENT = BML_WEB_USER_AGENT
|
||||
|
||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||
private val cookieJar = object : CookieJar {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE
|
||||
import java.security.KeyFactory
|
||||
import java.security.spec.MGF1ParameterSpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.OAEPParameterSpec
|
||||
import javax.crypto.spec.PSource
|
||||
import android.util.Base64
|
||||
|
||||
/**
|
||||
* Pays a BML Merchant Services payment link by card, for merchants that don't have BML Pay
|
||||
* enabled. It performs the same request sequence the link's own card form (Pomelo JS) and the
|
||||
* issuer's 3-D Secure page perform in a browser:
|
||||
*
|
||||
* 1. `GET public-client/credentials/<id>` (auth header: the page's `pomeloJsKey`) → RSA public
|
||||
* key + Pomelo API key.
|
||||
* 2. `POST api.pay.pomelopay.com/bin-lookup` with the PAN, CVV and `YYMM` expiry, each
|
||||
* RSA-OAEP(SHA-1) encrypted with that key → a card `tokenId`.
|
||||
* 3. `POST public-client/transactions/next-action` RATE_OPTIONS, polling while the server says
|
||||
* WAIT, until it returns a `3dsUrl`.
|
||||
* 4. The 3-D Secure challenge on BML's Wibmo ACS: the render page auto-posts the `creq`, we pick
|
||||
* the "Authenticator" channel and submit the BML token's TOTP. The ACS then auto-posts the
|
||||
* result to the Mastercard gateway, which posts it back to BML's `mpgsNotification`.
|
||||
* 5. Poll next-action until TRANSACTION_CONFIRMED.
|
||||
*
|
||||
* Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS
|
||||
* session cookies.
|
||||
*/
|
||||
class BmlMerchantCardPayClient {
|
||||
|
||||
data class Card(
|
||||
val pan: String,
|
||||
val expiryMonth: String, // "07"
|
||||
val expiryYear: String, // "28"
|
||||
val cvv: String,
|
||||
val holderName: String
|
||||
)
|
||||
|
||||
sealed class Result {
|
||||
object Success : Result()
|
||||
data class Failure(val message: String) : Result()
|
||||
}
|
||||
|
||||
private val cookies = mutableMapOf<String, MutableList<Cookie>>()
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
// Credentials/next-action tolerate okhttp, but the Cloudflare-fronted ACS does not — send a
|
||||
// browser UA on everything (only when the caller didn't set one).
|
||||
.addInterceptor { chain ->
|
||||
val req = chain.request()
|
||||
chain.proceed(
|
||||
if (req.header("User-Agent") == null)
|
||||
req.newBuilder().header("User-Agent", BML_WEB_USER_AGENT).build()
|
||||
else req
|
||||
)
|
||||
}
|
||||
.cookieJar(object : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, newCookies: List<Cookie>) {
|
||||
val list = cookies.getOrPut(url.host) { mutableListOf() }
|
||||
for (c in newCookies) { list.removeAll { it.name == c.name }; list.add(c) }
|
||||
}
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||
cookies.values.flatten().filter { it.matches(url) }
|
||||
})
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Runs the whole payment. [otp] returns the current BML token code; it is called again with
|
||||
* `retry = true` if the ACS rejects a code (it can expire between generating and submitting).
|
||||
*/
|
||||
fun pay(page: BmlMerchantTxnClient.PayPage, card: Card, otp: (retry: Boolean) -> String): Result {
|
||||
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
|
||||
val txnId = page.transactionId
|
||||
|
||||
runCatching { BmlMerchantTxnClient().announceBrowser(txnId) }
|
||||
|
||||
// 1-2. Credentials, then tokenise the card with Pomelo
|
||||
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
|
||||
val keyInfo = creds.getJSONObject("publicKey")
|
||||
val publicKey = parsePublicKey(keyInfo.getString("publicKeyPem"))
|
||||
val binBody = JSONObject()
|
||||
.put("encryptedCardNumber", encrypt(publicKey, card.pan))
|
||||
.put("encryptedCardSecurityCode", encrypt(publicKey, card.cvv))
|
||||
.put("encryptedCardExpiry", encrypt(publicKey, card.expiryYear + card.expiryMonth))
|
||||
.put("externalId", txnId)
|
||||
.put("cardHolderName", card.holderName)
|
||||
.put("encryptedCardExpiryMonth", card.expiryMonth)
|
||||
.put("encryptedCardExpiryYear", card.expiryYear)
|
||||
.put("encSerialId", keyInfo.getString("publicKeyId"))
|
||||
val binReq = Request.Builder()
|
||||
.url(creds.optString("binLookupUrl").ifBlank { "https://api.pay.pomelopay.com/bin-lookup" })
|
||||
.post(binBody.toString().toRequestBody(JSON))
|
||||
.header("tenant", "bankofmaldives")
|
||||
.header("x-api-key", creds.getString("apiKey"))
|
||||
.header("x-tenant-id", creds.optString("tid"))
|
||||
.build()
|
||||
val bin = execJson(binReq)
|
||||
val tokenId = bin.optString("tokenId").ifBlank { return Result.Failure("Card was not accepted") }
|
||||
|
||||
// 3. Rate options → poll while WAIT → 3-D Secure URL
|
||||
val cardFields = JSONObject()
|
||||
.put("transactionId", txnId)
|
||||
.put("tokenId", tokenId)
|
||||
.put("bin8", bin.optString("bin8"))
|
||||
.put("cardBrand", bin.optString("brand"))
|
||||
for ((from, to) in listOf("issuer" to "cardIssuer", "country" to "cardCountry",
|
||||
"cardCategory" to "cardCategory", "isCommercial" to "isCommercial",
|
||||
"isPrepaid" to "isPrepaid", "isReloadable" to "isReloadable", "paddedPan" to "paddedPan")) {
|
||||
if (bin.has(from) && !bin.isNull(from)) cardFields.put(to, bin.get(from))
|
||||
}
|
||||
var action = nextAction(pk, copy(cardFields).put("action", "RATE_OPTIONS").withBrowserInfo())
|
||||
val resolved = setOf("WAIT", "POLL", "TRANSACTION_CONFIRMED", "TRANSACTION_FAILED")
|
||||
if (action.optString("action") !in resolved && action.optString("3dsUrl").isBlank()) {
|
||||
action = nextAction(pk, copy(cardFields).put("action", "THREEDS").withBrowserInfo())
|
||||
}
|
||||
|
||||
var threeDsUrl: String? = null
|
||||
for (attempt in 0..MAX_POLLS) {
|
||||
when (action.optString("action")) {
|
||||
"TRANSACTION_CONFIRMED" -> return Result.Success
|
||||
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
|
||||
}
|
||||
threeDsUrl = action.optString("3dsUrl").ifBlank { null }
|
||||
if (threeDsUrl != null) break
|
||||
if (attempt == MAX_POLLS) return Result.Failure("Timed out waiting for the bank")
|
||||
Thread.sleep(POLL_MS)
|
||||
action = poll(pk, txnId)
|
||||
}
|
||||
|
||||
// 4. 3-D Secure challenge (handles the authenticator channel + TOTP)
|
||||
runThreeDs(threeDsUrl!!, otp)?.let { return it }
|
||||
|
||||
// 5. Wait for the gateway's verdict to reach BML
|
||||
repeat(MAX_POLLS * 2) {
|
||||
when (poll(pk, txnId).optString("action")) {
|
||||
"TRANSACTION_CONFIRMED" -> return Result.Success
|
||||
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
|
||||
}
|
||||
Thread.sleep(POLL_MS / 2)
|
||||
}
|
||||
return Result.Failure("Payment status unknown — check with the merchant before retrying")
|
||||
}
|
||||
|
||||
/** Drives the ACS challenge. Returns null on success, or a Failure to stop the payment. */
|
||||
private fun runThreeDs(threeDsUrl: String, otp: (Boolean) -> String): Result? {
|
||||
// render-tds: an auto-submitting form (with an explicit action) that posts the creq to the
|
||||
// issuer's ACS. The ACS's own channel/OTP forms carry no action attribute — their JS posts
|
||||
// back to this same creq URL — so it is the fallback action for everything that follows.
|
||||
var form = AcsForm.parse(execText(get(threeDsUrl)), null)
|
||||
?: return Result.Failure("Couldn't start card authentication")
|
||||
val acsUrl = form.action
|
||||
var html = execText(form.toRequest())
|
||||
|
||||
// Channel picker (Mobile / Email / Authenticator). The BML token is the "token" channel.
|
||||
if (html.contains("name=\"destValue\"")) {
|
||||
form = AcsForm.parse(html, acsUrl) ?: return Result.Failure("Unexpected authentication page")
|
||||
form.fields["destValue"] = "token"
|
||||
form.fields["selectChannel"] = "token"
|
||||
form.fields["authMethod"] = "OOB"
|
||||
form.fields["otpDest"] = ""
|
||||
form.fields["formReqType"] = "SUBMIT"
|
||||
html = execText(form.toRequest())
|
||||
}
|
||||
|
||||
// OTP entry. Submit the token code; if it expired, ask for a fresh one once and retry.
|
||||
var retry = false
|
||||
for (attempt in 0..1) {
|
||||
form = AcsForm.parse(html, acsUrl) ?: break
|
||||
if (!form.fields.containsKey("otpValue")) break
|
||||
form.fields["otpValue"] = otp(retry)
|
||||
form.fields["formReqType"] = "SUBMIT"
|
||||
html = execText(form.toRequest())
|
||||
if (!html.contains("incorrect", true) && !html.contains("expired", true)) break
|
||||
retry = true
|
||||
}
|
||||
// On success the ACS returns an auto-posting form to the gateway; follow it (and the
|
||||
// gateway's own auto-post back to BML) so the verdict is recorded before we poll.
|
||||
repeat(3) {
|
||||
val next = AcsForm.parse(html, acsUrl) ?: return null
|
||||
if (next.fields.keys.none { it == "cres" || it == "order.id" }) return null
|
||||
html = execText(next.toRequest())
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── next-action helpers ──────────────────────────────────────────────────
|
||||
|
||||
private fun nextAction(pk: String, body: JSONObject): JSONObject =
|
||||
execJson(Request.Builder()
|
||||
.url("$API_BASE/public-client/transactions/next-action")
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Authorization", pk)
|
||||
.build())
|
||||
|
||||
private fun poll(pk: String, txnId: String): JSONObject =
|
||||
nextAction(pk, JSONObject().put("action", "POLL").put("transactionId", txnId))
|
||||
|
||||
private fun JSONObject.withBrowserInfo(): JSONObject = this
|
||||
.put("javaEnabled", false).put("javascriptEnabled", true)
|
||||
.put("language", "en-US").put("colorDepth", 24)
|
||||
.put("screenHeight", 1850).put("screenWidth", 1080)
|
||||
.put("tz", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / -60000)
|
||||
.put("userAgent", "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile)")
|
||||
|
||||
private fun copy(o: JSONObject) = JSONObject(o.toString())
|
||||
|
||||
// ── HTTP ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun get(url: String) = Request.Builder().url(url).build()
|
||||
|
||||
private fun getJson(url: String, auth: String): JSONObject =
|
||||
execJson(Request.Builder().url(url).header("Authorization", auth).header("Accept", "application/json").build())
|
||||
|
||||
private fun execJson(request: Request): JSONObject = client.newCall(request).execute().use { r ->
|
||||
val text = r.body?.string().orEmpty()
|
||||
if (!r.isSuccessful) throw Exception("Request failed (HTTP ${r.code})")
|
||||
if (text.isBlank()) JSONObject() else JSONObject(text)
|
||||
}
|
||||
|
||||
private fun execText(request: Request): String = client.newCall(request).execute().use { r ->
|
||||
r.body?.string().orEmpty()
|
||||
}
|
||||
|
||||
// ── RSA-OAEP(SHA-1), matching the Pomelo JS crypto.subtle config ──────────
|
||||
|
||||
private fun parsePublicKey(pem: String): java.security.PublicKey {
|
||||
val der = Base64.decode(pem
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replace(Regex("\\s"), ""), Base64.DEFAULT)
|
||||
return KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(der))
|
||||
}
|
||||
|
||||
private fun encrypt(key: java.security.PublicKey, value: String): String {
|
||||
val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, OAEPParameterSpec(
|
||||
"SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT))
|
||||
return Base64.encodeToString(cipher.doFinal(value.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `application/x-www-form-urlencoded` form scraped from an ACS HTML page: its POST target
|
||||
* plus every `<input>` name/value. [fields] is mutable so the caller can fill in the chosen
|
||||
* channel and the OTP before re-submitting.
|
||||
*/
|
||||
private class AcsForm(val action: String, val fields: MutableMap<String, String>) {
|
||||
fun toRequest(): Request {
|
||||
val body = FormBody.Builder()
|
||||
for ((k, v) in fields) body.add(k, v)
|
||||
return Request.Builder().url(action).post(body.build()).build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val FORM = Regex("<form\\b[^>]*>", RegexOption.IGNORE_CASE)
|
||||
private val ACTION = Regex("action\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
|
||||
private val INPUT = Regex("<input\\b[^>]*>", RegexOption.IGNORE_CASE)
|
||||
private val NAME = Regex("name\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
|
||||
private val VALUE = Regex("value\\s*=\\s*[\"']([^\"']*)[\"']", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* The first `<form>` and its inputs. The form's `action` is used when present;
|
||||
* otherwise [defaultAction] (the ACS pages set it via JS to the current creq URL).
|
||||
* Null only when there is no form, or no action at all.
|
||||
*/
|
||||
fun parse(html: String, defaultAction: String?): AcsForm? {
|
||||
val form = FORM.find(html) ?: return null
|
||||
val action = ACTION.find(form.value)?.groupValues?.get(1)?.let { unescape(it) }
|
||||
?: defaultAction ?: return null
|
||||
val fields = linkedMapOf<String, String>()
|
||||
for (m in INPUT.findAll(html)) {
|
||||
val name = NAME.find(m.value)?.groupValues?.get(1) ?: continue
|
||||
fields[unescape(name)] = unescape(VALUE.find(m.value)?.groupValues?.get(1) ?: "")
|
||||
}
|
||||
return AcsForm(action, fields)
|
||||
}
|
||||
|
||||
private fun unescape(s: String) = s
|
||||
.replace("&", "&").replace(""", "\"")
|
||||
.replace(""", "\"").replace("'", "'").replace("<", "<").replace(">", ">")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private const val POLL_MS = 5_000L
|
||||
private const val MAX_POLLS = 10
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,89 @@ package sh.sar.basedbank.api.bml
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`),
|
||||
* e.g. the bill links Fenaka sends. The web page only shows a QR; this fetches the QR's text so it
|
||||
* can go through the regular BML QR payment flow.
|
||||
* e.g. the bill links Fenaka sends. Merchants with BML Pay enabled get their QR's text fetched so it
|
||||
* can go through the regular BML QR payment flow; card-only merchants are paid by
|
||||
* [BmlMerchantCardPayClient] instead — [fetchPayPage] tells the two apart.
|
||||
*/
|
||||
class BmlMerchantTxnClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/** What the payment page knows about a transaction, from its embedded `window.appData`. */
|
||||
data class PayPage(
|
||||
val transactionId: String,
|
||||
val merchantName: String,
|
||||
val merchantAddress: String,
|
||||
/** Major units (the page's amounts are in cents). */
|
||||
val amount: Double,
|
||||
val currency: String,
|
||||
val state: String,
|
||||
/** BML Pay (`bml_mpos`) is offered: pay through [fetchQrPayload] and the QR flow. */
|
||||
val supportsBmlPay: Boolean,
|
||||
/** Card entry (MPGS via Pomelo) is offered: pay with [BmlMerchantCardPayClient]. */
|
||||
val supportsCard: Boolean,
|
||||
/** `pk_production_…` key the page's card form authenticates with. */
|
||||
val pomeloKey: String?
|
||||
) {
|
||||
val isPaid get() = state == "CONFIRMED"
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads `/<id>/paynow`. The page is server-rendered with everything inline: the transaction,
|
||||
* the merchant, `availableProviders` (lists `bml_mpos` when BML Pay is enabled — empty for
|
||||
* card-only merchants) and the card form's `pomeloJsKey` / `pomeloJsProviders`.
|
||||
*/
|
||||
fun fetchPayPage(transactionId: String): PayPage {
|
||||
val request = Request.Builder()
|
||||
.url("$PAGE_ORIGIN/$transactionId/paynow")
|
||||
// The page host is behind Cloudflare, which 403s non-browser User-Agents.
|
||||
.header("User-Agent", BML_WEB_USER_AGENT)
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.build()
|
||||
val html = client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw Exception("Payment page failed (HTTP ${response.code})")
|
||||
response.body?.string().orEmpty()
|
||||
}
|
||||
val start = html.indexOf(APP_DATA_PREFIX).takeIf { it >= 0 }
|
||||
?.let { it + APP_DATA_PREFIX.length } ?: throw Exception("Payment page has no app data")
|
||||
val end = html.indexOf("</script>", start).takeIf { it >= 0 } ?: throw Exception("Payment page has no app data")
|
||||
val data = JSONObject(html.substring(start, end))
|
||||
|
||||
val txn = data.optJSONObject("transaction") ?: throw Exception("Payment page has no transaction")
|
||||
val merchant = data.optJSONObject("merchant")
|
||||
val providers = data.optJSONArray("availableProviders") ?: JSONArray()
|
||||
val bmlPay = (0 until providers.length()).any {
|
||||
val p = providers.optJSONObject(it)
|
||||
p?.optString("value") == PROVIDER_BML && p.optBoolean("enabled", true)
|
||||
}
|
||||
val pomeloProviders = data.optJSONArray("pomeloJsProviders") ?: JSONArray()
|
||||
val pomeloKey = data.optString("pomeloJsKey").ifBlank { null }
|
||||
val card = pomeloKey != null && (0 until pomeloProviders.length()).any { pomeloProviders.optString(it) == "mpgs" }
|
||||
val cents = if (txn.isNull("payAmount")) txn.optLong("amount") else txn.optLong("payAmount")
|
||||
|
||||
return PayPage(
|
||||
transactionId = transactionId,
|
||||
merchantName = merchant?.optString("tradingName")?.ifBlank { null }
|
||||
?: merchant?.optString("registeredName").orEmpty(),
|
||||
merchantAddress = listOfNotNull(
|
||||
merchant?.optString("address1")?.ifBlank { null },
|
||||
merchant?.optString("city")?.ifBlank { null }
|
||||
).joinToString(", "),
|
||||
amount = cents / 100.0,
|
||||
currency = txn.optString("payCurrency").ifBlank { txn.optString("currency", "MVR") },
|
||||
state = txn.optString("state"),
|
||||
supportsBmlPay = bmlPay,
|
||||
supportsCard = card,
|
||||
pomeloKey = pomeloKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the transaction's EMV QR payload (`vendorQrCode`).
|
||||
*
|
||||
@@ -41,6 +113,12 @@ class BmlMerchantTxnClient {
|
||||
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
|
||||
}
|
||||
|
||||
/** The PATCHes the page sends on load: register this "browser" and clear any FX selection. */
|
||||
fun announceBrowser(transactionId: String) {
|
||||
patch(transactionId, JSONObject().put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}"))
|
||||
patch(transactionId, JSONObject().put("fx", "reset"))
|
||||
}
|
||||
|
||||
private fun patch(transactionId: String, body: JSONObject): JSONObject {
|
||||
val request = Request.Builder()
|
||||
.url("$API_BASE/transactions/$transactionId")
|
||||
@@ -66,8 +144,9 @@ class BmlMerchantTxnClient {
|
||||
if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null }
|
||||
|
||||
companion object {
|
||||
private const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
|
||||
private const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
|
||||
internal const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
|
||||
internal const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
|
||||
private const val APP_DATA_PREFIX = "window.appData = "
|
||||
private const val PROVIDER_BML = "bml_mpos"
|
||||
private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$")
|
||||
private val TXN_ID = Regex("^[0-9a-fA-F]{24}$")
|
||||
|
||||
@@ -463,9 +463,11 @@ class TransferFragment : Fragment() {
|
||||
if (draft.amount.isNotEmpty()) binding.etAmount.setText(draft.amount)
|
||||
if (draft.remarks.isNotEmpty()) binding.etRemarks.setText(draft.remarks)
|
||||
val bmlQr = draft.bmlQrInfo
|
||||
val bmlCardMerchant = draft.bmlCardMerchant
|
||||
val mfaisaQr = draft.mfaisaQrInfo
|
||||
val mfaisaRecipient = draft.mfaisaRecipient
|
||||
when {
|
||||
bmlCardMerchant != null -> bmlHandler().showCardMerchant(bmlCardMerchant)
|
||||
bmlQr != null -> bmlHandler().showQrMerchant(bmlQr)
|
||||
mfaisaQr != null -> mfaisaHandler().showQrMerchant(mfaisaQr)
|
||||
mfaisaRecipient != null -> mfaisaHandler().showResolvedRecipient(mfaisaRecipient, saveRecent = false)
|
||||
@@ -553,7 +555,7 @@ class TransferFragment : Fragment() {
|
||||
|
||||
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
||||
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
|
||||
if (bmlHandler().hasQrMerchant) {
|
||||
if (bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant) {
|
||||
val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT"
|
||||
if (!isCard) {
|
||||
Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show()
|
||||
@@ -826,6 +828,7 @@ class TransferFragment : Fragment() {
|
||||
|
||||
binding.btnClearToInfo.setOnClickListener {
|
||||
bmlHandler().clearQrMerchant()
|
||||
bmlHandler().clearCardMerchant()
|
||||
mfaisaHandler().clearQrMerchant()
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
@@ -879,6 +882,20 @@ class TransferFragment : Fragment() {
|
||||
private fun lookupBmlMerchantTransaction(transactionId: String) {
|
||||
startLookupLoading()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
// Load the payment page first: it says whether the merchant takes BML Pay (QR flow)
|
||||
// or only cards (Pomelo + 3-D Secure flow).
|
||||
val page = withContext(Dispatchers.IO) {
|
||||
runCatching { BmlMerchantTxnClient().fetchPayPage(transactionId) }.getOrNull()
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
|
||||
if (page != null && !page.supportsBmlPay && page.supportsCard) {
|
||||
stopLookupLoading()
|
||||
bmlHandler().payCardMerchant(page)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// BML Pay (or unknown): resolve the QR and pay it like a scanned merchant QR.
|
||||
val target = withContext(Dispatchers.IO) {
|
||||
runCatching { BmlMerchantTxnClient().fetchQrPayload(transactionId) }
|
||||
.getOrNull()?.let { PaymvQrParser.bmlQrPayTarget(it) }
|
||||
@@ -1244,6 +1261,12 @@ class TransferFragment : Fragment() {
|
||||
return
|
||||
}
|
||||
|
||||
// BML card-only merchant payment (no BML Pay) — verified card + 3-D Secure
|
||||
if (bmlHandler().hasCardMerchant) {
|
||||
bmlHandler().submitCardPayment()
|
||||
return
|
||||
}
|
||||
|
||||
val src = selectedAccount ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
@@ -1672,7 +1695,7 @@ class TransferFragment : Fragment() {
|
||||
private fun updateTransferButton() {
|
||||
if (bmlHandler().isOtpFlowActive) return
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||
val recipientReady = bmlHandler().hasQrMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
|
||||
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
|
||||
val hasAll = selectedAccount != null && recipientReady && amount > 0
|
||||
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||
@@ -1684,6 +1707,7 @@ class TransferFragment : Fragment() {
|
||||
internal fun clearForm() {
|
||||
bmlHandler().resetOtpState()
|
||||
bmlHandler().clearQrMerchant()
|
||||
bmlHandler().clearCardMerchant()
|
||||
mfaisaHandler?.clearState()
|
||||
mfaisaHandler?.clearQrMerchant()
|
||||
selectedAccount = null
|
||||
@@ -1879,7 +1903,7 @@ class TransferFragment : Fragment() {
|
||||
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
|
||||
b.root.alpha = when {
|
||||
inactive -> 0.4f
|
||||
bmlHandler().hasQrMerchant && !isCard -> 0.35f
|
||||
(bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant) && !isCard -> 0.35f
|
||||
else -> 1f
|
||||
}
|
||||
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
||||
|
||||
@@ -15,6 +15,7 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
|
||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||
@@ -417,6 +418,170 @@ class BmlTransferHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Card-only merchant payment (no BML Pay) ─────────────────────────────
|
||||
|
||||
/** A card-only BML merchant is loaded — the fragment treats it like the QR merchant mode. */
|
||||
val hasCardMerchant: Boolean get() = cardMerchant != null
|
||||
private val cardMerchant get() = draft.bmlCardMerchant
|
||||
|
||||
/** A verified BML card we also hold a login (OTP seed) for — can go through the 3-D Secure step. */
|
||||
private fun verifiedCardCandidates(): List<BankAccount> {
|
||||
val store = CredentialStore(ctx)
|
||||
val verifiedKeys = sh.sar.basedbank.util.VerifiedCardStore.keys(ctx)
|
||||
return (viewModel.accounts.value ?: emptyList())
|
||||
.filter { isCard(it) && verifiedKeys.contains("bml:${it.accountNumber}") }
|
||||
.filter { store.loadBmlCredentials(it.loginTag.removePrefix("bml_"))?.otpSeed != null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as
|
||||
* a card payment: paints the merchant as the recipient, locks the amount, and limits the source
|
||||
* to the user's verified BML cards. Send then runs the Pomelo + 3-D Secure flow.
|
||||
*/
|
||||
fun payCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
|
||||
if (page.isPaid) {
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_already_paid, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
if (verifiedCardCandidates().isEmpty()) {
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
draft.bmlCardMerchant = page
|
||||
showCardMerchant(page)
|
||||
|
||||
// Default to a verified card if nothing suitable is already selected.
|
||||
if (currentSource()?.let { isCardVerified(it) } != true) {
|
||||
clearSource()
|
||||
val candidates = verifiedCardCandidates()
|
||||
val default = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||
(candidates.firstOrNull { it.accountNumber == default } ?: candidates.firstOrNull())
|
||||
?.let { selectSource(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCardVerified(account: BankAccount): Boolean =
|
||||
isCard(account) && sh.sar.basedbank.util.VerifiedCardStore.isVerified(ctx, "bml:${account.accountNumber}") &&
|
||||
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed != null
|
||||
|
||||
/** Paints the loaded card-only merchant into the "To" card and locks the amount. */
|
||||
fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
|
||||
hideToRow()
|
||||
binding.tvToAccountName.text = page.merchantName
|
||||
binding.tvToBankBic.text = page.merchantAddress.ifBlank { "BML Merchant" }
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(page.merchantName, "#0066A1"))
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
binding.etAmount.setText("%.2f".format(page.amount))
|
||||
fragment.setAmountLocked(true)
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.tilRemarks.alpha = 0.4f
|
||||
onStateChanged()
|
||||
}
|
||||
|
||||
/** Drops the loaded card merchant and unlocks the amount/remarks fields. */
|
||||
fun clearCardMerchant() {
|
||||
if (cardMerchant == null) return
|
||||
draft.bmlCardMerchant = null
|
||||
fragment.setAmountLocked(false)
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
|
||||
/** Confirm-then-pay for the loaded card merchant, using the selected verified card. */
|
||||
fun submitCardPayment() {
|
||||
val page = cardMerchant ?: return
|
||||
val src = currentSource()
|
||||
if (src == null || !isCardVerified(src)) {
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
confirmCardMerchant(page, src)
|
||||
}
|
||||
|
||||
private fun confirmCardMerchant(
|
||||
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
|
||||
src: BankAccount
|
||||
) {
|
||||
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
|
||||
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
|
||||
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = page.currency,
|
||||
amountValue = "%.2f".format(page.amount),
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = fromDetail,
|
||||
toName = page.merchantName,
|
||||
toNumber = "",
|
||||
toDetail = page.merchantAddress.ifBlank { "BML Merchant" }
|
||||
)
|
||||
fragment.showConfirmWithBiometric(
|
||||
title = ctx.getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "${page.currency} ${"%.2f".format(page.amount)} → ${page.merchantName}",
|
||||
onConfirmed = { dialog, frame ->
|
||||
fragment.showProcessingInDialog(dialog, frame)
|
||||
executeCardMerchant(page, src, dialog, frame)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeCardMerchant(
|
||||
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
|
||||
src: BankAccount,
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout
|
||||
) {
|
||||
val stored = sh.sar.basedbank.util.VerifiedCardStore.load(ctx, "bml:${src.accountNumber}")
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val otpSeed = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||
val expiry = stored?.expiry?.split("/") // "MM/YY"
|
||||
if (stored == null || otpSeed == null || expiry?.size != 2) {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
val card = sh.sar.basedbank.api.bml.BmlMerchantCardPayClient.Card(
|
||||
pan = stored.pan,
|
||||
expiryMonth = expiry[0].padStart(2, '0'),
|
||||
expiryYear = expiry[1].takeLast(2),
|
||||
cvv = stored.cvv,
|
||||
holderName = src.accountBriefName
|
||||
)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
BmlMerchantCardPayClient().pay(page, card) { _ -> Totp.generate(otpSeed) }
|
||||
}.getOrElse {
|
||||
BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed")
|
||||
}
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
when (result) {
|
||||
is BmlMerchantCardPayClient.Result.Success -> fragment.showSuccessInDialog(
|
||||
dialog, frame,
|
||||
amountCurrency = page.currency,
|
||||
amountValue = "%.2f".format(page.amount),
|
||||
fromName = src.accountBriefName,
|
||||
toName = page.merchantName
|
||||
) {
|
||||
fragment.clearForm()
|
||||
host?.triggerRefresh()
|
||||
}
|
||||
is BmlMerchantCardPayClient.Result.Failure -> {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, result.message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,9 @@ class TransferDraft {
|
||||
var remarks = ""
|
||||
var toText = ""
|
||||
|
||||
// BML card-only merchant payment (merchant without BML Pay, paid by verified card + 3-D Secure)
|
||||
var bmlCardMerchant: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage? = null
|
||||
|
||||
// BML merchant QR
|
||||
var bmlQrInfo: BmlQrPayInfo? = null
|
||||
/** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */
|
||||
|
||||
@@ -50,6 +50,9 @@ object VerifiedCardStore {
|
||||
|
||||
fun isVerified(context: Context, cardKey: String): Boolean = prefs(context).contains(cardKey)
|
||||
|
||||
/** All stored card keys (e.g. "bml:<accountNumber>"). */
|
||||
fun keys(context: Context): Set<String> = prefs(context).all.keys
|
||||
|
||||
fun remove(context: Context, cardKey: String) {
|
||||
prefs(context).edit().remove(cardKey).apply()
|
||||
}
|
||||
|
||||
@@ -297,6 +297,8 @@
|
||||
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
||||
<string name="bml_qr_lookup_failed">Could not load merchant details</string>
|
||||
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
|
||||
<string name="bml_card_pay_no_verified">No verified card available. Verify a BML card first in Manage Card.</string>
|
||||
<string name="bml_card_pay_already_paid">This payment has already been completed.</string>
|
||||
<string name="bml_qr_payment_success">Payment Successful</string>
|
||||
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user