forked from thijooree/android
286 lines
12 KiB
Markdown
286 lines
12 KiB
Markdown
# 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
|
|
GET transaction…/<id>?wait=1 → 302 merchant redirectUrl (?…&state=CONFIRMED&signature=…)
|
|
→ 302 merchant receipt page
|
|
```
|
|
|
|
---
|
|
|
|
## 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 |
|
|
|
|
## 7. Return to the merchant
|
|
|
|
The `mpgsNotification` response is a page whose script sends the browser to
|
|
`https://transaction.merchants.bankofmaldives.com.mv/<id>?wait=1`. Once the transaction is
|
|
confirmed, that 302s to the merchant's `redirectUrl` with a BML-signed result, then on to the
|
|
merchant's own receipt page:
|
|
|
|
```
|
|
GET transaction…/<id>?wait=1
|
|
→ 302 https://www.dhiraagu.com.mv/api/dhiraagu-bml-response.aspx?transactionId=<id>&state=CONFIRMED&signature=<sha1>
|
|
→ 302 https://www.dhiraagu.com.mv/services/reload-receipt?pyid=<paymentId>
|
|
```
|
|
|
|
(FahiPay's is `fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`.)
|
|
|
|
**This hop is required.** It's how at least Dhiraagu learns it was paid: a test reload that
|
|
stopped at `TRANSACTION_CONFIRMED` charged the card but never topped up, and opening the
|
|
`?wait=1` URL in a browser afterwards delivered it. The signature is generated by BML, so the
|
|
hop can be replayed later from the transaction id alone.
|
|
|
|
`BmlMerchantCardPayClient` does it after every confirmed payment (`returnToMerchant`): a browser
|
|
UA GET that follows the redirects, up to 3 tries, success = the chain ends on a 2xx page. The
|
|
merchant host may be behind Cloudflare: plain `curl` got a 403 on `dhiraagu.com.mv`, okhttp got
|
|
through.
|
|
|
|
---
|
|
|
|
## 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 |
|
|
| **Return to merchant** | The merchant's `redirectUrl` host blocks the client (Cloudflare) or is down | Charged but not delivered — `Success(merchantNotified = false)`, the app toasts the BML transaction id; opening `…/<id>?wait=1` in a browser delivers it |
|
|
|
|
**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)
|