From 7a4b7a17128c752acb3c94904d8dd9ef059af052 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Fri, 2 Oct 2026 22:04:05 +0500 Subject: [PATCH] add support for reload, raastas, ooredoo and dhiraagu bill pay via FahiPay --- .../sar/basedbank/api/fahipay/FahipayForm.kt | 47 ++++++ .../basedbank/api/fahipay/FahipayLoginFlow.kt | 51 +----- .../api/fahipay/FahipayPaymentClient.kt | 71 ++++++++ .../sar/basedbank/ui/home/TransferFragment.kt | 6 + .../home/transfer/FahipayTransferHandler.kt | 114 ++++++++++++- docs/fahipayapi/01-login.md | 4 +- docs/fahipayapi/02-otp.md | 4 +- docs/fahipayapi/08-paymv-qr.md | 2 +- docs/fahipayapi/09-payments.md | 157 ++++++++++++++++++ docs/fahipayapi/README.md | 1 + docs/thijooree/20-transfer-flows.md | 9 + 11 files changed, 409 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayForm.kt create mode 100644 app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayPaymentClient.kt create mode 100644 docs/fahipayapi/09-payments.md diff --git a/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayForm.kt b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayForm.kt new file mode 100644 index 0000000..8abc129 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayForm.kt @@ -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> = 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): 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) } + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayLoginFlow.kt index 0cf6f2a..9be0203 100644 --- a/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayLoginFlow.kt @@ -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> = 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): 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) diff --git a/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayPaymentClient.kt b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayPaymentClient.kt new file mode 100644 index 0000000..b4c323c --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayPaymentClient.kt @@ -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() } + ) + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 09c564f..9c1a245 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -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() diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt index f5bdc4d..7d61038 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt @@ -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( diff --git a/docs/fahipayapi/01-login.md b/docs/fahipayapi/01-login.md index 77ddec3..fe034b2 100644 --- a/docs/fahipayapi/01-login.md +++ b/docs/fahipayapi/01-login.md @@ -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' \ diff --git a/docs/fahipayapi/02-otp.md b/docs/fahipayapi/02-otp.md index 5a8a02e..603f8be 100644 --- a/docs/fahipayapi/02-otp.md +++ b/docs/fahipayapi/02-otp.md @@ -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' \ diff --git a/docs/fahipayapi/08-paymv-qr.md b/docs/fahipayapi/08-paymv-qr.md index 7c4eea4..cb7fb29 100644 --- a/docs/fahipayapi/08-paymv-qr.md +++ b/docs/fahipayapi/08-paymv-qr.md @@ -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) diff --git a/docs/fahipayapi/09-payments.md b/docs/fahipayapi/09-payments.md new file mode 100644 index 0000000..c3bf88f --- /dev/null +++ b/docs/fahipayapi/09-payments.md @@ -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=` | +| `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 - `, `Ooredoo BillPay` / `BillPay - `, `Dhiraagu Reload` / `Mobile Recharge - `, `Dhiraagu BillPay` / `BillPay - `. + +--- + +  + +--- + +[← PayMV QR](08-paymv-qr.md) diff --git a/docs/fahipayapi/README.md b/docs/fahipayapi/README.md index ec44f55..de38cd5 100644 --- a/docs/fahipayapi/README.md +++ b/docs/fahipayapi/README.md @@ -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 | --- diff --git a/docs/thijooree/20-transfer-flows.md b/docs/thijooree/20-transfer-flows.md index 61339f7..21b755b 100644 --- a/docs/thijooree/20-transfer-flows.md +++ b/docs/thijooree/20-transfer-flows.md @@ -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.