add support for reload, raastas, ooredoo and dhiraagu bill pay via FahiPay

This commit is contained in:
2026-10-02 22:04:05 +05:00
parent c0944d3809
commit 7a4b7a1712
11 changed files with 409 additions and 57 deletions
@@ -0,0 +1,47 @@
package sh.sar.basedbank.api.fahipay
import android.os.Build
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okio.Buffer
/** Form-body helpers shared by the Fahipay POST endpoints (login, OTP, payments). */
internal object FahipayForm {
/** The `device[...]` fields every Fahipay POST carries. */
fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
"device[available]" to "true",
"device[platform]" to "Android",
"device[uuid]" to deviceUuid,
"device[model]" to Build.MODEL,
"device[manufacturer]" to Build.MANUFACTURER,
"device[isVirtual]" to "false",
"device[serial]" to "unknown"
)
/**
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
* which is what the Fahipay server requires.
*/
fun body(vararg parts: Pair<String, String>): RequestBody {
val boundary = java.util.UUID.randomUUID().toString()
val buf = Buffer()
for ((name, value) in parts) {
val valueBytes = value.toByteArray(Charsets.UTF_8)
buf.writeUtf8("--$boundary\r\n")
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
buf.writeUtf8("\r\n")
buf.write(valueBytes)
buf.writeUtf8("\r\n")
}
buf.writeUtf8("--$boundary--\r\n")
val snapshot = buf.readByteString()
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
return object : RequestBody() {
override fun contentType() = mediaType
override fun contentLength() = snapshot.size.toLong()
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
}
}
}
@@ -4,11 +4,8 @@ import android.os.Build
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okio.Buffer
import org.json.JSONObject
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
@@ -74,14 +71,14 @@ class FahipayLoginFlow {
*/
fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep {
initSession()
val body = buildFormBody(
val body = FahipayForm.body(
"email" to idCard,
"password" to password,
"grant_type" to "auth_id",
"lang" to "en",
"version" to "2.0.0",
"platform" to "BasedBank",
*deviceParts(deviceUuid)
"platform" to "thijooree",
*FahipayForm.deviceParts(deviceUuid)
)
val resp = client.newCall(
@@ -109,15 +106,15 @@ class FahipayLoginFlow {
* Returns authId.
*/
fun verifyTotp(code: String, deviceUuid: String): String {
val body = buildFormBody(
val body = FahipayForm.body(
"code" to code,
"channel" to "totp",
"action" to "login",
"grant_type" to "auth_id",
"lang" to "en",
"version" to "2.0.0",
"platform" to "BasedBank",
*deviceParts(deviceUuid)
"platform" to "thijooree",
*FahipayForm.deviceParts(deviceUuid)
)
val resp = client.newCall(
@@ -138,42 +135,6 @@ class FahipayLoginFlow {
?: throw Exception("No authID in OTP response")
}
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
"device[available]" to "true",
"device[platform]" to "Android",
"device[uuid]" to deviceUuid,
"device[model]" to Build.MODEL,
"device[manufacturer]" to Build.MANUFACTURER,
"device[isVirtual]" to "false",
"device[serial]" to "unknown"
)
/**
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
* which is what the Fahipay server requires.
*/
private fun buildFormBody(vararg parts: Pair<String, String>): RequestBody {
val boundary = java.util.UUID.randomUUID().toString()
val buf = Buffer()
for ((name, value) in parts) {
val valueBytes = value.toByteArray(Charsets.UTF_8)
buf.writeUtf8("--$boundary\r\n")
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
buf.writeUtf8("\r\n")
buf.write(valueBytes)
buf.writeUtf8("\r\n")
}
buf.writeUtf8("--$boundary--\r\n")
val snapshot = buf.readByteString()
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
return object : RequestBody() {
override fun contentType() = mediaType
override fun contentLength() = snapshot.size.toLong()
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
}
}
companion object {
fun generateDeviceUuid(): String {
val bytes = ByteArray(8)
@@ -0,0 +1,71 @@
package sh.sar.basedbank.api.fahipay
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit
/**
* Pays a phone number from the Fahipay wallet: Ooredoo Raastas, Ooredoo bill pay, Dhiraagu
* reload and Dhiraagu bill pay. All four are the same POST, only the path differs — see
* `docs/fahipayapi/09-payments.md`.
*/
class FahipayPaymentClient {
private val BASE_URL = "https://fahipay.mv"
private val UA = "okhttp/4.12.0"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
/**
* A payment the server accepted. [message] is its wording, e.g. "Transaction successful." or,
* for Dhiraagu bill pay, "Transaction will be processed shortly.". [transactionId] (`tid`) is
* only returned by the reload / Raastas endpoints.
*/
data class Result(val message: String, val transactionId: String?)
/**
* POSTs the payment to [path] (e.g. `actions/payment/ooredoo/recharge/`). [amount] is sent as
* typed — the caller checks the service's limits first. Returns on success; throws with the
* server's message when it refuses, [BankServerException] on a 5xx, and IOException when the
* request doesn't get through. Blocking — call from IO.
*/
fun pay(session: FahipaySession, path: String, number: String, amount: String, deviceUuid: String): Result {
val body = FahipayForm.body(
"number" to number,
"amount" to amount,
"lang" to "en",
"version" to "2.0.2",
"build" to "329",
"platform" to "thijooree",
*FahipayForm.deviceParts(deviceUuid)
)
val resp = client.newCall(
Request.Builder().url("$BASE_URL/$path")
.post(body)
.header("authid", session.authId)
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
.header("User-Agent", UA)
.header("accept", "application/json")
.build()
).execute()
val code = resp.code
val json = resp.body?.string().orEmpty()
resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
val obj = try { JSONObject(json) } catch (_: Exception) {
throw Exception("Unexpected response from Fahipay (HTTP $code)")
}
val message = obj.optString("msg").ifBlank { obj.optString("title") }
if (obj.optString("type") != "success") throw Exception(message.ifBlank { "Payment failed" })
return Result(
message = message,
transactionId = obj.optString("tid").takeIf { it.isNotBlank() }
)
}
}
@@ -1481,6 +1481,12 @@ class TransferFragment : Fragment() {
return
}
// Fahipay source: reload / Raastas / bill pay to the picked service
if (selectedAccount?.bank == "FAHIPAY") {
fahipayHandler().submit()
return
}
// BML QR merchant payment — uses shared confirm dialog, no receipt
if (bmlHandler().hasQrMerchant) {
bmlHandler().submitQrPayment()
@@ -1,18 +1,25 @@
package sh.sar.basedbank.ui.home.transfer
import android.text.InputType
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.FahipayPaymentClient
import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.CredentialStore
import java.math.BigDecimal
import java.math.RoundingMode
@@ -21,14 +28,15 @@ import java.math.RoundingMode
* a given number; [label] names it in the "Transfer Type" picker and the recipient card,
* [destinationLabel] in the confirm dialog's "To" block.
*
* Wallet-to-wallet Fahipay transfer is not here yet — there is no send path for it (see the
* class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the
* exhaustive `when`s over this enum will point at every site that needs updating.
* Wallet-to-wallet Fahipay transfer is not here yet. Add it as a constant once its send path
* lands, and the exhaustive `when`s over this enum will point at every site that needs updating.
*/
enum class FahipayService(
val label: String,
val destinationLabel: String,
@param:DrawableRes val iconRes: Int,
/** The endpoint [FahipayPaymentClient.pay] POSTs this service's payments to. */
val paymentPath: String,
/** Smallest amount the service accepts, in MVR. */
val minAmount: Int,
/** Largest amount the service accepts, in MVR, or null for no limit. */
@@ -44,15 +52,19 @@ enum class FahipayService(
val gstPercent: Int? = null,
) {
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
paymentPath = "actions/payment/ooredoo/recharge/",
minAmount = 11, maxAmount = null, decimalsAllowed = false,
contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
paymentPath = "actions/payment/ooredoo/billpay/",
minAmount = 10, maxAmount = 50000, decimalsAllowed = true,
contactCategory = "FAHIPAY_OOREDOO_BILL"),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
paymentPath = "actions/payment/dhiraagu/recharge/",
minAmount = 8, maxAmount = 1000, decimalsAllowed = false,
contactCategory = "FAHIPAY_RELOAD"),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
paymentPath = "actions/payment/dhiraagu/billpay/",
minAmount = 10, maxAmount = 5000, decimalsAllowed = false,
contactCategory = "FAHIPAY_DHIRAAGU_BILL");
@@ -74,10 +86,8 @@ enum class FahipayService(
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific.
*
* **There is no send path yet.** A Fahipay source currently falls through to the MIB branch of
* `initiateTransfer`, which signs the request with a MIB session. When the real payout API is
* wired up it belongs here, as a `doTransfer(...)` alongside the lookup — same shape as the
* other handlers.
* [submit] sends the payment through [FahipayPaymentClient], with the shared confirm dialog
* and an in-dialog success screen (no receipt page yet).
*
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment]
* (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated.
@@ -212,6 +222,96 @@ class FahipayTransferHandler(
return servicesFor(result).map { TransferType.Fahipay(it, ownerName) }
}
// ─── Send ────────────────────────────────────────────────────────────────
/**
* Pays the picked service: confirm dialog (with the GST note for services that charge it),
* biometric gate, then the payment POST. Success shows in the dialog; a refusal closes it
* and toasts the server's message.
*/
fun submit() {
val svc = service ?: return
val src = viewModel.transferDraft.selectedAccount?.takeIf { it.bank == "FAHIPAY" } ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val number = viewModel.transferDraft.transferTypeNumber
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
// Whole-number services get "11", never "11.00"
val amountParam = amount.stripTrailingZeros().toPlainString()
val amountDisplay = "%,.2f".format(amount)
val toName = binding.tvToAccountName.text?.toString().orEmpty().ifBlank { number }
val confirmView = fragment.buildTransferConfirmView(
amountCurrency = "MVR",
amountValue = amountDisplay,
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = "Fahipay",
toName = toName,
toNumber = number,
toDetail = svc.destinationLabel,
warningTexts = listOfNotNull(gstNote(svc))
)
fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "MVR $amountDisplay → ${svc.label} $number",
onConfirmed = { dialog, frame ->
fragment.showProcessingInDialog(dialog, frame)
pay(src, svc, number, amountParam, amountDisplay, toName, dialog, frame)
}
)
}
private fun pay(
src: BankAccount,
svc: FahipayService,
number: String,
amountParam: String,
amountDisplay: String,
toName: String,
dialog: AlertDialog,
frame: android.widget.FrameLayout,
) {
val app = fragment.requireActivity().application as BasedBankApp
val session = app.fahipaySessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val deviceUuid = CredentialStore(ctx).getOrCreateFahipayDeviceUuid()
binding.btnTransfer.isEnabled = false
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { FahipayPaymentClient().pay(session, svc.paymentPath, number, amountParam, deviceUuid) }
}
if (fragment.view == null) return@launch
result.onSuccess {
fragment.showSuccessInDialog(
dialog, frame,
amountCurrency = "MVR",
amountValue = amountDisplay,
fromName = src.accountBriefName,
toName = "$toName · ${svc.label}"
) {
fragment.clearForm()
(fragment.activity as? HomeActivity)?.triggerRefresh()
}
}.onFailure { e ->
dialog.dismiss()
binding.btnTransfer.isEnabled = true
val msg = when {
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
!e.message.isNullOrBlank() -> e.message!!
else -> "Payment failed"
}
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
}
}
}
// ─── Carrier lookup ──────────────────────────────────────────────────────
private data class CarrierResult(
+2 -2
View File
@@ -25,7 +25,7 @@ POST https://fahipay.mv/api/app/login/
| `grant_type` | `auth_id` | Always `auth_id` |
| `lang` | `en` | Always `en` |
| `version` | `2.0.0` | App version string |
| `platform` | `BasedBank` | Client identifier (`app` in the original Fahipay app) |
| `platform` | `thijooree` | Client identifier (`app` in the original Fahipay app) |
| `device[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) |
| `device[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install |
@@ -53,7 +53,7 @@ curl --request POST \
--form 'grant_type=auth_id' \
--form 'lang=en' \
--form 'version=2.0.0' \
--form 'platform=BasedBank' \
--form 'platform=thijooree' \
--form 'device[available]=true' \
--form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \
+2 -2
View File
@@ -34,7 +34,7 @@ POST https://fahipay.mv/api/app/otp/
| `grant_type` | `auth_id` | Always `auth_id` |
| `lang` | `en` | Always `en` |
| `version` | `2.0.0` | App version string |
| `platform` | `BasedBank` | Client identifier (`app` in the original Fahipay app) |
| `platform` | `thijooree` | Client identifier (`app` in the original Fahipay app) |
| `device[available]` | `true` | Same device fields as login — must match |
| `device[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request |
@@ -64,7 +64,7 @@ curl --request POST \
--form 'grant_type=auth_id' \
--form 'lang=en' \
--form 'version=2.0.0' \
--form 'platform=BasedBank' \
--form 'platform=thijooree' \
--form 'device[available]=true' \
--form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \
+1 -1
View File
@@ -37,4 +37,4 @@ Server-issued payload fields that differ from a plain PayMV QR: `60` = `LD` + 4
---
[← Saved Favourites](07-contacts.md)
[← Saved Favourites](07-contacts.md) | [Payments →](09-payments.md)
+157
View File
@@ -0,0 +1,157 @@
# Payments: Reload, Raastas & Bill Pay
Pay a Dhiraagu or Ooredoo number from the Fahipay wallet. All four services use the same request; only the path differs.
---
## Endpoints
| Service | Endpoint | Activity `subtype` |
|---|---|---|
| Ooredoo Raastas (prepaid top-up) | `POST https://fahipay.mv/actions/payment/ooredoo/recharge/` | `OORCH` |
| Ooredoo Bill Pay | `POST https://fahipay.mv/actions/payment/ooredoo/billpay/` | `OOBPY` |
| Dhiraagu Reload | `POST https://fahipay.mv/actions/payment/dhiraagu/recharge/` | `DHRCH` |
| Dhiraagu Bill Pay | `POST https://fahipay.mv/actions/payment/dhiraagu/billpay/` | `DHBPY` |
Which services a number supports comes from the carrier lookups: [Dhiraagu](../dhiraaguapi/01-number-lookup.md) and [Ooredoo](../ooredooapi/01-number-validation.md).
---
## Prerequisites
- Valid `authID` from [login](01-login.md) or [OTP](02-otp.md)
- Valid `__Secure-sess` session cookie
There's no OTP or PIN step. The single POST moves the money.
---
## Request
### Headers
| Header | Value |
|---|---|
| `authid` | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
| `Content-Type` | `multipart/form-data; boundary=<boundary>` |
| `Cookie` | `__Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
The official app also sends a set of `x-app-*` / `x-device-*` headers and a `FahiPay-App/2.0.2 (...)` user agent. Thijooree sends `okhttp/4.12.0` like its other data calls.
### Body (`multipart/form-data`)
Thijooree builds it with lowercase `content-disposition` part headers, the way the app sends them (`FahipayForm.body`).
| Field | Example | Notes |
|---|---|---|
| `number` | `9198026` | 7-digit phone number |
| `amount` | `11` | MVR. Whole number for Raastas, Dhiraagu Reload and Dhiraagu Bill Pay. Ooredoo Bill Pay takes decimals (`10.1` seen) |
| `lang` | `en` | |
| `version` | `2.0.2` | App version |
| `build` | `329` | App build |
| `platform` | `app` | The official app sends `app`. Thijooree sends `thijooree` |
| `device[...]` | | The standard [device fields](README.md#common-form-fields-device-info) |
### Amount limits
These are enforced in Thijooree before sending (see [Transfer Flows](../thijooree/20-transfer-flows.md#amount-rules)):
| Service | Min | Max | Decimals |
|---|---|---|---|
| Raastas | 11 | none | no |
| Ooredoo Bill Pay | 10 | 50,000 | yes |
| Dhiraagu Reload | 8 | 1,000 | no |
| Dhiraagu Bill Pay | 10 | 5,000 | no |
The full `amount` is taken from the wallet. Raastas then has 8% GST taken out by Ooredoo, so the number is credited less than `amount`.
---
## curl Example
```bash
curl --request POST \
--url 'https://fahipay.mv/actions/payment/ooredoo/recharge/' \
--compressed \
--header 'authid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
--header 'Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
--form 'number=9198026' \
--form 'amount=11' \
--form 'lang=en' \
--form 'version=2.0.2' \
--form 'build=329' \
--form 'platform=thijooree' \
--form 'device[available]=true' \
--form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \
--form 'device[model]={model}' \
--form 'device[manufacturer]={manufacturer}' \
--form 'device[isVirtual]=false' \
--form 'device[serial]=unknown'
```
---
## Response
`200 OK`, `application/json`.
### Success: reload / Raastas
```json
{"title":"Success!","msg":"Transaction successful.","type":"success","tid":"FP202610021957143XKQ"}
```
### Success: Ooredoo Bill Pay
```json
{"title":"Success!","msg":"Transaction successful.","type":"success"}
```
### Success: Dhiraagu Bill Pay
```json
{"title":"Success!","msg":"Transaction will be processed shortly.","type":"success"}
```
| Field | Description |
|---|---|
| `type` | `success` when the payment went through |
| `title` / `msg` | Human-readable outcome |
| `tid` | Fahipay transaction ID. Only returned by the recharge endpoints. Bill pays have one too, but it's only visible in [history](05-history.md) |
### Failure
Not captured yet. Thijooree treats any `type` other than `success` as a refusal and shows `msg` (or `title`).
---
## In history
The payment shows up in [`actions/activity/`](05-history.md) straight away, with a negative `amount`:
```json
{
"date": "2026-10-02 19:57:14",
"name": "Ooredoo Raastas",
"details": "Mobile Recharge - 9198026",
"icon": "https://fahipay.mv/images/app/icons/services/oorch.png",
"transaction": "FP202610021957143XKQ",
"type": "payment",
"subtype": "OORCH",
"number": "9198026",
"amount": -11,
"success": 1,
"status": "Success"
}
```
`name` / `details` per service: `Ooredoo Raastas` / `Mobile Recharge - <number>`, `Ooredoo BillPay` / `BillPay - <number>`, `Dhiraagu Reload` / `Mobile Recharge - <number>`, `Dhiraagu BillPay` / `BillPay - <number>`.
---
&nbsp;
---
[← PayMV QR](08-paymv-qr.md)
+1
View File
@@ -128,6 +128,7 @@ Client Server
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
| 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress |
| 9 | [Payments](09-payments.md) | Dhiraagu reload / bill pay, Ooredoo Raastas / bill pay |
---
+9
View File
@@ -131,6 +131,15 @@ Raastas charges 8% GST out of the amount paid (`FahipayService.gstPercent`), so
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.