Files
android/docs/thijooree/29-card-verification-and-merchant-card-pay.md
T

8.2 KiB
Raw Blame History

Card Verification & Merchant Card Payment

Two linked features:

  1. Card verification — on the Cards 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, 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). The same flow pays carrier services by BML card (Dhiraagu Reload and Bill Pay, Ooredoo Raastas and Bill Pay), once the carrier has created the transaction.

⚠️ 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 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 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:<accountNumber> / mib:<cardId>), 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.

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 (BmlVerifiedCards.payable / isPayable, ui/home/transfer/BmlVerifiedCards.kt) — the 3-D Secure step needs that seed.

Send

submitCardPayment → confirmCardMerchant shows the shared transfer confirm dialog (biometric-gated), then executeCardMerchant runs, off the main thread. CardPayoutTransferHandler calls confirmCardMerchant directly with the page of the transaction a carrier created:

BmlMerchantCardPayClient().pay(page, card) { Totp.generate(otpSeed) }

where card and otpSeed come from BmlVerifiedCards.load — the VerifiedCardStore entry (expiry split MM/YY → month/year) and 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. Once BML confirms, it loads <id>?wait=1 and follows the redirects to the merchant, which is how the merchant learns it was paid (Dhiraagu doesn't top up without it). Outcome is shown in the shared processing/success dialog; failures surface as a toast. If the merchant couldn't be reached, success also toasts the merchant name and BML transaction id (bml_card_pay_merchant_not_notified).

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/transfer/BmlVerifiedCards.kt Which cards can pay by card; loads their details
ui/home/transfer/CardPayoutTransferHandler.kt Carrier services by card: carrier creates the transaction, then confirmCardMerchant
ui/home/TransferFragment.kt Transaction-ID lookup + routing

 


Related: Cards · Transfer Flows · API side: Merchant Card Payment

← Settings — About