From 25b5c80c49edbfd53b81c6fd5554e5adf75e7d51 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Thu, 1 Oct 2026 06:00:23 +0500 Subject: [PATCH] inital tests for pay via card --- .../sar/basedbank/api/bml/BmlApiConstants.kt | 2 + .../sh/sar/basedbank/api/bml/BmlLoginFlow.kt | 2 +- .../api/bml/BmlMerchantCardPayClient.kt | 301 ++++++++++++++++++ .../basedbank/api/bml/BmlMerchantTxnClient.kt | 87 ++++- .../sar/basedbank/ui/home/TransferFragment.kt | 30 +- .../ui/home/transfer/BmlTransferHandler.kt | 165 ++++++++++ .../ui/home/transfer/TransferDraft.kt | 3 + .../sar/basedbank/util/VerifiedCardStore.kt | 3 + app/src/main/res/values/strings.xml | 2 + 9 files changed, 587 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlApiConstants.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlApiConstants.kt index 51bd390..fd950bc 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlApiConstants.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlApiConstants.kt @@ -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() diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt index b04762b..22cb169 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt @@ -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>() private val cookieJar = object : CookieJar { diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt new file mode 100644 index 0000000..441b025 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt @@ -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/` (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>() + 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) { + 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 = + 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 `` 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) { + 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("]*>", RegexOption.IGNORE_CASE) + private val ACTION = Regex("action\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE) + private val INPUT = Regex("]*>", 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 `
` 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() + 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 + } +} diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt index c3d0b84..7e94663 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt @@ -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/`), - * 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 `//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("", 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}$") diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index be1c034..3e08c63 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -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) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt index a8bd22d..36c0712 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt @@ -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 { + 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) ────────── /** diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt index c0d8edc..f2dce1b 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt @@ -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. */ diff --git a/app/src/main/java/sh/sar/basedbank/util/VerifiedCardStore.kt b/app/src/main/java/sh/sar/basedbank/util/VerifiedCardStore.kt index 35788a1..9171679 100644 --- a/app/src/main/java/sh/sar/basedbank/util/VerifiedCardStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/VerifiedCardStore.kt @@ -50,6 +50,9 @@ object VerifiedCardStore { fun isVerified(context: Context, cardKey: String): Boolean = prefs(context).contains(cardKey) + /** All stored card keys (e.g. "bml:"). */ + fun keys(context: Context): Set = prefs(context).all.keys + fun remove(context: Context, cardKey: String) { prefs(context).edit().remove(cardKey).apply() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6640e5..3609342 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -297,6 +297,8 @@ Looking up merchant… Could not load merchant details Could not load BML payment for this transaction ID + No verified card available. Verify a BML card first in Manage Card. + This payment has already been completed. Payment Successful Select a BML account to pay from