add support for reload, raastas, ooredoo and dhiraagu bill pay via FahiPay
Auto Tag on Version Change / check-version (push) Successful in 4s

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.Cookie
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody
import okio.Buffer
import org.json.JSONObject import org.json.JSONObject
import java.security.SecureRandom import java.security.SecureRandom
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -74,14 +71,14 @@ class FahipayLoginFlow {
*/ */
fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep { fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep {
initSession() initSession()
val body = buildFormBody( val body = FahipayForm.body(
"email" to idCard, "email" to idCard,
"password" to password, "password" to password,
"grant_type" to "auth_id", "grant_type" to "auth_id",
"lang" to "en", "lang" to "en",
"version" to "2.0.0", "version" to "2.0.0",
"platform" to "BasedBank", "platform" to "thijooree",
*deviceParts(deviceUuid) *FahipayForm.deviceParts(deviceUuid)
) )
val resp = client.newCall( val resp = client.newCall(
@@ -109,15 +106,15 @@ class FahipayLoginFlow {
* Returns authId. * Returns authId.
*/ */
fun verifyTotp(code: String, deviceUuid: String): String { fun verifyTotp(code: String, deviceUuid: String): String {
val body = buildFormBody( val body = FahipayForm.body(
"code" to code, "code" to code,
"channel" to "totp", "channel" to "totp",
"action" to "login", "action" to "login",
"grant_type" to "auth_id", "grant_type" to "auth_id",
"lang" to "en", "lang" to "en",
"version" to "2.0.0", "version" to "2.0.0",
"platform" to "BasedBank", "platform" to "thijooree",
*deviceParts(deviceUuid) *FahipayForm.deviceParts(deviceUuid)
) )
val resp = client.newCall( val resp = client.newCall(
@@ -138,42 +135,6 @@ class FahipayLoginFlow {
?: throw Exception("No authID in OTP response") ?: 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 { companion object {
fun generateDeviceUuid(): String { fun generateDeviceUuid(): String {
val bytes = ByteArray(8) 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 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 // BML QR merchant payment — uses shared confirm dialog, no receipt
if (bmlHandler().hasQrMerchant) { if (bmlHandler().hasQrMerchant) {
bmlHandler().submitQrPayment() bmlHandler().submitQrPayment()
@@ -1,18 +1,25 @@
package sh.sar.basedbank.ui.home.transfer package sh.sar.basedbank.ui.home.transfer
import android.text.InputType import android.text.InputType
import android.widget.Toast
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient 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.fahipay.OoredooClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentTransferBinding 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.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.util.AccountInputParser import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.CredentialStore
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode 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, * a given number; [label] names it in the "Transfer Type" picker and the recipient card,
* [destinationLabel] in the confirm dialog's "To" block. * [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 * Wallet-to-wallet Fahipay transfer is not here yet. Add it as a constant once its send path
* class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the * lands, and the exhaustive `when`s over this enum will point at every site that needs updating.
* exhaustive `when`s over this enum will point at every site that needs updating.
*/ */
enum class FahipayService( enum class FahipayService(
val label: String, val label: String,
val destinationLabel: String, val destinationLabel: String,
@param:DrawableRes val iconRes: Int, @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. */ /** Smallest amount the service accepts, in MVR. */
val minAmount: Int, val minAmount: Int,
/** Largest amount the service accepts, in MVR, or null for no limit. */ /** Largest amount the service accepts, in MVR, or null for no limit. */
@@ -44,15 +52,19 @@ enum class FahipayService(
val gstPercent: Int? = null, val gstPercent: Int? = null,
) { ) {
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo, RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
paymentPath = "actions/payment/ooredoo/recharge/",
minAmount = 11, maxAmount = null, decimalsAllowed = false, minAmount = 11, maxAmount = null, decimalsAllowed = false,
contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8), contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo, OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
paymentPath = "actions/payment/ooredoo/billpay/",
minAmount = 10, maxAmount = 50000, decimalsAllowed = true, minAmount = 10, maxAmount = 50000, decimalsAllowed = true,
contactCategory = "FAHIPAY_OOREDOO_BILL"), contactCategory = "FAHIPAY_OOREDOO_BILL"),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo, DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
paymentPath = "actions/payment/dhiraagu/recharge/",
minAmount = 8, maxAmount = 1000, decimalsAllowed = false, minAmount = 8, maxAmount = 1000, decimalsAllowed = false,
contactCategory = "FAHIPAY_RELOAD"), contactCategory = "FAHIPAY_RELOAD"),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo, DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
paymentPath = "actions/payment/dhiraagu/billpay/",
minAmount = 10, maxAmount = 5000, decimalsAllowed = false, minAmount = 10, maxAmount = 5000, decimalsAllowed = false,
contactCategory = "FAHIPAY_DHIRAAGU_BILL"); contactCategory = "FAHIPAY_DHIRAAGU_BILL");
@@ -74,10 +86,8 @@ enum class FahipayService(
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm * Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific. * 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 * [submit] sends the payment through [FahipayPaymentClient], with the shared confirm dialog
* `initiateTransfer`, which signs the request with a MIB session. When the real payout API is * and an in-dialog success screen (no receipt page yet).
* wired up it belongs here, as a `doTransfer(...)` alongside the lookup — same shape as the
* other handlers.
* *
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] * 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. * (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) } 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 ────────────────────────────────────────────────────── // ─── Carrier lookup ──────────────────────────────────────────────────────
private data class CarrierResult( 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` | | `grant_type` | `auth_id` | Always `auth_id` |
| `lang` | `en` | Always `en` | | `lang` | `en` | Always `en` |
| `version` | `2.0.0` | App version string | | `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[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) |
| `device[platform]` | `Android` | | | `device[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install | | `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 'grant_type=auth_id' \
--form 'lang=en' \ --form 'lang=en' \
--form 'version=2.0.0' \ --form 'version=2.0.0' \
--form 'platform=BasedBank' \ --form 'platform=thijooree' \
--form 'device[available]=true' \ --form 'device[available]=true' \
--form 'device[platform]=Android' \ --form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \ --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` | | `grant_type` | `auth_id` | Always `auth_id` |
| `lang` | `en` | Always `en` | | `lang` | `en` | Always `en` |
| `version` | `2.0.0` | App version string | | `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[available]` | `true` | Same device fields as login — must match |
| `device[platform]` | `Android` | | | `device[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request | | `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 'grant_type=auth_id' \
--form 'lang=en' \ --form 'lang=en' \
--form 'version=2.0.0' \ --form 'version=2.0.0' \
--form 'platform=BasedBank' \ --form 'platform=thijooree' \
--form 'device[available]=true' \ --form 'device[available]=true' \
--form 'device[platform]=Android' \ --form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \ --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) | | 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 | | 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 | | 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. 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 #### 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. 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.