From 449c27ced2e88c726242c686d58927adbd88fc21 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Thu, 1 Oct 2026 06:06:57 +0500 Subject: [PATCH] update docs: card payments --- docs/bmlapi/16-card-payment.md | 260 ++++++++++++++++++ docs/bmlapi/README.md | 1 + ...card-verification-and-merchant-card-pay.md | 159 +++++++++++ docs/thijooree/README.md | 1 + 4 files changed, 421 insertions(+) create mode 100644 docs/bmlapi/16-card-payment.md create mode 100644 docs/thijooree/29-card-verification-and-merchant-card-pay.md diff --git a/docs/bmlapi/16-card-payment.md b/docs/bmlapi/16-card-payment.md new file mode 100644 index 0000000..25f0c70 --- /dev/null +++ b/docs/bmlapi/16-card-payment.md @@ -0,0 +1,260 @@ +# Merchant Card Payment (no BML Pay) + +BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/`, +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-.wibmo.com` | Behind Cloudflare; `` varies (e.g. `indmum-mumrdc`, `indblr-blrtdc`) | +| Card scheme gateway | `https://ap.gateway.mastercard.com` | MPGS | + +--- + +## Detecting the merchant type + +`GET //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 ``): + +| `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 //paynow → window.appData (merchant type, pomeloJsKey) +PATCH transactions/ {activeBrowserId} ─┐ announce browser +PATCH transactions/ {fx:"reset"} ─┘ +GET public-client/credentials/ → 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 creq → OTP channel picker +POST destValue=token… → OTP entry page +POST otpValue= → auto-POST form (cres → gateway) +POST cres → auto-POST form (→ mpgsNotification) +POST transactions/mpgsNotification/ → 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/ +Content-Type: application/json + +{"activeBrowserId":"_"} +``` +``` +PATCH …/transactions/ +{"fx":"reset"} +``` + +--- + +## 2. Credentials + +``` +GET https://api.merchants.bankofmaldives.com.mv/public-client/credentials/ +Authorization: # 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: +x-tenant-id: + +{ + "encryptedCardNumber":"", + "encryptedCardSecurityCode":"", + "encryptedCardExpiry":"", + "externalId":"", + "cardHolderName":"NAME ON CARD", + "encryptedCardExpiryMonth":"07", // NOTE: sent in clear despite the name + "encryptedCardExpiryYear":"28", + "encSerialId":"" +} +``` +```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: `. + +``` +POST https://api.merchants.bankofmaldives.com.mv/public-client/transactions/next-action +Authorization: + +{ "action":"RATE_OPTIONS", "transactionId":"", + "cardBrand":"V", "bin8":"42136300", "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":"" } +``` + +> In the capture: `RATE_OPTIONS → WAIT`, then one `POLL → THREEDS` with +> `3dsUrl = …/modirum/render-tds?transactionId=`. + +--- + +## 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/`. 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=`, + `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/`**. 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) diff --git a/docs/bmlapi/README.md b/docs/bmlapi/README.md index decdb69..59ef40a 100644 --- a/docs/bmlapi/README.md +++ b/docs/bmlapi/README.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 | --- diff --git a/docs/thijooree/29-card-verification-and-merchant-card-pay.md b/docs/thijooree/29-card-verification-and-merchant-card-pay.md new file mode 100644 index 0000000..75b1e7d --- /dev/null +++ b/docs/thijooree/29-card-verification-and-merchant-card-pay.md @@ -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:` / +`mib:`), 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) diff --git a/docs/thijooree/README.md b/docs/thijooree/README.md index 5ea5200..7a16dc2 100644 --- a/docs/thijooree/README.md +++ b/docs/thijooree/README.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