add support for reload, raastas, ooredoo and dhiraagu bill pay via FahiPay
Auto Tag on Version Change / check-version (push) Successful in 4s
Auto Tag on Version Change / check-version (push) Successful in 4s
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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' \
|
||||||
|
|||||||
@@ -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' \
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← PayMV QR](08-paymv-qr.md)
|
||||||
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user