update docs: card payments
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
# Merchant Card Payment (no BML Pay)
|
||||
|
||||
BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`,
|
||||
e.g. the bill links Fenaka and Fahipay send) are paid one of two ways depending on what the
|
||||
merchant has enabled:
|
||||
|
||||
| Merchant capability | How it is paid | Doc |
|
||||
|---|---|---|
|
||||
| **BML Pay** (`bml_mpos`) enabled | Fetch the merchant's QR text, pay it via the normal QR flow | [QR Payment](13-qr-payment.md) |
|
||||
| **Card only** (no BML Pay) | Enter card details → Pomelo tokenise → MPGS + 3-D Secure | **this doc** |
|
||||
|
||||
The payment page is a React app (Pomelo Pay, white-labelled as "Bank of Maldives Merchant
|
||||
Services"). The card flow here replays the exact requests that page and the issuer's 3-D Secure
|
||||
challenge make in a browser. Reconstructed from `docs/bmlapi/tmp/bmlpaywithid-verifiedcard.har`.
|
||||
|
||||
> ⚠️ This flow is **scraped browser/ACS traffic**, not a stable API. See
|
||||
> [Fragility](#fragility--what-can-break) before relying on it.
|
||||
|
||||
---
|
||||
|
||||
## Hosts
|
||||
|
||||
| Purpose | Base URL | Notes |
|
||||
|---|---|---|
|
||||
| Payment page (`/paynow`) | `https://transaction.merchants.bankofmaldives.com.mv` | Behind Cloudflare — **browser User-Agent required** |
|
||||
| Merchant API | `https://api.merchants.bankofmaldives.com.mv` | Tolerates non-browser UA |
|
||||
| Card tokenisation (Pomelo CDE) | `https://api.pay.pomelopay.com` | `bin-lookup` |
|
||||
| 3-D Secure ACS (Wibmo) | `https://secure-acs2ui-bk2-<dc>.wibmo.com` | Behind Cloudflare; `<dc>` varies (e.g. `indmum-mumrdc`, `indblr-blrtdc`) |
|
||||
| Card scheme gateway | `https://ap.gateway.mastercard.com` | MPGS |
|
||||
|
||||
---
|
||||
|
||||
## Detecting the merchant type
|
||||
|
||||
`GET /<id>/paynow` returns server-rendered HTML with everything inline in a
|
||||
`window.appData = {…}` script. Parse that JSON (the code reads between `window.appData = ` and the
|
||||
next `</script>`):
|
||||
|
||||
| `window.appData` field | Meaning |
|
||||
|---|---|
|
||||
| `transaction.state` | `QR_CODE_GENERATED` normally; `CONFIRMED` if already paid |
|
||||
| `transaction.payAmount` / `transaction.amount` | Amount in **cents** (payAmount preferred; falls back to amount) |
|
||||
| `transaction.payCurrency` / `transaction.currency` | e.g. `MVR` |
|
||||
| `merchant.tradingName` / `registeredName` | Display name |
|
||||
| `availableProviders[]` | Contains `{value:"bml_mpos", enabled:true}` **iff BML Pay is enabled** |
|
||||
| `pomeloJsProviders[]` | Contains `"mpgs"` when card entry is offered |
|
||||
| `pomeloJsKey` | `pk_production_…` — the card form's auth token (a JWT carrying the merchant id) |
|
||||
|
||||
**Decision:** `supportsBmlPay = availableProviders` contains an enabled `bml_mpos`;
|
||||
`supportsCard = pomeloJsKey present && pomeloJsProviders` contains `mpgs`.
|
||||
Route to the card flow only when **`!supportsBmlPay && supportsCard`**.
|
||||
|
||||
> The `/paynow` host is fronted by Cloudflare and returns **403** to the `okhttp/*` User-Agent.
|
||||
> Send a browser UA (`BML_WEB_USER_AGENT`) + `Accept: text/html…`. The `api.merchants…` host is
|
||||
> not UA-gated, which is why the PATCHes below work with the default client.
|
||||
|
||||
---
|
||||
|
||||
## Flow overview
|
||||
|
||||
```
|
||||
GET /<id>/paynow → window.appData (merchant type, pomeloJsKey)
|
||||
PATCH transactions/<id> {activeBrowserId} ─┐ announce browser
|
||||
PATCH transactions/<id> {fx:"reset"} ─┘
|
||||
GET public-client/credentials/<id> → RSA public key + Pomelo apiKey
|
||||
POST api.pay.pomelopay.com/bin-lookup → tokenId (card encrypted here)
|
||||
POST public-client/transactions/next-action RATE_OPTIONS → WAIT
|
||||
POST …next-action POLL (every 5s) → THREEDS + 3dsUrl
|
||||
GET <3dsUrl> (modirum/render-tds) → auto-POST form (creq → ACS)
|
||||
POST <ACS creq url> creq → OTP channel picker
|
||||
POST <ACS creq url> destValue=token… → OTP entry page
|
||||
POST <ACS creq url> otpValue=<token TOTP> → auto-POST form (cres → gateway)
|
||||
POST <gateway callback> cres → auto-POST form (→ mpgsNotification)
|
||||
POST transactions/mpgsNotification/<id> → records the verdict
|
||||
POST …next-action POLL → TRANSACTION_CONFIRMED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Announce browser
|
||||
|
||||
Two unauthenticated PATCHes the page sends on load (needed by `fx`/state bookkeeping). `Origin` /
|
||||
`Referer` are the transaction host.
|
||||
|
||||
```
|
||||
PATCH https://api.merchants.bankofmaldives.com.mv/transactions/<id>
|
||||
Content-Type: application/json
|
||||
|
||||
{"activeBrowserId":"<id>_<epoch-millis>"}
|
||||
```
|
||||
```
|
||||
PATCH …/transactions/<id>
|
||||
{"fx":"reset"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Credentials
|
||||
|
||||
```
|
||||
GET https://api.merchants.bankofmaldives.com.mv/public-client/credentials/<id>
|
||||
Authorization: <pomeloJsKey> # the pk_production_… from the page
|
||||
```
|
||||
```json
|
||||
{
|
||||
"publicKey": {
|
||||
"publicKeyId": "3edf1db0-…",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN…\n-----END PUBLIC KEY-----"
|
||||
},
|
||||
"apiKey": "UU8a9m4Q…",
|
||||
"binLookupUrl": "https://api.pay.pomelopay.com/bin-lookup"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Tokenise the card (`bin-lookup`)
|
||||
|
||||
The card number, CVV and expiry are **RSA-OAEP(SHA-1)** encrypted with `publicKeyPem`, Base64
|
||||
(no-wrap) encoded. The Pomelo JS uses WebCrypto `{name:"RSA-OAEP", hash:"SHA-1"}` over the plain
|
||||
strings — the Java equivalent is `RSA/ECB/OAEPPadding` with
|
||||
`OAEPParameterSpec("SHA-1","MGF1",MGF1ParameterSpec.SHA1,PSpecified.DEFAULT)`.
|
||||
|
||||
| Plaintext encrypted | Field |
|
||||
|---|---|
|
||||
| PAN (digits only) | `encryptedCardNumber` |
|
||||
| CVV | `encryptedCardSecurityCode` |
|
||||
| `YYMM` (year then month) | `encryptedCardExpiry` |
|
||||
|
||||
```
|
||||
POST https://api.pay.pomelopay.com/bin-lookup
|
||||
Content-Type: application/json
|
||||
tenant: bankofmaldives
|
||||
x-api-key: <apiKey>
|
||||
x-tenant-id:
|
||||
|
||||
{
|
||||
"encryptedCardNumber":"<b64>",
|
||||
"encryptedCardSecurityCode":"<b64>",
|
||||
"encryptedCardExpiry":"<b64>",
|
||||
"externalId":"<id>",
|
||||
"cardHolderName":"NAME ON CARD",
|
||||
"encryptedCardExpiryMonth":"07", // NOTE: sent in clear despite the name
|
||||
"encryptedCardExpiryYear":"28",
|
||||
"encSerialId":"<publicKeyId>"
|
||||
}
|
||||
```
|
||||
```json
|
||||
{ "tokenId":"24d5be26…", "bin8":"42136300", "brand":"V" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Rate options → 3-D Secure URL
|
||||
|
||||
All `next-action` calls POST to the merchant API with `Authorization: <pomeloJsKey>`.
|
||||
|
||||
```
|
||||
POST https://api.merchants.bankofmaldives.com.mv/public-client/transactions/next-action
|
||||
Authorization: <pomeloJsKey>
|
||||
|
||||
{ "action":"RATE_OPTIONS", "transactionId":"<id>",
|
||||
"cardBrand":"V", "bin8":"42136300", "tokenId":"<tokenId>",
|
||||
"javaEnabled":false, "javascriptEnabled":true, "language":"en-US",
|
||||
"colorDepth":24, "screenHeight":1850, "screenWidth":1080, "tz":-300,
|
||||
"userAgent":"Mozilla/5.0 (Android …; Mobile)" }
|
||||
```
|
||||
|
||||
Response `action` values:
|
||||
|
||||
| `action` | Meaning | Do |
|
||||
|---|---|---|
|
||||
| `WAIT` | Processing | Poll (below) |
|
||||
| `POLL` | Keep polling | Poll |
|
||||
| `THREEDS` + `3dsUrl` | Challenge required | Run [§5](#5-3-d-secure-challenge) |
|
||||
| `TRANSACTION_CONFIRMED` | Paid (frictionless) | Done |
|
||||
| `TRANSACTION_FAILED` | Declined | Fail |
|
||||
|
||||
Poll body (every **5 s**, no browser-info):
|
||||
|
||||
```
|
||||
POST …/next-action { "action":"POLL", "transactionId":"<id>" }
|
||||
```
|
||||
|
||||
> In the capture: `RATE_OPTIONS → WAIT`, then one `POLL → THREEDS` with
|
||||
> `3dsUrl = …/modirum/render-tds?transactionId=<id>`.
|
||||
|
||||
---
|
||||
|
||||
## 5. 3-D Secure challenge (Wibmo ACS)
|
||||
|
||||
A chain of auto-submitting HTML forms. **Only the `render-tds` form and the final gateway /
|
||||
notification forms carry an `action` attribute** — the ACS's channel-picker and OTP forms have
|
||||
no `action`; their JavaScript posts back to the **same creq URL**. So the creq URL (the
|
||||
`render-tds` form's action) is the fallback action for every subsequent form.
|
||||
|
||||
1. **`GET <3dsUrl>`** (`render-tds`) → a form posting `creq` to
|
||||
`https://secure-acs…wibmo.com/v1/acs/services/browser/creq/L/8573/<acsTransId>`. Capture that
|
||||
URL as the ACS creq URL.
|
||||
2. **POST creq** → the **channel picker**: radios `destValue ∈ {mobile, email, token}`, plus hidden
|
||||
`creq`, `authMethod`, `otpDest`, `selectChannel`, `otpChannels`, `formReqType`. The BML token /
|
||||
authenticator is the **`token`** channel. Submit:
|
||||
`destValue=token`, `selectChannel=token`, `authMethod=OOB`, `otpDest=`, `formReqType=SUBMIT`
|
||||
(keep the hidden `creq` / `otpChannels`).
|
||||
3. **POST channel** → the **OTP entry** page (`otpValue` input). Submit `otpValue=<BML token TOTP>`,
|
||||
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page with text containing
|
||||
*"incorrect"* / *"expired"* — regenerate the TOTP and retry once.
|
||||
4. On success the ACS returns a form auto-posting **`cres`** to the Mastercard gateway; the gateway
|
||||
returns a form auto-posting the result (`order.id`, `result=SUCCESS`, …) to
|
||||
**`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded.
|
||||
|
||||
Cookies (`__cf_bm`, `_cfuvid`) are set by the ACS and must be carried across these POSTs — the
|
||||
Cloudflare-fronted ACS also requires a browser User-Agent.
|
||||
|
||||
---
|
||||
|
||||
## 6. Confirm
|
||||
|
||||
Poll `next-action` until the recorded verdict surfaces:
|
||||
|
||||
| `action` | Result |
|
||||
|---|---|
|
||||
| `TRANSACTION_CONFIRMED` | Success |
|
||||
| `TRANSACTION_FAILED` | Declined |
|
||||
|
||||
The merchant's own backend is also notified out-of-band (e.g.
|
||||
`fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`).
|
||||
|
||||
---
|
||||
|
||||
## Fragility — what can break
|
||||
|
||||
This is scraped glue across BML, Pomelo, Wibmo and MPGS. No versioned contract, no sandbox; you
|
||||
learn of breakage from a failed live payment.
|
||||
|
||||
| Area | Breaks when | Symptom |
|
||||
|---|---|---|
|
||||
| **ACS HTML scraping** (most fragile) | Wibmo changes field names (`destValue`/`otpValue`/`creq`), the `"token"` channel label, the error wording, or the form layout | "Unexpected authentication page" / wrong-OTP loop |
|
||||
| **Cloudflare** | `/paynow` or `wibmo.com` adds a JS/managed challenge or TLS-fingerprint check | 403; **not fixable by UA alone** |
|
||||
| **TOTP seed assumption** | The card's 3-D Secure "authenticator" is not the same soft-token TOTP as the BML login; or the card only offers SMS/email OTP | Wrong code submitted; auth fails |
|
||||
| **Pomelo crypto/contract** | OAEP hash change (SHA-1→256), added nonce/timestamp, renamed fields, moved endpoint | `bin-lookup` rejects the card |
|
||||
| **`next-action` states** | New/renamed actions, or browser-info becomes validated | Poll never resolves |
|
||||
| **Merchant detection** | BML adds other card providers (UnionPay, Apple/Google Pay); non-`mpgs` card provider | Misroute to the wrong flow |
|
||||
| **`window.appData` parsing** | Key moved/obfuscated or made dynamically signed | No `pomeloJsKey` |
|
||||
| **Double-charge** | Confirm poll times out but the charge went through | Retry risks paying twice |
|
||||
|
||||
**Maintenance:** re-capture a HAR whenever any party updates; expect to touch the ACS form parser
|
||||
most often; the flow is effectively untestable in CI (no deterministic 3-D Secure double). Keep the
|
||||
gitignored HARs under `docs/bmlapi/tmp/` as reference fixtures to diff against.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Related:** [QR Payment](13-qr-payment.md) · App side:
|
||||
[Card Verification & Merchant Card Pay](../thijooree/29-card-verification-and-merchant-card-pay.md)
|
||||
|
||||
[← Card Freeze](15-card-freeze.md)
|
||||
Reference in New Issue
Block a user