# 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](#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](25-qr-scanner.md) 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](#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.applyServiceContact`). 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 `canTransfer` set 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: 1. **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. 2. **Biometric gate**, as for every transfer. 3. **Payment.** `FahipayPaymentClient.pay()` POSTs to the service's `paymentPath` (see [Fahipay Payments](../fahipayapi/09-payments.md)). The amount is sent without trailing zeros (`11`, `10.1`). 4. **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](29-card-verification-and-merchant-card-pay.md)). 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: 1. `CardPayoutTransferHandler.submit()` has the carrier create it for the number and amount (`DhiraaguPaymentClient.createReloadTransaction` / `createBillPayTransaction`, `OoredooPaymentClient.createRaastasTransaction` / `createBillPayTransaction`, see [Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md) and [Ooredoo API → Raastas](../ooredooapi/02-raastas.md), [→ Bill Pay](../ooredooapi/03-bill-pay.md)). 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. 2. 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. 3. 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 1. If the input type is `MIB_ACCOUNT`, calls `BmlValidateClient.verifyMibAccount()`. 2. Otherwise calls `BmlValidateClient.validateAccount()`. 3. If either BML call fails and a MIB session is available, falls back to `MibTransferClient.lookup()`. 4. 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: 1. **Initiate** — `startBmlBusinessOtpFlow()` calls `BmlAccountClient.fetchTransferChannels()` to list available channels (email, SMS). 2. **Channel selection** — a channel picker is shown inline. Transfer fields are locked (dimmed, disabled). 3. **Initiate with channel** — `BmlTransferClient.initiateTransfer()` is called with the chosen channel, which triggers the OTP dispatch. 4. **OTP entry** — an OTP input field appears. The transfer button label changes to "Verify Payment". 5. **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](18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr). Flow: 1. `lookupQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row. 2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field. 3. Remarks field is locked (not applicable for merchant payments). 4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP. 5. 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 (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is 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](#amount-rules)) - For a carrier service by card, the amount meets that service's rules (see [Carrier services by BML card](#carrier-services-by-bml-card)) - No connectivity error for `NO_INTERNET` or for the source bank ---   --- [← Account Parser Architecture](19-parsers.md)