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.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(