23 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, contactCategory?) |
Pre-fills the "To" card from a contact, recents pick, or About → Donate. A Fahipay favourite's contactCategory opens it as that payout service instead (see Saved Fahipay favourites) |
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/POS 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, or on there being no source yet.
Transfer Type picker
When a lookup offers exactly one transfer type, it is picked automatically and no popup is shown. When it offers more than one, a "Transfer type for " popup opens straight away. The options are laid out as a grid of tiles, up to three per row (item_transfer_type.xml). Each tile shows the type's icon, its label and a subtitle (the recipient name, plus "via Fahipay" for Fahipay services). Fahipay services also show a small Fahipay logo badge on the icon's bottom corner (TransferType.badgeRes). Nothing is preselected, and Send stays disabled until the user picks one.
The popup can't be dismissed by tapping outside it or pressing back until a type has been picked. Cancel drops the options and brings the "To" field back for editing. Once a type is picked and the lookup offered more than one, tapping the recipient card reopens the popup to change it. Cancel then keeps the current pick.
Each option is a TransferType (ui/home/transfer/TransferType.kt):
| Type | Label | Icon | Pays from |
|---|---|---|---|
Favara(info) |
Favara Transfer | favara_logo |
MIB or BML account |
Fahipay(service, ownerName) |
the service's label, e.g. Raastas | FahipayService.iconRes: ooredoo_logo / dhiraagu_logo |
Fahipay wallet |
Card(service, ownerName, cards) |
the service's label, e.g. Dhiraagu Reload | CardPayoutService.iconRes, with a BML badge |
One of cards: verified BML cards that can pay by card (see Carrier services by BML card) |
Picking an option also picks the source (TransferFragment.applyTransferType). If the selected source can't pay that type, Thijooree switches it:
- Favara: the default account, if it's MIB or BML. Otherwise the source is cleared and the user is asked to pick one.
- Fahipay: the user's Fahipay wallet.
- Card: the default card, if it's one of the type's cards. Otherwise the first of them.
Then the recipient card is filled in. The options, the number they were looked up for and the pick are kept in TransferDraft. If the view is recreated while the popup is still unanswered, it opens again.
The options are dropped when the "To" number is edited, the recipient is cleared, a new lookup starts or the form is cleared. If the user changes the source by hand to one that can't pay the picked type, the pick and the recipient card are dropped and the popup opens again for the same number. If that type was the only option, the recipient is cleared instead, so the user can search again with the new source.
No source selected, phone number
Two lookups run in parallel:
- Favara / IPS lookup. Uses any logged-in MIB or BML session. The default account's bank goes first, the other is the fallback.
- Carrier lookup (
CarrierLookup.query, see Fahipay source below). Only runs when the user has a Fahipay wallet or a BML card that can pay by card. One lookup feeds both.
Everything that resolves is offered as a transfer type. Favara comes first, then the Fahipay services, then the card services.
The same lookup runs when the source is already a BML card that can pay by card and a phone number is searched. If nothing resolves, the Favara lookup's error is shown as a toast.
Any other input with no source selected falls back to the default account as the source, and then the normal lookup for that bank runs.
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 |
The matching services are offered in the Transfer Type picker (see above).
Saved Fahipay favourites
Each Fahipay favourites list is one payout service (FahipayService.contactCategory / fromContactCategory):
| Contact category | Service |
|---|---|
FAHIPAY_RAASTAS |
Raastas |
FAHIPAY_RELOAD |
Dhiraagu Reload |
FAHIPAY_OOREDOO_BILL |
Ooredoo Bill Pay |
FAHIPAY_DHIRAAGU_BILL |
Dhiraagu Bill Pay |
So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (TransferFragment.applyFahipayContact). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked:
- the contact picker sheet (the row's category goes back as
ContactPickerSheetFragment.KEY_CATEGORY) - the "To" field's search-as-you-type dropdown
- the Contacts page: the row's transfer button and the contact details sheet's Transfer action. Fahipay favourites have
canTransferset when their category maps to a service.
Recents work the same way. Paying a number as a Fahipay service saves the recent with that service's category (RecentPick.contactCategory). The picker passes it back like a favourite's, so picking the recent pays with the same service. Recents saved before this was added have no category and are still filled in directly.
Amount rules
Each service has its own limits on the amount (FahipayService.minAmount / maxAmount / decimalsAllowed):
| Service | Min (MVR) | Max (MVR) | Decimals |
|---|---|---|---|
| Raastas | 11 | no limit | no |
| Ooredoo Bill Pay | 10 | 50,000 | yes, up to 2 places |
| Dhiraagu Reload | 8 | 1,000 | no |
| Dhiraagu Bill Pay | 10 | 5,000 | no |
The amount is checked as the user types (FahipayTransferHandler.amountProblem). An amount that breaks a rule shows an error on the amount field ("Minimum is MVR 11", "Maximum is MVR 5,000", "Whole amounts only, no decimals", "Up to 2 decimal places"), and Send stays disabled. A trailing .00 counts as a whole number. Services that don't take decimals switch the amount field to a number-only keypad.
GST (Raastas)
Raastas charges 8% GST out of the amount paid (FahipayService.gstPercent), so the number credited is less than the amount deducted from the wallet. The amount is GST-inclusive, so the credit is amount / 1.08, rounded down to 2 decimal places. The amount field's helper text says so:
- Empty field: "8% GST is deducted from this amount"
- With an amount: "Recipient receives MVR 92.59 after 8% GST" (for MVR 100)
When the amount breaks a rule, the error replaces the helper text.
Sending
initiateTransfer hands a Fahipay source to FahipayTransferHandler.submit(). The flow:
- Confirm dialog. From is the wallet. To is the recipient name, the number and the service's
destinationLabel(e.g. "Ooredoo · Raastas"). For Raastas, the GST line ("Recipient receives MVR X after 8% GST") is shown as a warning. - Biometric gate, as for every transfer.
- Payment.
FahipayPaymentClient.pay()POSTs to the service'spaymentPath(see Fahipay Payments). The amount is sent without trailing zeros (11,10.1). - Result. On success, the result shows inside the dialog (no receipt page yet), then OK clears the form and refreshes balances. A refusal closes the dialog and toasts the server's
msg. A network failure shows the no-internet message.
Reference
None of the Fahipay services take a reference. Picking one clears the Reference field and disables it, the same way BML merchant QR payments do. Clearing the service turns the field back on.
Carrier services by BML card
A carrier service can also be paid with a verified BML card, through the carrier's own website
and its BML merchant gateway, instead of the Fahipay wallet: Dhiraagu Reload, Dhiraagu
Bill Pay, Ooredoo Raastas and Ooredoo Bill Pay (CardPayoutService, ui/home/transfer/CardPayoutTransferHandler.kt).
Which cards. A card qualifies when it's verified and its BML login has an OTP seed, the same
rule as card-only merchant links (BmlVerifiedCards, see
Card Verification & Merchant Card Pay). The
type remembers those cards (TransferType.Card.cards). Picking a card that isn't one of them
drops the pick, like any other source that can't pay the picked type.
Which services.
| Carrier result | Service |
|---|---|
Dhiraagu RELOAD |
Dhiraagu Reload |
Dhiraagu BILL_PAY |
Dhiraagu Bill Pay |
Ooredoo PRE or HYBRID |
Raastas |
Ooredoo POST or HYBRID |
Ooredoo Bill Pay |
Amount rules. The carrier website's, not Fahipay's. They're checked the same way, through
the shared PayoutAmountField:
| Service | Min (MVR) | Max (MVR) | Decimals | GST |
|---|---|---|---|---|
| Dhiraagu Reload | 20 | 1,000 | no | 8%, included (credit = amount − round2(amount × 0.08 / 1.08)) |
| Dhiraagu Bill Pay | 1 | none | up to 2 places | none |
| Raastas | 20 | none | no | 8%, added (charged = amount + round2(amount × 0.08)) |
| Ooredoo Bill Pay | 10 | none | up to 2 places | none |
Easy Pay itself sets no minimum or maximum; the MVR 1 floor is Thijooree's. The Ooredoo maximums aren't known.
Raastas by card is the one service where GST is added on top (PayoutService.gstAdded): the
number is credited what's typed, the card pays more, and the note under the amount says what's
paid ("You pay MVR 21.60 with 8% GST"). The order check in step 2 below compares against
chargedWithGst.
Reference. None. The field is cleared and disabled, as for the Fahipay services.
Recents. As with Fahipay services, the recent is saved with the service's category
(CardPayoutService.contactCategory: CARD_DHIRAAGU_RELOAD, CARD_DHIRAAGU_BILL,
CARD_RAASTAS, CARD_OOREDOO_BILL). Picking it again applies that service with no lookup, so
the default card (or another payable card) is selected rather than the default account
(TransferFragment.applyServiceContact). With no payable card left, it toasts and stops. A number
keeps one recent, so paying it another way replaces the category.
Sending. The only part that differs from paying a card-only BML merchant link is where the BML transaction comes from:
CardPayoutTransferHandler.submit()has the carrier create it for the number and amount (DhiraaguPaymentClient.createReloadTransaction/createBillPayTransaction,OoredooPaymentClient.createRaastasTransaction/createBillPayTransaction, see Dhiraagu API → Reload, → Bill Pay and Ooredoo API → Raastas, → Bill Pay). Bill pay looks the number up again first, for the billing account the order is made out to. That takes a few round trips, so the payment's "Processing..." box shows meanwhile (TransferFragment.showProcessingDialog) and closes before the confirm dialog opens.- Its payment page is loaded (
BmlMerchantTxnClient.fetchPayPage). If it doesn't take cards, or its amount isn't the one typed, the payment stops with a toast. - The page goes to
BmlTransferHandler.confirmCardMerchant, so from here it's the merchant-link card flow: the same confirm dialog and warning, biometric gate, Pomelo + 3-D Secure payment, and success / failure handling.
Nothing is charged before the confirm dialog. A cancelled confirm leaves an unpaid Dhiraagu order, which expires on its own.
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
Transfer type: Card (verified BML card)
└── Carrier creates a BML merchant transaction → card-only merchant flow
DHIRAAGU_RELOAD, DHIRAAGU_BILL, OOREDOO_RAASTAS, OOREDOO_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 without selecting a "From" account, the input isn't a phone number, and there is no default account. For a phone number, the same toast appears when there's no MIB/BML session and no Fahipay wallet.
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(), which every scanner caller reaches through PaymvQrParser.bmlQrPayTarget(raw) — it returns the value to pay with, or null for a QR that is not BML's.
Three sub-modes:
| Mode | bmlQrPayTarget() returns |
Extra step |
|---|---|---|
| Static card QR | the QR text, when it starts with https://ebanking.bankofmaldives.com.mv/qrpay/ |
None |
| Gateway QR | the QR text, or the URL at TLV 35→20→01 in a combined EMV QR |
BmlQrPayClient.preInitiatePayment() required before initiate |
| POS QR | the whole EMV payload, for QRs whose tag 80→00 domain is mv.com.bml.qtr |
Treated as a gateway QR — see the note below |
BmlTransferHandler.lookupQrMerchant() passes that value through PaymvQrParser.bmlPayRequestKey(), which hands the URL to the lookup for URL QRs and the bare 35→20→01 reference for POS QRs. See PayMV QR Format — BML POS QR.
Flow:
lookupQrMerchant()— 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.
Lookup failure: the user stays on the Transfer screen with the "To" row restored via resetToFieldVisibility() — the screen is no longer popped. When BML answered with success: false, its own wording is toasted (BmlQrPayLookupException.message, e.g. "The payment request has expired"); network, empty and non-JSON responses fall back to the bml_qr_lookup_failed string.
Unverified: POS QRs are treated as gateway QRs (pre-initiate before initiate) because they carry a preset amount. No POS payment has been completed end-to-end yet — the reference captured for testing had already expired.
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, or the BML handler'sqrInfois set) - Amount is greater than
0 - If transfer types are on offer, one has been picked
- For a Fahipay service, the amount meets that service's rules (see Amount rules)
- For a carrier service by card, the amount meets that service's rules (see Carrier services by BML card)
- No connectivity error for
NO_INTERNETor for the source bank