10 KiB
Transfer Flows
The transfer screen (TransferFragment) handles all outgoing payments across MIB, BML, and Fahipay. This document covers how the UI routes transfers, how recipients are looked up, which combinations are allowed, and which are rejected.
Entry Points
TransferFragment can be launched in several modes depending on context:
| Factory method | Behaviour |
|---|---|
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 |
newInstanceWithAutoScan() |
Opens the QR scanner immediately on load |
Account Input Detection
The raw "To" field input is normalised first (spaces stripped, +960/960 country prefix removed if the result is 7 digits), then classified:
| Pattern | Type |
|---|---|
Starts with 9, exactly 17 digits |
MIB_ACCOUNT |
Starts with 7, exactly 13 digits |
BML_ACCOUNT |
Starts with 7 or 9, exactly 7 digits |
PHONE |
Starts with A followed by 6 digits |
NATIONAL_ID |
Contains @ |
EMAIL |
| Anything else | UNKNOWN |
Recipient Lookup
Lookup behaviour depends on the source account's bank.
Fahipay source
Only PHONE input is accepted. Any other type is rejected immediately with an error on the "To" field.
Phone lookup hits both Dhiraagu and Ooredoo in parallel (order depends on the first digit):
- Numbers starting with
7: Dhiraagu first, Ooredoo fallback - Numbers starting with
9: Ooredoo first, Dhiraagu fallback
The result maps to one or more Fahipay services:
| Carrier result | Service shown |
|---|---|
Dhiraagu RELOAD |
Dhiraagu Reload |
Dhiraagu BILL_PAY |
Dhiraagu Bill Pay |
Ooredoo PRE or HYBRID |
Raastas (prepaid top-up) |
Ooredoo POST or HYBRID |
Ooredoo Bill Pay |
If exactly one service matches, it is auto-selected. If multiple match (Ooredoo HYBRID gives two), a chip group is shown for the user to choose.
BML source
- If the input type is
MIB_ACCOUNT, callsBmlValidateClient.verifyMibAccount(). - Otherwise calls
BmlValidateClient.validateAccount(). - If either BML call fails and a MIB session is available, falls back to
MibTransferClient.lookup(). - If both fail, shows the error from the MIB lookup (or a generic "account not found").
There is also a short-circuit: if the input matches a saved contact whose transferCyDesc is not MVR, the contact is used directly without a network lookup.
MIB source
Calls MibTransferClient.lookup() directly. Errors from MibLookupException are shown verbatim to the user.
BML-only session (no MIB session)
Falls back to BmlValidateClient.validateAccount() only.
Destination currency resolution
resolvedDestCurrency (TransferFragment.kt:89) holds the currency reported by the lookup, defaulting to "" until verified. When BML returns nothing useful but a MIB session exists, the MIB lookup is used as a fallback to verify the destination currency — this is what allows the BML USD → MIB rejection dialog to know whether it can pre-fill the recipient name and currency into the auto-add contact sheet.
Transfer Type Routing
Once the source and destination are resolved, the transfer type is determined as follows. This applies for both BML personal (doBmlTransfer) and BML business (startBmlBusinessOtpFlow) — the routing logic is identical.
Source: BML
├── isSrcCard (BML_PREPAID / BML_CREDIT / BML_DEBIT)
│ └── type = CAD creditAccount = dest BML CASA internalId (or dest account number)
│
├── isDestMyCard (destination is user's own BML card)
│ └── type = CPA creditAccount = card internalId
│
├── isDestMib && currency == MVR
│ └── type = DOT creditAccount = MIB account number bank = "MIB"
│
├── isDestMib && currency == USD
│ └── Requires a saved BML contact for that MIB account (see Rejections)
│ type = DOT creditAccount = contact.benefNo (numeric) bank = null
│
└── everything else (BML → BML CASA, BML → other local bank)
└── type = IAT creditAccount = dest account number
Source: MIB
├── isDestMib (17-digit 9… account)
│ └── bankNo = 2 endpoint = transferInternal
│
└── everything else (BML or other local bank)
└── bankNo = 3 endpoint = transferLocal
Source: Fahipay
└── Routed to the selected service:
FAHIPAY_TRANSFER, RAASTAS, OOREDOO_BILL, DHIRAAGU_RELOAD, DHIRAAGU_BILL
Rejected Combinations
These combinations are blocked before a transfer is attempted.
BML USD → MIB (no saved contact)
Condition: source is BML, currency is USD, destination is a MIB account, and no BML contact exists for that account number (TransferFragment.kt:1136-1164).
Result: "Contact required" dialog with a Save button that opens AddContactSheetFragment.newInstance(bmlProfileId, accountNumber, recipientName, currency) pre-filled. If resolvedDestCurrency was verified via the MIB fallback lookup the recipient name and currency are passed through; otherwise the user fills them in manually. After saving the contact the user can retry the transfer.
The dialog body text is switched based on whether the destination currency was verified (R.string.transfer_bml_contact_required_msg_bml_limit vs. R.string.transfer_bml_contact_required_msg).
BML QR payment — non-card source
Condition: in BML QR merchant payment mode and the user selects a non-card account (i.e. not BML_PREPAID, BML_CREDIT, or BML_DEBIT) from the "From" dropdown.
Result: selection is rejected with a toast: "Unsupported for BML QR — select a card". The dropdown resets.
Fahipay — non-phone destination
Condition: source is Fahipay and the input type is anything other than PHONE.
Result: inline error on the "To" field: "Only phone numbers are supported for Fahipay transfers."
No source account selected
Condition: user taps the lookup button or the transfer button without selecting a "From" account.
Result: toast: "Please select a source account first."
Inactive BML card as source
Condition: a BML card (BML_PREPAID, BML_CREDIT, BML_DEBIT) with statusDesc != "Active" appears in the dropdown but is not selectable — getAccount() returns null for it and isEnabled() returns false.
Result: the row is shown at 40% opacity and cannot be tapped.
Missing internalId
Condition: a BML source account has a blank internalId (needed as the debitAccount in BML API calls).
Result: transfer is aborted with a toast: "Missing internal account ID — please refresh your accounts."
Warnings (allowed but flagged)
These combinations proceed after user confirmation but show a prominent red warning in the confirm dialog.
USD source → MVR destination
"You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
Condition: src.currencyName == "USD" and the resolved destination account's currency is MVR.
BML credit card as source
"Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
Condition: src.profileType == "BML_CREDIT".
BML Business Profile OTP Flow
Business profiles use a manual OTP delivered via email or SMS rather than a TOTP seed. The flow replaces the standard single-step confirm:
- Initiate —
startBmlBusinessOtpFlow()callsBmlAccountClient.fetchTransferChannels()to list available channels (email, SMS). - Channel selection — a channel picker is shown inline. Transfer fields are locked (dimmed, disabled).
- Initiate with channel —
BmlTransferClient.initiateTransfer()is called with the chosen channel, which triggers the OTP dispatch. - OTP entry — an OTP input field appears. The transfer button label changes to "Verify Payment".
- Confirm —
BmlTransferClient.confirmTransfer()is called with the entered OTP (not a generated TOTP).
If channel fetch fails or returns empty, the flow is aborted and the form is re-enabled.
Profile detection: isBusinessProfile() checks bmlProfilesMap[loginId] for a profile entry matching src.profileId with profileType == "business".
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.
Two sub-modes:
| Mode | Trigger | 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 |
Flow:
lookupBmlQrMerchant()— fetches merchant info viaBmlQrPayClient.lookupPayRequest(). Locks the "To" row.- For dynamic QRs (
info.amount > 0), pre-fills the amount and locks the amount field. - Remarks field is locked (not applicable for merchant payments).
- On confirm: TOTP is generated, then
initiatePayment()→ (for gateway QR:preInitiatePayment()first) →confirmPayment()with a fresh TOTP. - On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen.
Transfer Button Enable Conditions
The transfer button is only enabled when all of the following are true:
- A source account is selected
- A recipient is resolved (
resolvedAccountNumbernot blank, orbmlQrInfois set) - Amount is greater than
0 - No connectivity error for
NO_INTERNETor for the source bank