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 2f8f995..51bd390 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 @@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder() .readTimeout(30, TimeUnit.SECONDS) .build() +/** + * Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional: + * handled errors come back as JSON either way, but when a route throws (HTTP 500) the server + * renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON. + */ internal fun bmlApiRequest(session: BmlSession, url: String): Request = Request.Builder().url(url) - .header("Authorization", "Bearer ${session.accessToken}") - .header("User-Agent", BML_USER_AGENT) + .header("accept", "application/json") .header("x-app-version", BML_APP_VERSION) + .header("user-agent", BML_USER_AGENT) + .header("authorization", "Bearer ${session.accessToken}") .build() diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt index 89ef1ec..1208a81 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt @@ -74,6 +74,13 @@ data class BmlQrPayInfo( val currency: String ) +/** + * The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show + * (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and + * parse failures stay plain exceptions so the generic message is used for those instead. + */ +class BmlQrPayLookupException(val code: Int, message: String) : Exception(message) + data class BmlQrPayResult( val success: Boolean, val merchant: String = "", diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlQrPayClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlQrPayClient.kt index aafce82..c69dde8 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlQrPayClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlQrPayClient.kt @@ -1,5 +1,6 @@ package sh.sar.basedbank.api.bml +import android.util.Base64 import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -10,17 +11,27 @@ class BmlQrPayClient { private val client = newBmlApiClient() /** - * Resolves a BML QR URL to merchant details. - * [base64Url] is the full QR URL Base64-encoded (standard, with padding). + * Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare + * `35` → `20` → `01` reference. + * + * The key is Base64-encoded here without padding, as BML's own app sends it. The padded form + * resolves too, so this is parity rather than a requirement. */ - fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo { + fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo { + val key = Base64.encodeToString( + payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING) val request = bmlApiRequest(session, - "$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url") + "$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key") return client.newCall(request).execute().use { response -> val body = response.body?.string() ?: throw Exception("No response") + // A server-side error renders an HTML page under HTTP 200 rather than an error JSON. + if (!body.trimStart().startsWith("{")) + throw Exception("Unexpected non-JSON response (HTTP ${response.code})") val json = JSONObject(body) if (!json.optBoolean("success")) - throw Exception(json.optString("message").ifBlank { "Lookup failed" }) + throw BmlQrPayLookupException( + json.optInt("code"), + json.optString("message").ifBlank { "Lookup failed" }) val payload = json.getJSONObject("payload") val addr2 = payload.optString("narrative2").trim() val addr3 = payload.optString("narrative3").trim() diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/BmlQrPayFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/BmlQrPayFragment.kt index 5678a8e..6ec1a53 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/BmlQrPayFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/BmlQrPayFragment.kt @@ -5,7 +5,6 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.os.Bundle -import android.util.Base64 import android.view.Gravity import android.view.LayoutInflater import android.view.View @@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() { } private fun lookupMerchant(qrUrl: String) { - val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) val app = requireActivity().application as BasedBankApp val session = app.anyBmlSession() ?: run { Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() @@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { val info = withContext(Dispatchers.IO) { - try { BmlQrPayClient().lookupPayRequest(session, base64Url) } + try { BmlQrPayClient().lookupPayRequest(session, qrUrl) } catch (_: Exception) { null } } if (_binding == null) return@launch diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt index 28c322b..948c5a6 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/DashboardFragment.kt @@ -43,10 +43,10 @@ class DashboardFragment : Fragment() { if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null } - val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) - if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { + val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw) + if (bmlTarget != null) { (requireActivity() as HomeActivity).navigateTo( - R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber) + R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber) ) } else { val qr = PaymvQrParser.parse(raw) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index e198aed..b762899 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -528,9 +528,9 @@ fun applyNavLabelVisibility() { private fun routeSharedQrText(text: String) { val store = CredentialStore(this) - val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text) - if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { - navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber())) + val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text) + if (bmlTarget != null) { + navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber())) return } val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt index 9824c8e..2ca307c 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt @@ -77,10 +77,10 @@ class CardsFragment : Fragment() { if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null } - val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) - if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { + val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw) + if (bmlTarget != null) { (requireActivity() as HomeActivity).navigateTo( - R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber) + R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber) ) } else { val qr = PaymvQrParser.parse(raw) 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 74f729f..754c5ea 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 @@ -172,13 +172,13 @@ class TransferFragment : Fragment() { if (result.resultCode != Activity.RESULT_OK) return val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return - // BML card/gateway QR — hand off to dedicated payment screen - val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw) - if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) { + // BML card/gateway/POS QR — hand off to dedicated payment screen + val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw) + if (bmlTarget != null) { val fromCard = selectedAccount?.takeIf { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" } - (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, fromCard?.accountNumber)) + (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber)) return } 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 e2f61bd..8a5bcbe 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 @@ -18,6 +18,7 @@ import sh.sar.basedbank.api.bml.BmlAccountClient import sh.sar.basedbank.api.bml.BmlOtpChannel import sh.sar.basedbank.api.bml.BmlQrPayClient import sh.sar.basedbank.api.bml.BmlQrPayInfo +import sh.sar.basedbank.api.bml.BmlQrPayLookupException import sh.sar.basedbank.api.bml.BmlQrPayResult import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.bml.BmlTransferClient @@ -31,6 +32,7 @@ import sh.sar.basedbank.ui.home.HomeViewModel import sh.sar.basedbank.ui.home.TransferFragment import sh.sar.basedbank.ui.home.TransferReceiptData import sh.sar.basedbank.util.CredentialStore +import sh.sar.basedbank.util.PaymvQrParser import sh.sar.basedbank.util.RecentPick import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.Totp @@ -173,9 +175,10 @@ class BmlTransferHandler( fun lookupQrMerchant(qrUrl: String) { qrLookupAttempted = true - gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") - val base64Url = android.util.Base64.encodeToString( - qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP) + // Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and + // need the extra pre-initiate POST; ebanking qrpay URLs do not. + gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://") + val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl) val session = app.anyBmlSession() ?: return // Lock the "To" input row while loading @@ -185,14 +188,20 @@ class BmlTransferHandler( host?.setRefreshing(true) fragment.viewLifecycleOwner.lifecycleScope.launch { - val info = withContext(Dispatchers.IO) { - try { BmlQrPayClient().lookupPayRequest(session, base64Url) } - catch (_: Exception) { null } + val result = withContext(Dispatchers.IO) { + runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) } } host?.setRefreshing(false) + val info = result.getOrNull() if (info == null) { - Toast.makeText(ctx, R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show() - fragment.requireActivity().onBackPressedDispatcher.onBackPressed() + // An expired or rejected QR is BML telling us something specific — show its own + // wording and stay put with the To row restored, rather than bouncing the user out + // of the screen they just scanned from. + val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message + ?: ctx.getString(R.string.bml_qr_lookup_failed) + Toast.makeText(ctx, message, Toast.LENGTH_LONG).show() + fragment.resetToFieldVisibility() + onStateChanged() return@launch } qrInfo = info diff --git a/app/src/main/java/sh/sar/basedbank/util/PaymvQrParser.kt b/app/src/main/java/sh/sar/basedbank/util/PaymvQrParser.kt index aec50f2..b776772 100644 --- a/app/src/main/java/sh/sar/basedbank/util/PaymvQrParser.kt +++ b/app/src/main/java/sh/sar/basedbank/util/PaymvQrParser.kt @@ -9,22 +9,58 @@ data class PaymvQrData( object PaymvQrParser { + private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/" + private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/" + private const val BML_POS_DOMAIN = "mv.com.bml.qtr" + /** - * Returns the BML gateway URL if [raw] is or contains one, otherwise null. - * Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR). - * For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01) - * rather than via regex, to avoid greedily consuming subsequent EMV tag bytes. + * Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR. + * + * Three shapes exist: + * - plain URL QRs — the QR text is the gateway short link or an ebanking `qrpay` link; + * - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at + * `35` → `20` → `01`; + * - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a + * bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload, + * which [bmlPayRequestKey] then reduces back to the reference the lookup wants. + * + * The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags + * that follow it. */ - fun extractBmlGatewayUrl(raw: String): String? { - if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw - return try { - val root = parseTlv(raw) - val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null - val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null - inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") } - } catch (_: Exception) { - null - } + fun bmlQrPayTarget(raw: String): String? { + if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw + val ref = bmlQrReference(raw) ?: return null + if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) } + // A bare reference only means a BML POS QR when the supplementary domain says so; anything + // else keeps falling through to the PayMV parse, as it did before POS QRs existed. + return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true } + } + + /** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */ + private fun supplementaryDomain(raw: String): String? = try { + parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00") + } catch (_: Exception) { + null + } + + /** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */ + private fun bmlQrReference(raw: String): String? = try { + val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) } + val inner = merchantInfo?.get("20")?.let { parseTlv(it) } + inner?.get("01")?.takeIf { it.isNotBlank() } + } catch (_: Exception) { + null + } + + /** + * The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be + * Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the + * bare `35` → `20` → `01` reference (the whole EMV payload is rejected with code 112, + * "Unsupported payment link"). + */ + fun bmlPayRequestKey(target: String): String { + if (target.startsWith("https://")) return target + return bmlQrReference(target) ?: target } fun parse(raw: String): PaymvQrData? { diff --git a/docs/bmlapi/13-qr-payment.md b/docs/bmlapi/13-qr-payment.md index 667988e..3f9a18a 100644 --- a/docs/bmlapi/13-qr-payment.md +++ b/docs/bmlapi/13-qr-payment.md @@ -33,6 +33,16 @@ TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`** The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL. +### 3. POS QR (`mv.com.bml.qtr`) + +BML POS terminals emit an EMVCo-style dynamic QR with no tag `26` and supplementary domain +`mv.com.bml.qtr`. The same TLV path (`35` → `20` → `01`) holds a bare reference such as +`02:215c7b9f15ce4ed28e15697ea976db99` instead of a URL. That bare reference — not the whole payload, +which is rejected with code `112` — is what gets Base64-encoded into the payrequest lookup below. +The reference does **not** resolve as a `pay.bml.com.mv/app/` short code in a browser; only the API +understands it. See +[PayMV QR Format → BML POS QR](../thijooree/18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr). + --- ## PayMV QR Format (TLV) @@ -71,19 +81,29 @@ PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV): GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url} ``` -`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding). +`{base64Url}` is the lookup key, base64-encoded with standard encoding — the full QR URL +(e.g. `https://pay.bml.com.mv/app/...`), or for POS QRs the bare `35` → `20` → `01` reference. +BML's own app omits the `=` padding; the padded form resolves as well, so the client matches the app +rather than relying on either being required. ### Headers | Header | Value | |---|---| +| `accept` | `application/json` — **required in practice**, see below | | `Authorization` | `Bearer ` | | `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` | | `x-app-version` | `2.1.44.348` | +> **`accept: application/json` is not optional.** Handled errors (codes 103, 112, …) come back as +> JSON regardless, but when the route throws, the server renders the Internet Banking HTML login +> page — under HTTP **200** — instead of a JSON error body. A client without the header then sees a +> "successful" HTML response it cannot parse. All BML API requests set it in `bmlApiRequest()`. + ```bash curl --request GET \ --url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/' \ + --header 'accept: application/json' \ --header 'Authorization: Bearer ' \ --header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \ --header 'x-app-version: 2.1.44.348' @@ -116,6 +136,16 @@ curl --request GET \ | `amount` | Payment amount (`"0.00"` for static QRS) | | `currency` | Currency code (typically `"MVR"`) | +### Failure Responses + +`success: false` comes back with a code and a user-facing message; the client shows BML's own +wording and keeps the user on the Transfer screen. + +| Code | Message | +|---|---| +| `103` | The payment request has expired | +| `112` | Unsupported payment link | + --- ## Step 2 — Pay (3-Step TOTP Flow) diff --git a/docs/thijooree/12-bml-qr-pay.md b/docs/thijooree/12-bml-qr-pay.md index 040840c..5e6d6b2 100644 --- a/docs/thijooree/12-bml-qr-pay.md +++ b/docs/thijooree/12-bml-qr-pay.md @@ -2,7 +2,9 @@ > **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md). -The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`). +The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`), and [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) for the three QR sub-modes the live path handles. + +Two differences remain in the stale fragment, should it ever be revived: it calls `lookupPayRequest()` with the scanned URL only (no `PaymvQrParser.bmlPayRequestKey()`, so POS QRs would not resolve), and it swallows every lookup error into the hardcoded `bml_qr_lookup_failed` toast instead of surfacing BML's own message. --- diff --git a/docs/thijooree/18-paymv-qr-format.md b/docs/thijooree/18-paymv-qr-format.md index dd2d4a2..7193fce 100644 --- a/docs/thijooree/18-paymv-qr-format.md +++ b/docs/thijooree/18-paymv-qr-format.md @@ -28,12 +28,13 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di | `00` | Format indicator | Always `"01"` | | `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR | | `26` | Merchant account information | Container — see sub-tags below | -| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs only | +| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs | | `52` | Merchant category code | `"0000"` (generic) | | `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) | | `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs | | `58` | Country code | `"MV"` | | `59` | Merchant / recipient name | Max 25 characters | +| `60` | Merchant city / store code | BML POS QRs only | | `62` | Additional data field | Container — see sub-tags below | | `63` | CRC | `6304` prefix + 4-char hex checksum — always last | | `80` | Supplementary data | Container — timestamp and domain | @@ -153,6 +154,67 @@ The value at sub-sub-tag `01` is a full `https://pay.bml.com.mv/app/...` URL. Ex Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly. +`PaymvQrParser.bmlQrPayTarget()` covers all of these: it returns the URL for plain URL QRs and for +combined QRs, the whole EMV payload for POS QRs (below, recognised by the `mv.com.bml.qtr` +supplementary domain), and null for everything else — PayMV QRs keep falling through to `parse()`. + +--- + +## BML POS QR (`mv.com.bml.qtr`) + +BML POS terminals emit a third shape — an EMVCo-style dynamic QR whose supplementary data domain +(tag `80` → `00`) is `mv.com.bml.qtr` and which has **no tag `26`**, so there is no PayMV account +number to transfer to. The same `35` → `20` container is used as in combined QRs, but sub-tag `01` +holds a bare reference instead of a URL. + +Example (CRC verified, same CRC-16/CCITT-FALSE as above): + +``` +00020101021235752071000202013502:215c7b9f15ce4ed28e15697ea976db9902109809724081030874009538520400005303462540436005802MV5911BEST BANANA6006LD044262220510dtyams497d0804POPE80470014mv.com.bml.qtr01252026-09-21T13:33:22.00000630443FA +``` + +| TLV path | Value | Notes | +|---|---|---| +| `00` | `01` | Format indicator | +| `01` | `12` | Dynamic QR | +| `35`→`20`→`00` | `02` | Version / format of the container (combined QRs use the URL form) | +| `35`→`20`→`01` | `02:215c7b9f15ce4ed28e15697ea976db99` | Payment reference — `:<32 hex>` | +| `35`→`20`→`02` | `9809724081` | Merchant identifier (10 digits) | +| `35`→`20`→`03` | `74009538` | Terminal identifier (8 digits) | +| `52` | `0000` | MCC | +| `53` | `462` | MVR | +| `54` | `3600` | Amount — **no decimal point**, unlike PayMV's `"1.50"` | +| `58` / `59` / `60` | `MV` / `BEST BANANA` / `LD0442` | Country, merchant name, merchant city/store code | +| `62`→`05` | `dtyams497d` | Reference / bill number | +| `62`→`08` | `POPE` | Purpose / terminal label | +| `80`→`00` | `mv.com.bml.qtr` | Domain — identifies the POS format | +| `80`→`01` | `2026-09-21T13:33:22.00000` | Timestamp | + +### Pay-Request Lookup Key + +The lookup key is the **bare reference**, Base64-encoded without padding: + +``` +GET .../walletpayments/payrequest/MDI6MjE1YzdiOWYxNWNlNGVkMjhlMTU2OTdlYTk3NmRiOTk +``` + +BML's own app omits the `=` padding, so `BmlQrPayClient.lookupPayRequest()` encodes with +`NO_WRAP or NO_PADDING` for every QR type; the padded form resolves too. What the request *must* +carry is `accept: application/json` — see +[QR Payment → Step 1 headers](../bmlapi/13-qr-payment.md#headers). + +Confirmed against the live API (the example QR had already expired, so BML answered `103` rather +than with merchant details — but `103` means the reference itself resolved): + +| Key tried | Response | +|---|---| +| `02:215c7b9f15ce4ed28e15697ea976db99` | `103` — "The payment request has expired" ✅ recognised | +| the whole EMV payload | `112` — "Unsupported payment link" ❌ | +| `https://pay.bml.com.mv/app/02:215c…` | `103` — recognised too; the host itself 400s on that path, so the backend must strip the prefix | + +`PaymvQrParser.bmlPayRequestKey()` returns the URL for URL QRs and the reference for POS QRs, so +exactly one request is made either way. + --- ## Example Payload diff --git a/docs/thijooree/20-transfer-flows.md b/docs/thijooree/20-transfer-flows.md index 7d5a525..443cd02 100644 --- a/docs/thijooree/20-transfer-flows.md +++ b/docs/thijooree/20-transfer-flows.md @@ -13,7 +13,7 @@ The transfer screen (`TransferFragment`) handles all outgoing payments across MI | `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate | | `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown | | `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan | -| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway QR merchant payment mode — locks recipient, may pre-fill amount | +| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway/POS QR merchant payment mode — locks recipient, may pre-fill amount | | `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load | --- @@ -215,22 +215,29 @@ If channel fetch fails or returns empty, the flow is aborted and the form is re- ## BML QR Merchant Payment Flow -Triggered when the transfer screen is opened via `newInstanceFromBmlQr()` or when a BML ebanking/pay.bml URL is scanned from the QR scanner. +Triggered when the transfer screen is opened via `newInstanceFromBmlQr()`, which every scanner caller reaches through `PaymvQrParser.bmlQrPayTarget(raw)` — it returns the value to pay with, or null for a QR that is not BML's. -Two sub-modes: +Three sub-modes: -| Mode | Trigger | Extra step | +| Mode | `bmlQrPayTarget()` returns | Extra step | |---|---|---| -| Static card QR | URL starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None | -| Gateway QR | URL starts with `https://pay.bml.com.mv/app/` | `BmlQrPayClient.preInitiatePayment()` required before initiate | +| Static card QR | the QR text, when it starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None | +| Gateway QR | the QR text, or the URL at TLV `35`→`20`→`01` in a combined EMV QR | `BmlQrPayClient.preInitiatePayment()` required before initiate | +| POS QR | the whole EMV payload, for QRs whose tag `80`→`00` domain is `mv.com.bml.qtr` | Treated as a gateway QR — see the note below | + +`BmlTransferHandler.lookupQrMerchant()` passes that value through `PaymvQrParser.bmlPayRequestKey()`, which hands the URL to the lookup for URL QRs and the bare `35`→`20`→`01` reference for POS QRs. See [PayMV QR Format — BML POS QR](18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr). Flow: -1. `lookupBmlQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row. +1. `lookupQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row. 2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field. 3. Remarks field is locked (not applicable for merchant payments). 4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP. 5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen. +**Lookup failure:** the user stays on the Transfer screen with the "To" row restored via `resetToFieldVisibility()` — the screen is no longer popped. When BML answered with `success: false`, its own wording is toasted (`BmlQrPayLookupException.message`, e.g. "The payment request has expired"); network, empty and non-JSON responses fall back to the `bml_qr_lookup_failed` string. + +> **Unverified:** POS QRs are treated as gateway QRs (pre-initiate before initiate) because they carry a preset amount. No POS payment has been completed end-to-end yet — the reference captured for testing had already expired. + --- ## Transfer Button Enable Conditions @@ -238,7 +245,7 @@ Flow: The transfer button is only enabled when all of the following are true: - A source account is selected -- A recipient is resolved (`resolvedAccountNumber` not blank, or `bmlQrInfo` is set) +- A recipient is resolved (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is set) - Amount is greater than `0` - No connectivity error for `NO_INTERNET` or for the source bank diff --git a/docs/thijooree/25-qr-scanner.md b/docs/thijooree/25-qr-scanner.md index 7cff1ca..c05c159 100644 --- a/docs/thijooree/25-qr-scanner.md +++ b/docs/thijooree/25-qr-scanner.md @@ -57,10 +57,10 @@ Each caller registers an `ActivityResultContracts.StartActivityForResult` launch | Caller | Result handling | |---|---| -| [TransferFragment](07-transfer.md) | Routes PayMV / BML URL via `PaymvQrParser` + `extractBmlGatewayUrl` | +| [TransferFragment](07-transfer.md) | `PaymvQrParser.bmlQrPayTarget()` first (URL, combined and POS QRs → BML QR pay), then M-Faisa numeric ids, then `PaymvQrParser.parse()` | | [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly | | `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs | -| [DashboardFragment](21-dashboard.md) | BML URL → BML QR pay; PayMV → pre-fill Transfer; otherwise toast | +| [DashboardFragment](21-dashboard.md) | BML QR (URL or POS) → BML QR pay; PayMV → pre-fill Transfer; otherwise toast | | [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card | ### Share-to-Scan Fast Path