Compare commits
5
Commits
2b2fd59543
...
1bf63ed55b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bf63ed55b
|
||
|
|
3350c84a33
|
||
|
|
449c27ced2
|
||
|
|
3c5d3ff883
|
||
|
|
25b5c80c49
|
@@ -22,7 +22,7 @@ android {
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 33
|
||||
versionName = "1.0.32"
|
||||
versionName = "1.0.33"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -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}$")
|
||||
|
||||
@@ -269,7 +269,17 @@ class CardsFragment : Fragment() {
|
||||
}
|
||||
binding.btnBlock.setOnClickListener(wip)
|
||||
binding.btnVerify.setOnClickListener {
|
||||
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
|
||||
// Already-verified cards: a tap only informs; long-press re-verifies to update.
|
||||
if (VerifiedCardStore.isVerified(requireContext(), cardItemKey(item))) {
|
||||
Toast.makeText(requireContext(), R.string.card_verify_already, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
onVerifyClicked(item)
|
||||
}
|
||||
}
|
||||
binding.btnVerify.setOnLongClickListener {
|
||||
cards.getOrNull(currentCardPosition)?.let { onVerifyClicked(it) }
|
||||
true
|
||||
}
|
||||
binding.btnCancelVerify.setOnClickListener { setVerifyMode(false) }
|
||||
binding.btnManualVerify.setOnClickListener {
|
||||
|
||||
@@ -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,176 @@ 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 warnings = listOf(
|
||||
"⚠ ${page.merchantName} does not support BML Pay. This transaction will be paid via card. " +
|
||||
"Card payments can be less reliable, and this can take up to a minute to complete. " +
|
||||
"Please keep the app open and don't retry if it seems slow."
|
||||
)
|
||||
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" },
|
||||
warningTexts = warnings
|
||||
)
|
||||
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>
|
||||
|
||||
@@ -389,6 +391,7 @@
|
||||
<string name="card_action_block">Block</string>
|
||||
<string name="card_action_verify">Verify</string>
|
||||
<string name="card_action_verified">Verified</string>
|
||||
<string name="card_verify_already">Card already verified. Press and hold to update.</string>
|
||||
<string name="card_verify_title">Verify Card</string>
|
||||
<string name="card_verify_tap">Tap card to verify</string>
|
||||
<string name="card_verify_reading">Reading card… hold still</string>
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
# Merchant Card Payment (no BML Pay)
|
||||
|
||||
BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`,
|
||||
e.g. the bill links Fenaka and Fahipay send) are paid one of two ways depending on what the
|
||||
merchant has enabled:
|
||||
|
||||
| Merchant capability | How it is paid | Doc |
|
||||
|---|---|---|
|
||||
| **BML Pay** (`bml_mpos`) enabled | Fetch the merchant's QR text, pay it via the normal QR flow | [QR Payment](13-qr-payment.md) |
|
||||
| **Card only** (no BML Pay) | Enter card details → Pomelo tokenise → MPGS + 3-D Secure | **this doc** |
|
||||
|
||||
The payment page is a React app (Pomelo Pay, white-labelled as "Bank of Maldives Merchant
|
||||
Services"). The card flow here replays the exact requests that page and the issuer's 3-D Secure
|
||||
challenge make in a browser. Reconstructed from `docs/bmlapi/tmp/bmlpaywithid-verifiedcard.har`.
|
||||
|
||||
> ⚠️ This flow is **scraped browser/ACS traffic**, not a stable API. See
|
||||
> [Fragility](#fragility--what-can-break) before relying on it.
|
||||
|
||||
---
|
||||
|
||||
## Hosts
|
||||
|
||||
| Purpose | Base URL | Notes |
|
||||
|---|---|---|
|
||||
| Payment page (`/paynow`) | `https://transaction.merchants.bankofmaldives.com.mv` | Behind Cloudflare — **browser User-Agent required** |
|
||||
| Merchant API | `https://api.merchants.bankofmaldives.com.mv` | Tolerates non-browser UA |
|
||||
| Card tokenisation (Pomelo CDE) | `https://api.pay.pomelopay.com` | `bin-lookup` |
|
||||
| 3-D Secure ACS (Wibmo) | `https://secure-acs2ui-bk2-<dc>.wibmo.com` | Behind Cloudflare; `<dc>` varies (e.g. `indmum-mumrdc`, `indblr-blrtdc`) |
|
||||
| Card scheme gateway | `https://ap.gateway.mastercard.com` | MPGS |
|
||||
|
||||
---
|
||||
|
||||
## Detecting the merchant type
|
||||
|
||||
`GET /<id>/paynow` returns server-rendered HTML with everything inline in a
|
||||
`window.appData = {…}` script. Parse that JSON (the code reads between `window.appData = ` and the
|
||||
next `</script>`):
|
||||
|
||||
| `window.appData` field | Meaning |
|
||||
|---|---|
|
||||
| `transaction.state` | `QR_CODE_GENERATED` normally; `CONFIRMED` if already paid |
|
||||
| `transaction.payAmount` / `transaction.amount` | Amount in **cents** (payAmount preferred; falls back to amount) |
|
||||
| `transaction.payCurrency` / `transaction.currency` | e.g. `MVR` |
|
||||
| `merchant.tradingName` / `registeredName` | Display name |
|
||||
| `availableProviders[]` | Contains `{value:"bml_mpos", enabled:true}` **iff BML Pay is enabled** |
|
||||
| `pomeloJsProviders[]` | Contains `"mpgs"` when card entry is offered |
|
||||
| `pomeloJsKey` | `pk_production_…` — the card form's auth token (a JWT carrying the merchant id) |
|
||||
|
||||
**Decision:** `supportsBmlPay = availableProviders` contains an enabled `bml_mpos`;
|
||||
`supportsCard = pomeloJsKey present && pomeloJsProviders` contains `mpgs`.
|
||||
Route to the card flow only when **`!supportsBmlPay && supportsCard`**.
|
||||
|
||||
> The `/paynow` host is fronted by Cloudflare and returns **403** to the `okhttp/*` User-Agent.
|
||||
> Send a browser UA (`BML_WEB_USER_AGENT`) + `Accept: text/html…`. The `api.merchants…` host is
|
||||
> not UA-gated, which is why the PATCHes below work with the default client.
|
||||
|
||||
---
|
||||
|
||||
## Flow overview
|
||||
|
||||
```
|
||||
GET /<id>/paynow → window.appData (merchant type, pomeloJsKey)
|
||||
PATCH transactions/<id> {activeBrowserId} ─┐ announce browser
|
||||
PATCH transactions/<id> {fx:"reset"} ─┘
|
||||
GET public-client/credentials/<id> → RSA public key + Pomelo apiKey
|
||||
POST api.pay.pomelopay.com/bin-lookup → tokenId (card encrypted here)
|
||||
POST public-client/transactions/next-action RATE_OPTIONS → WAIT
|
||||
POST …next-action POLL (every 5s) → THREEDS + 3dsUrl
|
||||
GET <3dsUrl> (modirum/render-tds) → auto-POST form (creq → ACS)
|
||||
POST <ACS creq url> creq → OTP channel picker
|
||||
POST <ACS creq url> destValue=token… → OTP entry page
|
||||
POST <ACS creq url> otpValue=<token TOTP> → auto-POST form (cres → gateway)
|
||||
POST <gateway callback> cres → auto-POST form (→ mpgsNotification)
|
||||
POST transactions/mpgsNotification/<id> → records the verdict
|
||||
POST …next-action POLL → TRANSACTION_CONFIRMED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Announce browser
|
||||
|
||||
Two unauthenticated PATCHes the page sends on load (needed by `fx`/state bookkeeping). `Origin` /
|
||||
`Referer` are the transaction host.
|
||||
|
||||
```
|
||||
PATCH https://api.merchants.bankofmaldives.com.mv/transactions/<id>
|
||||
Content-Type: application/json
|
||||
|
||||
{"activeBrowserId":"<id>_<epoch-millis>"}
|
||||
```
|
||||
```
|
||||
PATCH …/transactions/<id>
|
||||
{"fx":"reset"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Credentials
|
||||
|
||||
```
|
||||
GET https://api.merchants.bankofmaldives.com.mv/public-client/credentials/<id>
|
||||
Authorization: <pomeloJsKey> # the pk_production_… from the page
|
||||
```
|
||||
```json
|
||||
{
|
||||
"publicKey": {
|
||||
"publicKeyId": "3edf1db0-…",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN…\n-----END PUBLIC KEY-----"
|
||||
},
|
||||
"apiKey": "UU8a9m4Q…",
|
||||
"binLookupUrl": "https://api.pay.pomelopay.com/bin-lookup"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Tokenise the card (`bin-lookup`)
|
||||
|
||||
The card number, CVV and expiry are **RSA-OAEP(SHA-1)** encrypted with `publicKeyPem`, Base64
|
||||
(no-wrap) encoded. The Pomelo JS uses WebCrypto `{name:"RSA-OAEP", hash:"SHA-1"}` over the plain
|
||||
strings — the Java equivalent is `RSA/ECB/OAEPPadding` with
|
||||
`OAEPParameterSpec("SHA-1","MGF1",MGF1ParameterSpec.SHA1,PSpecified.DEFAULT)`.
|
||||
|
||||
| Plaintext encrypted | Field |
|
||||
|---|---|
|
||||
| PAN (digits only) | `encryptedCardNumber` |
|
||||
| CVV | `encryptedCardSecurityCode` |
|
||||
| `YYMM` (year then month) | `encryptedCardExpiry` |
|
||||
|
||||
```
|
||||
POST https://api.pay.pomelopay.com/bin-lookup
|
||||
Content-Type: application/json
|
||||
tenant: bankofmaldives
|
||||
x-api-key: <apiKey>
|
||||
x-tenant-id:
|
||||
|
||||
{
|
||||
"encryptedCardNumber":"<b64>",
|
||||
"encryptedCardSecurityCode":"<b64>",
|
||||
"encryptedCardExpiry":"<b64>",
|
||||
"externalId":"<id>",
|
||||
"cardHolderName":"NAME ON CARD",
|
||||
"encryptedCardExpiryMonth":"07", // NOTE: sent in clear despite the name
|
||||
"encryptedCardExpiryYear":"28",
|
||||
"encSerialId":"<publicKeyId>"
|
||||
}
|
||||
```
|
||||
```json
|
||||
{ "tokenId":"24d5be26…", "bin8":"42136300", "brand":"V" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Rate options → 3-D Secure URL
|
||||
|
||||
All `next-action` calls POST to the merchant API with `Authorization: <pomeloJsKey>`.
|
||||
|
||||
```
|
||||
POST https://api.merchants.bankofmaldives.com.mv/public-client/transactions/next-action
|
||||
Authorization: <pomeloJsKey>
|
||||
|
||||
{ "action":"RATE_OPTIONS", "transactionId":"<id>",
|
||||
"cardBrand":"V", "bin8":"42136300", "tokenId":"<tokenId>",
|
||||
"javaEnabled":false, "javascriptEnabled":true, "language":"en-US",
|
||||
"colorDepth":24, "screenHeight":1850, "screenWidth":1080, "tz":-300,
|
||||
"userAgent":"Mozilla/5.0 (Android …; Mobile)" }
|
||||
```
|
||||
|
||||
Response `action` values:
|
||||
|
||||
| `action` | Meaning | Do |
|
||||
|---|---|---|
|
||||
| `WAIT` | Processing | Poll (below) |
|
||||
| `POLL` | Keep polling | Poll |
|
||||
| `THREEDS` + `3dsUrl` | Challenge required | Run [§5](#5-3-d-secure-challenge) |
|
||||
| `TRANSACTION_CONFIRMED` | Paid (frictionless) | Done |
|
||||
| `TRANSACTION_FAILED` | Declined | Fail |
|
||||
|
||||
Poll body (every **5 s**, no browser-info):
|
||||
|
||||
```
|
||||
POST …/next-action { "action":"POLL", "transactionId":"<id>" }
|
||||
```
|
||||
|
||||
> In the capture: `RATE_OPTIONS → WAIT`, then one `POLL → THREEDS` with
|
||||
> `3dsUrl = …/modirum/render-tds?transactionId=<id>`.
|
||||
|
||||
---
|
||||
|
||||
## 5. 3-D Secure challenge (Wibmo ACS)
|
||||
|
||||
A chain of auto-submitting HTML forms. **Only the `render-tds` form and the final gateway /
|
||||
notification forms carry an `action` attribute** — the ACS's channel-picker and OTP forms have
|
||||
no `action`; their JavaScript posts back to the **same creq URL**. So the creq URL (the
|
||||
`render-tds` form's action) is the fallback action for every subsequent form.
|
||||
|
||||
1. **`GET <3dsUrl>`** (`render-tds`) → a form posting `creq` to
|
||||
`https://secure-acs…wibmo.com/v1/acs/services/browser/creq/L/8573/<acsTransId>`. Capture that
|
||||
URL as the ACS creq URL.
|
||||
2. **POST creq** → the **channel picker**: radios `destValue ∈ {mobile, email, token}`, plus hidden
|
||||
`creq`, `authMethod`, `otpDest`, `selectChannel`, `otpChannels`, `formReqType`. The BML token /
|
||||
authenticator is the **`token`** channel. Submit:
|
||||
`destValue=token`, `selectChannel=token`, `authMethod=OOB`, `otpDest=`, `formReqType=SUBMIT`
|
||||
(keep the hidden `creq` / `otpChannels`).
|
||||
3. **POST channel** → the **OTP entry** page (`otpValue` input). Submit `otpValue=<BML token TOTP>`,
|
||||
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page with text containing
|
||||
*"incorrect"* / *"expired"* — regenerate the TOTP and retry once.
|
||||
4. On success the ACS returns a form auto-posting **`cres`** to the Mastercard gateway; the gateway
|
||||
returns a form auto-posting the result (`order.id`, `result=SUCCESS`, …) to
|
||||
**`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded.
|
||||
|
||||
Cookies (`__cf_bm`, `_cfuvid`) are set by the ACS and must be carried across these POSTs — the
|
||||
Cloudflare-fronted ACS also requires a browser User-Agent.
|
||||
|
||||
---
|
||||
|
||||
## 6. Confirm
|
||||
|
||||
Poll `next-action` until the recorded verdict surfaces:
|
||||
|
||||
| `action` | Result |
|
||||
|---|---|
|
||||
| `TRANSACTION_CONFIRMED` | Success |
|
||||
| `TRANSACTION_FAILED` | Declined |
|
||||
|
||||
The merchant's own backend is also notified out-of-band (e.g.
|
||||
`fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`).
|
||||
|
||||
---
|
||||
|
||||
## Fragility — what can break
|
||||
|
||||
This is scraped glue across BML, Pomelo, Wibmo and MPGS. No versioned contract, no sandbox; you
|
||||
learn of breakage from a failed live payment.
|
||||
|
||||
| Area | Breaks when | Symptom |
|
||||
|---|---|---|
|
||||
| **ACS HTML scraping** (most fragile) | Wibmo changes field names (`destValue`/`otpValue`/`creq`), the `"token"` channel label, the error wording, or the form layout | "Unexpected authentication page" / wrong-OTP loop |
|
||||
| **Cloudflare** | `/paynow` or `wibmo.com` adds a JS/managed challenge or TLS-fingerprint check | 403; **not fixable by UA alone** |
|
||||
| **TOTP seed assumption** | The card's 3-D Secure "authenticator" is not the same soft-token TOTP as the BML login; or the card only offers SMS/email OTP | Wrong code submitted; auth fails |
|
||||
| **Pomelo crypto/contract** | OAEP hash change (SHA-1→256), added nonce/timestamp, renamed fields, moved endpoint | `bin-lookup` rejects the card |
|
||||
| **`next-action` states** | New/renamed actions, or browser-info becomes validated | Poll never resolves |
|
||||
| **Merchant detection** | BML adds other card providers (UnionPay, Apple/Google Pay); non-`mpgs` card provider | Misroute to the wrong flow |
|
||||
| **`window.appData` parsing** | Key moved/obfuscated or made dynamically signed | No `pomeloJsKey` |
|
||||
| **Double-charge** | Confirm poll times out but the charge went through | Retry risks paying twice |
|
||||
|
||||
**Maintenance:** re-capture a HAR whenever any party updates; expect to touch the ACS form parser
|
||||
most often; the flow is effectively untestable in CI (no deterministic 3-D Secure double). Keep the
|
||||
gitignored HARs under `docs/bmlapi/tmp/` as reference fixtures to diff against.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Related:** [QR Payment](13-qr-payment.md) · App side:
|
||||
[Card Verification & Merchant Card Pay](../thijooree/29-card-verification-and-merchant-card-pay.md)
|
||||
|
||||
[← Card Freeze](15-card-freeze.md)
|
||||
@@ -193,6 +193,7 @@ The access token expires after `expires_in` seconds (typically 3600). On a `401`
|
||||
| 13 | [QR Payment](13-qr-payment.md) | PayMV QR payment — QR formats, payrequest lookup, 3-step pay flow |
|
||||
| 14 | [Notifications](14-notifications.md) | Notifications list, mark-as-read, and polling |
|
||||
| 15 | [Card Freeze](15-card-freeze.md) | Freeze / unfreeze a BML card |
|
||||
| 16 | [Merchant Card Payment](16-card-payment.md) | Pay a card-only BML Merchant Services link — Pomelo tokenise + 3-D Secure |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Card Verification & Merchant Card Payment
|
||||
|
||||
Two linked features:
|
||||
|
||||
1. **Card verification** — on the [Cards](22-cards.md) manage screen, a **Verify** action reads the
|
||||
physical card over NFC (or takes it by hand), checks it matches the on-screen card, and stores the
|
||||
full card details (PAN, expiry, CVV) encrypted on-device.
|
||||
2. **Merchant card payment** — on [Transfer](07-transfer.md), a BML Merchant Services transaction ID
|
||||
whose merchant has **no BML Pay** is paid with a verified card via the Pomelo + 3-D Secure flow
|
||||
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
|
||||
|
||||
> ⚠️ The merchant card flow is scraped browser/ACS traffic, not a stable API. Storing the CVV is a
|
||||
> security/PCI liability. See the API doc's
|
||||
> [Fragility](../bmlapi/16-card-payment.md#fragility--what-can-break) section.
|
||||
|
||||
---
|
||||
|
||||
## Card verification
|
||||
|
||||
### Entry — the Verify button
|
||||
|
||||
In manage mode the action row has **Change PIN · Freeze · Block · Verify**
|
||||
(`fragment_cards.xml`, icon `ic_card_verify`). The button reads **Verified** once the selected card
|
||||
has a stored entry (`bindManageCardData` in `PayWithCardFragment.kt`).
|
||||
|
||||
`onVerifyClicked(item)` (`PayWithCardFragment.kt:296`) branches on NFC:
|
||||
|
||||
| Device state | Behaviour |
|
||||
|---|---|
|
||||
| No NFC hardware | Straight to manual entry (`showCardDetailsDialog`) |
|
||||
| NFC off | Dialog: **NFC Settings** / **Manually Verify** / Cancel |
|
||||
| NFC ready | Enter verify mode (tap animation) |
|
||||
|
||||
### Verify mode
|
||||
|
||||
`setVerifyMode(enabled, item)` (`PayWithCardFragment.kt:314`) swaps the manage action buttons for
|
||||
**Cancel Verification** / **Manually Verify**, and draws `CardVerifyAnimationView`
|
||||
(`ui/home/CardVerifyAnimationView.kt`) in the empty area — a flat card tapping a phone with NFC
|
||||
waves, matching the [Tap to Pay](23-tap-to-pay.md) style, with `WAITING / READING / SUCCESS / ERROR`
|
||||
states.
|
||||
|
||||
`startVerifyReader()` (`PayWithCardFragment.kt:346`) uses `NfcAdapter.enableReaderMode` (reader,
|
||||
not HCE). On tap, `EmvCardReader.read(tag)` (`nfc/EmvCardReader.kt:24`) runs a minimal contactless
|
||||
EMV read (PPSE → SELECT AID → GPO → read AFL records) and returns `CardData(pan, expiry)` from tags
|
||||
`5A` / `57` (Track 2) and `5F24`. `onVerifyCardRead` (`:367`) compares the **last 4 digits** against
|
||||
the managed card:
|
||||
|
||||
- **match** → success check mark → `showCardDetailsDialog(item, nfcData)` for the CVV;
|
||||
- **mismatch / unreadable** → error state, then back to waiting.
|
||||
|
||||
### Card details dialog
|
||||
|
||||
`showCardDetailsDialog(item, nfcData?)` (`PayWithCardFragment.kt:406`, layout
|
||||
`dialog_card_manual_verify.xml`):
|
||||
|
||||
- The **name** is always prefilled read-only from the API-provided holder name (`accountBriefName`
|
||||
for BML, `cardHolderName` for MIB) — never read off the chip.
|
||||
- **After an NFC tap** (`nfcData != null`): card number + expiry are prefilled and **locked**; only
|
||||
the CVV is entered. Title shows `Card ending <4>`.
|
||||
- **Manual entry**: number + expiry + CVV entered; validated with a Luhn check (`luhnValid`,
|
||||
`:500`), last-4 match, a not-in-the-past expiry (`normalizeExpiry`, `:489`), and a 3–4 digit CVV.
|
||||
|
||||
`saveVerifiedCard` (`PayWithCardFragment.kt:481`) writes the entry and toggles the button to
|
||||
**Verified**.
|
||||
|
||||
### Storage — `VerifiedCardStore`
|
||||
|
||||
`util/VerifiedCardStore.kt`. Per-card entry keyed by the card's identity (`bml:<accountNumber>` /
|
||||
`mib:<cardId>`), encrypted with the shared `CacheEncryption` AndroidKeyStore key (same as the other
|
||||
caches).
|
||||
|
||||
```
|
||||
VerifiedCard(pan, expiry /*MM/YY*/, cvv, method /*nfc|manual*/, verifiedAt)
|
||||
```
|
||||
|
||||
`save` / `load` / `isVerified` / `keys` / `remove` / `clear`. **Not** wiped by the "clear cache" or
|
||||
"remove login" paths — treated as user data (like profile images).
|
||||
|
||||
---
|
||||
|
||||
## Merchant card payment
|
||||
|
||||
### Routing — card-only vs BML Pay
|
||||
|
||||
A transaction ID / link typed into Transfer's **To** field is parsed by
|
||||
`BmlMerchantTxnClient.parseTransactionId` and resolved in
|
||||
`TransferFragment.lookupBmlMerchantTransaction` (`TransferFragment.kt:882`):
|
||||
|
||||
1. `BmlMerchantTxnClient.fetchPayPage(id)` (`api/bml/BmlMerchantTxnClient.kt:43`) loads `/paynow`
|
||||
(browser UA — the host is Cloudflare-fronted) and parses `window.appData`.
|
||||
2. If `!supportsBmlPay && supportsCard` → `bmlHandler().payCardMerchant(page)` (card flow).
|
||||
3. Otherwise → existing QR path (`fetchQrPayload` → `bmlQrPayTarget` → `openBmlQr`), see
|
||||
[Transfer Flows](20-transfer-flows.md).
|
||||
|
||||
### On-screen, like the QR merchant mode
|
||||
|
||||
`BmlTransferHandler.payCardMerchant(page)` (`ui/home/transfer/BmlTransferHandler.kt:441`) renders
|
||||
into the Transfer screen rather than a one-off dialog, mirroring the BML QR merchant mode:
|
||||
|
||||
- `showCardMerchant(page)` (`:468`) paints the merchant as the **To** card, fills + **locks** the
|
||||
amount (these links carry a fixed amount), and disables remarks.
|
||||
- The **From** picker is limited to BML cards; a verified default card is auto-selected.
|
||||
- State lives in `TransferDraft.bmlCardMerchant`, so it survives tab switches and theme/rotation
|
||||
recreation (repainted via `restoreFromDraft`).
|
||||
- The **✕** on the To card and `clearForm()` both call `clearCardMerchant()` (`:486`), which unlocks
|
||||
and empties the amount and re-enables remarks.
|
||||
|
||||
A card is only offered when it is **both** verified **and** belongs to a BML login the app has an
|
||||
OTP seed for (`verifiedCardCandidates`, `:428`; `isCardVerified`, `:463`) — the 3-D Secure step
|
||||
needs that seed.
|
||||
|
||||
### Send
|
||||
|
||||
`submitCardPayment` (`:496`) → `confirmCardMerchant` (`:506`) shows the shared transfer confirm
|
||||
dialog (biometric-gated), then `executeCardMerchant` (`:534`) runs, off the main thread:
|
||||
|
||||
```
|
||||
BmlMerchantCardPayClient().pay(page, card) { Totp.generate(otpSeed) }
|
||||
```
|
||||
|
||||
where `card` comes from `VerifiedCardStore` (expiry split `MM/YY` → month/year) and `otpSeed` is the
|
||||
card's BML login seed. The client (`api/bml/BmlMerchantCardPayClient.kt`) performs the whole
|
||||
Pomelo + MPGS + Wibmo 3-D Secure sequence — feeding the BML token TOTP into the ACS OTP form
|
||||
automatically, retrying once if the first code expired. Outcome is shown in the shared
|
||||
processing/success dialog; failures surface as a toast.
|
||||
|
||||
### Key assumption
|
||||
|
||||
The 3-D Secure "Authenticator" OTP must be the **same** soft-token TOTP the app already uses for BML
|
||||
transfers (`CredentialStore.loadBmlCredentials(loginId).otpSeed`). This holds for the user's own
|
||||
BML-issued card on a login the app has. It does **not** work for a non-BML card, a card belonging to
|
||||
another login/person, or a card whose 3-D Secure only offers SMS/email OTP.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `ui/home/PayWithCardFragment.kt` | Verify button, verify mode, NFC reader, card details dialog |
|
||||
| `ui/home/CardVerifyAnimationView.kt` | "Tap card to verify" animation |
|
||||
| `nfc/EmvCardReader.kt` | Minimal contactless EMV read (PAN + expiry) |
|
||||
| `util/VerifiedCardStore.kt` | Encrypted per-card store of full details |
|
||||
| `res/layout/dialog_card_manual_verify.xml` | Card details form |
|
||||
| `api/bml/BmlMerchantTxnClient.kt` | `fetchPayPage` (merchant-type detection), `announceBrowser`, QR payload |
|
||||
| `api/bml/BmlMerchantCardPayClient.kt` | Pomelo tokenise + 3-D Secure card payment |
|
||||
| `ui/home/transfer/BmlTransferHandler.kt` | On-screen card merchant mode + payment |
|
||||
| `ui/home/TransferFragment.kt` | Transaction-ID lookup + routing |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Related:** [Cards](22-cards.md) · [Transfer Flows](20-transfer-flows.md) · API side:
|
||||
[Merchant Card Payment](../bmlapi/16-card-payment.md)
|
||||
|
||||
[← Settings — About](28-settings-about.md)
|
||||
@@ -34,6 +34,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|
||||
| [26 — Circular Nav](26-circular-nav.md) | Radial 4-slot wheel UI with lock centre |
|
||||
| [27 — Settings: Notifications](27-settings-notifications.md) | Opt-in flow: permission → battery opt → service start |
|
||||
| [28 — Settings: About](28-settings-about.md) | Version, T&Cs, donate buttons |
|
||||
| [29 — Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md) | NFC/manual card verification + card-only BML merchant payment |
|
||||
|
||||
## Reference
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- Verify cards via NFC or manually
|
||||
- Add support for bml gateway card payment.
|
||||
Reference in New Issue
Block a user