Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aba66c3e34
|
@@ -24,11 +24,20 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
|
||||
|
||||
if git tag -l | grep -q "^v${VERSION}$"; then
|
||||
echo "Tag v${VERSION} already exists, skipping"
|
||||
BEFORE="${{ github.event.before }}"
|
||||
if [ -z "$BEFORE" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
|
||||
BEFORE="HEAD~1"
|
||||
fi
|
||||
PREV_VERSION_CODE=$(git show "${BEFORE}:app/build.gradle.kts" 2>/dev/null | grep 'versionCode = ' | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||
|
||||
if [ "$VERSION_CODE" = "$PREV_VERSION_CODE" ]; then
|
||||
echo "versionCode unchanged (${VERSION_CODE}), skipping"
|
||||
echo "should_release=false" >> $GITHUB_OUTPUT
|
||||
elif git tag -l | grep -q "^v${VERSION}$"; then
|
||||
echo "versionCode changed (${PREV_VERSION_CODE} -> ${VERSION_CODE}) but tag v${VERSION} already exists; bump versionName"
|
||||
exit 1
|
||||
else
|
||||
echo "New version detected: v${VERSION}"
|
||||
echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})"
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
@@ -17,9 +17,6 @@ docs/mibapi/tmp
|
||||
docs/bmlapi/tmp
|
||||
docs/fahipayapi/tmp
|
||||
docs/mfaisaapi/tmp
|
||||
docs/dhiraaguapi/tmp
|
||||
docs/ooredooapi/tmp
|
||||
docs/ooredooapi/tmp
|
||||
tmp
|
||||
app/key.jks
|
||||
.kotlin/*
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 35
|
||||
versionName = "1.0.34"
|
||||
versionCode = 34
|
||||
versionName = "1.0.33"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.PAGE_ORIGIN
|
||||
import java.security.KeyFactory
|
||||
import java.security.spec.MGF1ParameterSpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
@@ -34,12 +33,7 @@ import android.util.Base64
|
||||
* 4. The 3-D Secure challenge on BML's Wibmo ACS: the render page auto-posts the `creq`, we pick
|
||||
* the "Authenticator" channel and submit the BML token's TOTP. The ACS then auto-posts the
|
||||
* result to the Mastercard gateway, which posts it back to BML's `mpgsNotification`.
|
||||
* 5. Poll next-action until TRANSACTION_CONFIRMED. A decline after 3-D Secure (e.g. insufficient
|
||||
* funds) doesn't show up there: it's a new `paymentErrorHistory` entry on the transaction,
|
||||
* checked alongside.
|
||||
* 6. Return to the merchant: `GET <txn>?wait=1` redirects to the merchant's `redirectUrl` with a
|
||||
* signed `state=CONFIRMED` — the browser's last hop, and how merchants (Dhiraagu, Ooredoo)
|
||||
* learn they were paid. Without it the card is charged but the merchant never delivers.
|
||||
* 5. Poll next-action until TRANSACTION_CONFIRMED.
|
||||
*
|
||||
* Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS
|
||||
* session cookies.
|
||||
@@ -55,8 +49,7 @@ class BmlMerchantCardPayClient {
|
||||
)
|
||||
|
||||
sealed class Result {
|
||||
/** Paid. [merchantNotified] is false when the return to the merchant (step 6) failed. */
|
||||
data class Success(val merchantNotified: Boolean) : Result()
|
||||
object Success : Result()
|
||||
data class Failure(val message: String) : Result()
|
||||
}
|
||||
|
||||
@@ -92,10 +85,7 @@ class BmlMerchantCardPayClient {
|
||||
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
|
||||
val txnId = page.transactionId
|
||||
|
||||
val txnClient = BmlMerchantTxnClient()
|
||||
val browserId = runCatching { txnClient.announceBrowser(txnId) }.getOrNull()
|
||||
// Earlier attempts' declines are already in the history; only newer ones are ours
|
||||
val priorErrors = browserId?.let { id -> runCatching { txnClient.paymentErrors(txnId, id).size }.getOrNull() }
|
||||
runCatching { BmlMerchantTxnClient().announceBrowser(txnId) }
|
||||
|
||||
// 1-2. Credentials, then tokenise the card with Pomelo
|
||||
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
|
||||
@@ -140,7 +130,7 @@ class BmlMerchantCardPayClient {
|
||||
var threeDsUrl: String? = null
|
||||
for (attempt in 0..MAX_POLLS) {
|
||||
when (action.optString("action")) {
|
||||
"TRANSACTION_CONFIRMED" -> return confirmed(txnId)
|
||||
"TRANSACTION_CONFIRMED" -> return Result.Success
|
||||
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
|
||||
}
|
||||
threeDsUrl = action.optString("3dsUrl").ifBlank { null }
|
||||
@@ -156,56 +146,14 @@ class BmlMerchantCardPayClient {
|
||||
// 5. Wait for the gateway's verdict to reach BML
|
||||
repeat(MAX_POLLS * 2) {
|
||||
when (poll(pk, txnId).optString("action")) {
|
||||
"TRANSACTION_CONFIRMED" -> return confirmed(txnId)
|
||||
"TRANSACTION_CONFIRMED" -> return Result.Success
|
||||
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
|
||||
}
|
||||
if (browserId != null && priorErrors != null) {
|
||||
runCatching { txnClient.paymentErrors(txnId, browserId) }.getOrNull()
|
||||
?.drop(priorErrors)?.lastOrNull()
|
||||
?.let { return Result.Failure(it.message.ifBlank { "The bank declined the payment" }) }
|
||||
}
|
||||
Thread.sleep(POLL_MS / 2)
|
||||
}
|
||||
return Result.Failure("Payment status unknown — check with the merchant before retrying")
|
||||
}
|
||||
|
||||
/** The payment went through: return to the merchant, then report success either way. */
|
||||
private fun confirmed(txnId: String) = Result.Success(merchantNotified = returnToMerchant(txnId))
|
||||
|
||||
/**
|
||||
* What the browser does once the payment page sees the confirmation: loads `<txn>?wait=1`,
|
||||
* which 302s to the merchant's `redirectUrl` (`…?transactionId=<id>&state=CONFIRMED&signature=…`)
|
||||
* and on to its receipt page. Some merchants' callback page instead auto-submits a form on
|
||||
* load (Ooredoo's posts the result on to its own site), so those forms are submitted too.
|
||||
* Retries a couple of times; true when the chain ended on a 2xx page.
|
||||
*/
|
||||
private fun returnToMerchant(txnId: String): Boolean {
|
||||
repeat(RETURN_ATTEMPTS) { attempt ->
|
||||
if (attempt > 0) Thread.sleep(RETURN_RETRY_MS)
|
||||
val ok = runCatching {
|
||||
var request = browserNav(Request.Builder().url("$PAGE_ORIGIN/$txnId?wait=1"))
|
||||
for (hop in 0..MAX_AUTO_SUBMITS) {
|
||||
val (code, html, url) = client.newCall(request).execute().use {
|
||||
Triple(it.code, it.body?.string().orEmpty(), it.request.url.toString())
|
||||
}
|
||||
if (code !in 200..299) return@runCatching false
|
||||
// A page that only exists to post itself onward, like the ACS's own hops
|
||||
if (hop == MAX_AUTO_SUBMITS || !AUTO_SUBMIT.containsMatchIn(html)) return@runCatching true
|
||||
val form = AcsForm.parse(html, url) ?: return@runCatching true
|
||||
request = browserNav(form.toRequest().newBuilder())
|
||||
}
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
if (ok) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun browserNav(builder: Request.Builder): Request = builder
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.build()
|
||||
|
||||
/** Drives the ACS challenge. Returns null on success, or a Failure to stop the payment. */
|
||||
private fun runThreeDs(threeDsUrl: String, otp: (Boolean) -> String): Result? {
|
||||
// render-tds: an auto-submitting form (with an explicit action) that posts the creq to the
|
||||
@@ -228,8 +176,6 @@ class BmlMerchantCardPayClient {
|
||||
}
|
||||
|
||||
// OTP entry. Submit the token code; if it expired, ask for a fresh one once and retry.
|
||||
// A rejected code comes back as the OTP page again with "The OTP code you entered is
|
||||
// incorrect Please try again."
|
||||
var retry = false
|
||||
for (attempt in 0..1) {
|
||||
form = AcsForm.parse(html, acsUrl) ?: break
|
||||
@@ -237,10 +183,9 @@ class BmlMerchantCardPayClient {
|
||||
form.fields["otpValue"] = otp(retry)
|
||||
form.fields["formReqType"] = "SUBMIT"
|
||||
html = execText(form.toRequest())
|
||||
if (!otpRejected(html)) break
|
||||
if (!html.contains("incorrect", true) && !html.contains("expired", true)) break
|
||||
retry = true
|
||||
}
|
||||
if (otpRejected(html)) return Result.Failure("The bank rejected the BML token code. Check the phone's clock and try again.")
|
||||
// On success the ACS returns an auto-posting form to the gateway; follow it (and the
|
||||
// gateway's own auto-post back to BML) so the verdict is recorded before we poll.
|
||||
repeat(3) {
|
||||
@@ -251,9 +196,6 @@ class BmlMerchantCardPayClient {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun otpRejected(html: String) = html.contains("name=\"otpValue\"") &&
|
||||
(html.contains("incorrect", true) || html.contains("expired", true))
|
||||
|
||||
// ── next-action helpers ──────────────────────────────────────────────────
|
||||
|
||||
private fun nextAction(pk: String, body: JSONObject): JSONObject =
|
||||
@@ -355,11 +297,5 @@ class BmlMerchantCardPayClient {
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private const val POLL_MS = 5_000L
|
||||
private const val MAX_POLLS = 10
|
||||
private const val RETURN_ATTEMPTS = 3
|
||||
private const val RETURN_RETRY_MS = 2_000L
|
||||
/** Auto-submitting merchant pages followed on the way back; Ooredoo has one. */
|
||||
private const val MAX_AUTO_SUBMITS = 2
|
||||
/** `<body onload="document.forms['x'].submit()">` and the like. */
|
||||
private val AUTO_SUBMIT = Regex("""onload\s*=\s*("[^"]*|'[^']*)\.submit\(\)""", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,36 +113,12 @@ class BmlMerchantTxnClient {
|
||||
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
|
||||
}
|
||||
|
||||
/**
|
||||
* The PATCHes the page sends on load: register this "browser" and clear any FX selection.
|
||||
* Returns the browser id, for [paymentErrors].
|
||||
*/
|
||||
fun announceBrowser(transactionId: String): String {
|
||||
val browserId = "${transactionId}_${System.currentTimeMillis()}"
|
||||
patch(transactionId, JSONObject().put("activeBrowserId", browserId))
|
||||
/** The PATCHes the page sends on load: register this "browser" and clear any FX selection. */
|
||||
fun announceBrowser(transactionId: String) {
|
||||
patch(transactionId, JSONObject().put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}"))
|
||||
patch(transactionId, JSONObject().put("fx", "reset"))
|
||||
return browserId
|
||||
}
|
||||
|
||||
/**
|
||||
* The transaction's failed payment attempts, oldest first, read with the page's load PATCH
|
||||
* as [browserId]. A declined card (e.g. `INSUFFICIENT_FUNDS`) lands here while the
|
||||
* transaction stays payable — `state` doesn't change, `hasError` turns true.
|
||||
*/
|
||||
fun paymentErrors(transactionId: String, browserId: String): List<PaymentError> {
|
||||
val history = patch(transactionId, JSONObject().put("activeBrowserId", browserId))
|
||||
.optJSONArray("paymentErrorHistory") ?: return emptyList()
|
||||
return (0 until history.length()).mapNotNull { history.optJSONObject(it) }.map {
|
||||
PaymentError(
|
||||
code = it.optString("code"),
|
||||
message = it.optString("customerVisibleDescription").ifBlank { it.optString("reason") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry of `paymentErrorHistory`: the gateway's code and the wording BML shows for it. */
|
||||
data class PaymentError(val code: String, val message: String)
|
||||
|
||||
private fun patch(transactionId: String, body: JSONObject): JSONObject {
|
||||
val request = Request.Builder()
|
||||
.url("$API_BASE/transactions/$transactionId")
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
package sh.sar.basedbank.api.dhiraagu
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Dhiraagu prepaid reload ("Easy TopUp") and bill payment ("Easy Pay") through dhiraagu.com.mv,
|
||||
* paid by card on BML's merchant gateway. Dhiraagu only builds the order; the money moves on the
|
||||
* BML Merchant Services transaction handed back, which is paid like any card-only BML merchant
|
||||
* link. See `docs/dhiraaguapi/02-reload.md` and `docs/dhiraaguapi/03-bill-pay.md`.
|
||||
*
|
||||
* Every call blocks, so run it on an IO thread.
|
||||
*/
|
||||
class DhiraaguPaymentClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Creates the reload order for [number] and the BML transaction paying for it: cart →
|
||||
* merchant (the BML gateway entry) → payment → BML transaction. [amount] is the whole MVR
|
||||
* amount paid, GST included. Returns the 24-hex BML transaction id. Throws with Dhiraagu's
|
||||
* wording when a step is refused.
|
||||
*/
|
||||
fun createReloadTransaction(number: String, amount: Int): String {
|
||||
val topupNonce = pageNonce("$BASE/services/easy-topup")
|
||||
val gst = gstOf(amount)
|
||||
val cart = api("cart", "recharge", topupNonce, JSONObject()
|
||||
.put("formId", FORM_RELOAD)
|
||||
.put("serviceNumber", number)
|
||||
.put("amount", amount)
|
||||
.put("amountGST", gst.toDouble())
|
||||
.put("amountRecharge", (BigDecimal(amount) - gst).toDouble())
|
||||
.put("gstRate", GST_RATE)
|
||||
.put("memberId", "").put("memberName", "").put("memberNId", "")
|
||||
.put("customerId", "").put("customerCode", "")
|
||||
.put("version", 2))
|
||||
return payCart(FORM_RELOAD, cart, BigDecimal(amount))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the bill payment order for postpaid [number] and the BML transaction paying for
|
||||
* it: lookup (for the billing account) → cart → merchant → payment → BML transaction.
|
||||
* [amount] is MVR, up to 2 decimal places. Returns the 24-hex BML transaction id. Throws
|
||||
* with Dhiraagu's wording when a step is refused.
|
||||
*/
|
||||
fun createBillPayTransaction(number: String, amount: BigDecimal): String {
|
||||
val easyPayNonce = pageNonce("$BASE/services/easy-pay")
|
||||
// The cart needs the billing account the number belongs to, which only the lookup gives
|
||||
val info = call("dhiraaguIO", "infoUnlisted", easyPayNonce, JSONObject().put("number", number))
|
||||
if (info.optJSONArray("serviceDetails")?.optJSONObject(0)?.optString("prepaidIndicator") == "Y") {
|
||||
throw Exception("Prepaid number is not allowed.")
|
||||
}
|
||||
val accountNumber = info.optString("accountNumber").ifBlank { throw Exception("Invalid account/service number") }
|
||||
|
||||
// The page refuses some account statuses and customer types before ordering; so do we
|
||||
val rules = runCatching { get("setting", "bill", easyPayNonce).getJSONObject("resp").getJSONObject("settingAppJson1") }.getOrNull()
|
||||
val status = info.optString("accountStatus")
|
||||
if (rules != null && status in rules.blockedValues("accountStatus")) {
|
||||
throw Exception("Dhiraagu can't accept payment for this service. Contact Dhiraagu customer service. [Account Status: $status]")
|
||||
}
|
||||
val customerType = info.optString("customerType")
|
||||
if (rules != null && customerType in rules.blockedValues("customerType")) {
|
||||
throw Exception("The number is not allowed. [Customer Type: $customerType]")
|
||||
}
|
||||
|
||||
val cart = api("cart", "easyPay", easyPayNonce, JSONObject()
|
||||
.put("formId", FORM_BILL)
|
||||
.put("serviceNumber", number)
|
||||
.put("accountNumber", accountNumber)
|
||||
.put("amount", money(amount))
|
||||
.put("memberId", "").put("memberName", "").put("memberNId", "")
|
||||
.put("billRef", "")
|
||||
.put("billType", if (info.optString("type") == BILL_WRITE_OFF) BILL_WRITE_OFF else BILL_PAYMENT))
|
||||
return payCart(FORM_BILL, cart, amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* The payment page's half, shared by every form: picks the BML gateway, creates the payment
|
||||
* for [cart] and has Dhiraagu create the BML transaction. Returns its 24-hex id.
|
||||
*/
|
||||
private fun payCart(formId: Int, cart: JSONObject, amount: BigDecimal): String {
|
||||
val cartId = cart.optString("cartId").ifBlank { throw Exception("Dhiraagu didn't create the order") }
|
||||
|
||||
// The payment page carries its own nonce, used for the rest of the order
|
||||
val paymentNonce = pageNonce("$BASE/services/payment-v2?cartid=$cartId")
|
||||
val merchants = apiList("merchant", "form", paymentNonce, JSONObject().put("formId", formId))
|
||||
val bml = (0 until merchants.length()).map { merchants.getJSONObject(it) }
|
||||
.firstOrNull { it.optInt("gatewayId") == GATEWAY_BML }
|
||||
?: throw Exception("Dhiraagu isn't taking BML card payments right now")
|
||||
|
||||
val payment = api("payment", "create", paymentNonce, JSONObject()
|
||||
.put("formId", formId)
|
||||
.put("cartId", cartId)
|
||||
.put("gatewayId", GATEWAY_BML)
|
||||
.put("dhiraaguPayNumber", "")
|
||||
.put("amount", money(amount))
|
||||
.put("paymentMerchantId", bml.getString("merchantId"))
|
||||
.put("memberId", "").put("tokenize", "").put("paymentType", "")
|
||||
.put("recurringFrequency", "").put("bmlTokenId", ""))
|
||||
val paymentId = payment.optString("paymentId").ifBlank { throw Exception("Dhiraagu didn't create the payment") }
|
||||
|
||||
val txn = api("bml", "createV2", paymentNonce, JSONObject().put("paymentId", paymentId))
|
||||
return TXN_URL.find(txn.optString("url"))?.groupValues?.get(1)
|
||||
?: throw Exception("BML didn't create the transaction")
|
||||
}
|
||||
|
||||
private fun money(amount: BigDecimal) =
|
||||
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
|
||||
|
||||
/** The `val` list of a `setting` rule, e.g. `{"accountStatus":{"val":["F"]}}`. */
|
||||
private fun JSONObject.blockedValues(rule: String): Set<String> {
|
||||
val vals = optJSONObject(rule)?.optJSONArray("val") ?: return emptySet()
|
||||
return (0 until vals.length()).map { vals.optString(it) }.toSet()
|
||||
}
|
||||
|
||||
// ── HTTP ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every page embeds a `var nonce = "…"` that its API calls send as the `nonce` header. */
|
||||
private fun pageNonce(url: String): String =
|
||||
NONCE.find(page(url))?.groupValues?.get(1) ?: throw Exception("Dhiraagu page didn't load")
|
||||
|
||||
private fun page(url: String): String = client.newCall(
|
||||
Request.Builder().url(url)
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.build()
|
||||
).execute().use { r ->
|
||||
if (r.code in 500..599) throw BankServerException("Dhiraagu")
|
||||
r.body?.string().orEmpty()
|
||||
}
|
||||
|
||||
private fun api(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
|
||||
call(sub, act, nonce, body).getJSONObject("resp")
|
||||
|
||||
private fun apiList(sub: String, act: String, nonce: String, body: JSONObject): JSONArray =
|
||||
call(sub, act, nonce, body).getJSONArray("resp")
|
||||
|
||||
/** POSTs to `sdk-dhr-webapi.ashx`; throws unless `respStatus` is OK. */
|
||||
private fun call(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
|
||||
send(sub, act, nonce, body.toString().toRequestBody(JSON))
|
||||
|
||||
/** GETs from `sdk-dhr-webapi.ashx` (the settings calls); throws unless `respStatus` is OK. */
|
||||
private fun get(sub: String, act: String, nonce: String): JSONObject = send(sub, act, nonce, null)
|
||||
|
||||
private fun send(sub: String, act: String, nonce: String, body: okhttp3.RequestBody?): JSONObject {
|
||||
val text = client.newCall(
|
||||
Request.Builder().url("$API?website_id=$WEBSITE_ID&sub=$sub&act=$act")
|
||||
.apply { if (body != null) post(body) }
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Origin", BASE)
|
||||
.header("nonce", nonce)
|
||||
.build()
|
||||
).execute().use { r ->
|
||||
if (r.code in 500..599) throw BankServerException("Dhiraagu")
|
||||
r.body?.string().orEmpty()
|
||||
}
|
||||
val obj = try { JSONObject(text) } catch (_: Exception) {
|
||||
throw Exception("Unexpected response from Dhiraagu")
|
||||
}
|
||||
if (obj.optString("respStatus") != "OK") {
|
||||
throw Exception(obj.optString("respMsg").ifBlank { obj.optString("resp") }.ifBlank { "Dhiraagu refused the payment" })
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BASE = "https://www.dhiraagu.com.mv"
|
||||
private const val API = "$BASE/api/sdk-dhr-webapi.ashx"
|
||||
private const val WEBSITE_ID = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"
|
||||
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private val NONCE = Regex("""var nonce = "([^"]+)"""")
|
||||
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
|
||||
|
||||
/** Easy Pay's (bill payment) form id across cart / merchant / payment. */
|
||||
private const val FORM_BILL = 1
|
||||
/** Easy TopUp's form id across cart / merchant / payment. */
|
||||
private const val FORM_RELOAD = 2
|
||||
/** Bank of Maldives in `merchant&act=form` (1 = BML, 2 = MIB, 3 = DhiraaguPay). */
|
||||
private const val GATEWAY_BML = 1
|
||||
private const val GST_RATE = 0.08
|
||||
|
||||
/** Easy Pay's `billType`s; the lookup's `type` says which applies. */
|
||||
private const val BILL_PAYMENT = "BillPayment"
|
||||
private const val BILL_WRITE_OFF = "writeOffPayments"
|
||||
|
||||
/**
|
||||
* The GST inside a GST-inclusive reload [amount], as the page works it out:
|
||||
* `amount × rate / (1 + rate)`, to 2 places. The number is credited `amount − gst`.
|
||||
*/
|
||||
fun gstOf(amount: Int): BigDecimal {
|
||||
val rate = BigDecimal(GST_RATE.toString())
|
||||
return (BigDecimal(amount) * rate).divide(BigDecimal.ONE + rate, 2, RoundingMode.HALF_UP)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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,8 +4,11 @@ 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
|
||||
@@ -71,14 +74,14 @@ class FahipayLoginFlow {
|
||||
*/
|
||||
fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep {
|
||||
initSession()
|
||||
val body = FahipayForm.body(
|
||||
val body = buildFormBody(
|
||||
"email" to idCard,
|
||||
"password" to password,
|
||||
"grant_type" to "auth_id",
|
||||
"lang" to "en",
|
||||
"version" to "2.0.0",
|
||||
"platform" to "thijooree",
|
||||
*FahipayForm.deviceParts(deviceUuid)
|
||||
"platform" to "BasedBank",
|
||||
*deviceParts(deviceUuid)
|
||||
)
|
||||
|
||||
val resp = client.newCall(
|
||||
@@ -106,15 +109,15 @@ class FahipayLoginFlow {
|
||||
* Returns authId.
|
||||
*/
|
||||
fun verifyTotp(code: String, deviceUuid: String): String {
|
||||
val body = FahipayForm.body(
|
||||
val body = buildFormBody(
|
||||
"code" to code,
|
||||
"channel" to "totp",
|
||||
"action" to "login",
|
||||
"grant_type" to "auth_id",
|
||||
"lang" to "en",
|
||||
"version" to "2.0.0",
|
||||
"platform" to "thijooree",
|
||||
*FahipayForm.deviceParts(deviceUuid)
|
||||
"platform" to "BasedBank",
|
||||
*deviceParts(deviceUuid)
|
||||
)
|
||||
|
||||
val resp = client.newCall(
|
||||
@@ -135,6 +138,42 @@ 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)
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package sh.sar.basedbank.api.ooredoo
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Ooredoo prepaid recharge (Raastas) and bill payment through the ooredoo.mv Quick Pay pages,
|
||||
* paid by card on BML's merchant gateway. Ooredoo only creates the order; the money moves on the
|
||||
* BML Merchant Services transaction it hands back, which is paid like any card-only BML merchant
|
||||
* link. See `docs/ooredooapi/02-raastas.md` and `docs/ooredooapi/03-bill-pay.md`.
|
||||
*
|
||||
* Every call blocks, so run it on an IO thread.
|
||||
*/
|
||||
class OoredooPaymentClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Creates the recharge order for [number] (7 digits) and the BML transaction paying for it.
|
||||
* [amount] is what the number is credited, in whole MVR; the card pays [charged], which is
|
||||
* that plus GST. Returns the 24-hex BML transaction id. Throws with Ooredoo's wording when
|
||||
* the order is refused.
|
||||
*/
|
||||
fun createRaastasTransaction(number: String, amount: Int, charged: BigDecimal): String =
|
||||
createOrder(number, charged, amount.toString(), transType = "recharge", serviceType = "prepaid")
|
||||
|
||||
/**
|
||||
* Creates the bill payment order for postpaid [number] (7 digits) and the BML transaction
|
||||
* paying for it. [amount] is MVR, up to 2 decimal places; no GST. Returns the 24-hex BML
|
||||
* transaction id. Throws with Ooredoo's wording when the order is refused.
|
||||
*/
|
||||
fun createBillPayTransaction(number: String, amount: BigDecimal): String =
|
||||
createOrder(number, amount, money(amount), transType = "billpay", serviceType = "Mobile")
|
||||
|
||||
/** `POST PaymentGateway/bml` — one call makes the order and its BML transaction. */
|
||||
private fun createOrder(
|
||||
number: String, charged: BigDecimal, amountWithoutGst: String, transType: String, serviceType: String,
|
||||
): String {
|
||||
val msisdn = "960$number"
|
||||
val body = JSONObject()
|
||||
.put("msisdn", msisdn)
|
||||
.put("purchaseAmount", money(charged))
|
||||
.put("amountWithoutGst", amountWithoutGst)
|
||||
.put("receiverMsisdn", msisdn)
|
||||
.put("transType", transType)
|
||||
.put("serviceType", serviceType)
|
||||
.put("serviceTypeDisplayName", "Mobile")
|
||||
val text = client.newCall(
|
||||
Request.Builder().url("$BASE/ooredoo-prod/PaymentGateway/bml")
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "application/json")
|
||||
.header("Origin", BASE)
|
||||
.build()
|
||||
).execute().use { r ->
|
||||
if (r.code in 500..599) throw BankServerException("Ooredoo")
|
||||
r.body?.string().orEmpty()
|
||||
}
|
||||
val obj = try { JSONObject(text) } catch (_: Exception) {
|
||||
throw Exception("Unexpected response from Ooredoo")
|
||||
}
|
||||
if (obj.optString("status") != "OK" || obj.optString("code") != "2000") {
|
||||
throw Exception(obj.optString("msg").ifBlank { "Ooredoo refused the payment" })
|
||||
}
|
||||
val data = obj.optJSONObject("data") ?: throw Exception("Ooredoo didn't create the order")
|
||||
return TXN_URL.find(data.optString("bmlUrl"))?.groupValues?.get(1)
|
||||
?: throw Exception("BML didn't create the transaction")
|
||||
}
|
||||
|
||||
private fun money(amount: BigDecimal) =
|
||||
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
|
||||
|
||||
companion object {
|
||||
private const val BASE = "https://www.ooredoo.mv"
|
||||
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,6 @@ import sh.sar.basedbank.databinding.ItemPickerRowBinding
|
||||
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemTransferTypeBinding
|
||||
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
|
||||
import sh.sar.basedbank.ui.home.transfer.BmlVerifiedCards
|
||||
import sh.sar.basedbank.ui.home.transfer.CardPayoutService
|
||||
import sh.sar.basedbank.ui.home.transfer.CardPayoutTransferHandler
|
||||
import sh.sar.basedbank.ui.home.transfer.CarrierLookup
|
||||
import sh.sar.basedbank.ui.home.transfer.FahipayService
|
||||
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
|
||||
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
||||
@@ -162,12 +158,6 @@ class TransferFragment : Fragment() {
|
||||
private fun fahipayHandler(): FahipayTransferHandler =
|
||||
fahipayHandler ?: FahipayTransferHandler(this, binding, viewModel).also { fahipayHandler = it }
|
||||
|
||||
/** Lazy: created the first time a phone lookup offers carrier services by BML card. */
|
||||
private var cardPayoutHandler: CardPayoutTransferHandler? = null
|
||||
private fun cardPayoutHandler(): CardPayoutTransferHandler =
|
||||
cardPayoutHandler ?: CardPayoutTransferHandler(this, binding, viewModel) { bmlHandler() }
|
||||
.also { cardPayoutHandler = it }
|
||||
|
||||
/** Lazy: created the first time the user selects an MFAISA source account. */
|
||||
private var mfaisaHandler: MfaisaTransferHandler? = null
|
||||
private fun mfaisaHandler(): MfaisaTransferHandler =
|
||||
@@ -408,7 +398,7 @@ class TransferFragment : Fragment() {
|
||||
return@setFragmentResultListener
|
||||
}
|
||||
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
||||
if (applyServiceContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) {
|
||||
if (applyFahipayContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) {
|
||||
return@setFragmentResultListener
|
||||
}
|
||||
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
|
||||
@@ -454,7 +444,7 @@ class TransferFragment : Fragment() {
|
||||
// Pre-select contact if navigated from contacts page or QR scan
|
||||
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
||||
val name = arguments?.getString(ARG_NAME) ?: account
|
||||
if (applyServiceContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let
|
||||
if (applyFahipayContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let
|
||||
prefillToDirectly(
|
||||
accountNumber = account,
|
||||
displayName = name,
|
||||
@@ -635,7 +625,6 @@ class TransferFragment : Fragment() {
|
||||
draft.transferTypeNumber = ""
|
||||
draft.transferType = null
|
||||
fahipayHandler?.clearState()
|
||||
cardPayoutHandler?.clearState()
|
||||
transferTypeDialog?.dismiss()
|
||||
updateTransferButton()
|
||||
}
|
||||
@@ -659,12 +648,6 @@ class TransferFragment : Fragment() {
|
||||
// The default account when it can do Favara, otherwise leave the user to choose
|
||||
is TransferType.Favara -> accounts.firstOrNull { it.accountNumber == defaultNum && type.worksFrom(it) }
|
||||
is TransferType.Fahipay -> accounts.firstOrNull(type::worksFrom)
|
||||
// The default card when it can pay, otherwise any card that can
|
||||
is TransferType.Card -> {
|
||||
val defaultCard = CredentialStore(requireContext()).getDefaultCardAccountNumber()
|
||||
accounts.firstOrNull { it.accountNumber == defaultCard && type.worksFrom(it) }
|
||||
?: accounts.firstOrNull(type::worksFrom)
|
||||
}
|
||||
}
|
||||
if (pick != null) selectSourceAccount(pick)
|
||||
else {
|
||||
@@ -675,17 +658,9 @@ class TransferFragment : Fragment() {
|
||||
when (type) {
|
||||
is TransferType.Favara -> {
|
||||
fahipayHandler?.clearState()
|
||||
cardPayoutHandler?.clearState()
|
||||
showFavaraRecipient(type.info)
|
||||
}
|
||||
is TransferType.Fahipay -> {
|
||||
cardPayoutHandler?.clearState()
|
||||
fahipayHandler().applyService(type, number)
|
||||
}
|
||||
is TransferType.Card -> {
|
||||
fahipayHandler?.clearState()
|
||||
cardPayoutHandler().applyService(type, number)
|
||||
}
|
||||
is TransferType.Fahipay -> fahipayHandler().applyService(type, number)
|
||||
}
|
||||
updateTransferButton()
|
||||
}
|
||||
@@ -1031,7 +1006,6 @@ class TransferFragment : Fragment() {
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
fahipayHandler?.clearState()
|
||||
cardPayoutHandler?.clearState()
|
||||
if (!keepTransferTypes) resetTransferTypes()
|
||||
mfaisaHandler?.clearState()
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
@@ -1126,7 +1100,7 @@ class TransferFragment : Fragment() {
|
||||
|
||||
binding.etTo.setOnItemClickListener { _, _, position, _ ->
|
||||
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
|
||||
if (applyServiceContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
|
||||
if (applyFahipayContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
|
||||
return@setOnItemClickListener
|
||||
}
|
||||
prefillToDirectly(
|
||||
@@ -1142,27 +1116,15 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved Fahipay favourite, or a recent paid with a Fahipay or card service: its category
|
||||
* ([categoryId]) already says which service pays it, so that service is applied as the only
|
||||
* transfer type, with no carrier lookup. That switches the source to the Fahipay wallet or
|
||||
* the default card and brings in the service's amount rules, the same as a searched number.
|
||||
* Returns false, doing nothing, when [categoryId] isn't one of those.
|
||||
* A saved Fahipay favourite: its list ([categoryId]) already says which service pays it, so
|
||||
* that service is applied as the only transfer type, with no carrier lookup. That switches
|
||||
* the source to the Fahipay wallet and brings in the service's amount rules, the same as a
|
||||
* searched number. Returns false, doing nothing, when [categoryId] isn't a Fahipay list.
|
||||
*/
|
||||
private fun applyServiceContact(number: String, categoryId: String?, name: String): Boolean {
|
||||
val ownerName = name.takeIf { it.isNotBlank() && it != number }
|
||||
val type = FahipayService.fromContactCategory(categoryId)?.let { TransferType.Fahipay(it, ownerName) }
|
||||
?: CardPayoutService.fromContactCategory(categoryId)?.let { service ->
|
||||
// The cards that could pay it then may not be payable now
|
||||
val cards = cardPayoutHandler().payableCards()
|
||||
if (cards.isEmpty()) {
|
||||
Toast.makeText(requireContext(), R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||
return true
|
||||
}
|
||||
TransferType.Card(service, ownerName, cards)
|
||||
}
|
||||
?: return false
|
||||
private fun applyFahipayContact(number: String, categoryId: String?, name: String): Boolean {
|
||||
val service = FahipayService.fromContactCategory(categoryId) ?: return false
|
||||
clearRecipient()
|
||||
offerTransferTypes(number, listOf(type))
|
||||
offerTransferTypes(number, listOf(TransferType.Fahipay(service, name.takeIf { it.isNotBlank() && it != number })))
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1183,12 +1145,6 @@ class TransferFragment : Fragment() {
|
||||
lookupPhoneForAnySource(accountNumber)
|
||||
return
|
||||
}
|
||||
// A card that can pay by card: the number may be a carrier service it can pay too
|
||||
if (selectedAccount?.let { BmlVerifiedCards.isPayable(requireContext(), it) } == true &&
|
||||
AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
|
||||
lookupPhoneForAnySource(accountNumber)
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedAccount == null) {
|
||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
@@ -1250,17 +1206,15 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A phone number searched before any source is picked (or from a card that can pay by
|
||||
* card). Runs the Favara lookup (through MIB or BML, whichever is logged in) and, when the
|
||||
* user has a Fahipay wallet or a card that can pay by card, the Dhiraagu / Ooredoo carrier
|
||||
* lookup — in parallel — and offers whatever came back as transfer types.
|
||||
* A phone number searched before any source is picked. Runs the Favara lookup (through MIB
|
||||
* or BML, whichever is logged in) and, when the user has a Fahipay wallet, the Dhiraagu /
|
||||
* Ooredoo carrier lookup — in parallel — and offers whatever came back as transfer types.
|
||||
*/
|
||||
private fun lookupPhoneForAnySource(number: String) {
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val hasFahipay = accounts.any { it.bank == "FAHIPAY" }
|
||||
val payableCards = cardPayoutHandler().payableCards()
|
||||
val hasFavara = mibHandler.session != null || bmlSessionFor(null) != null
|
||||
if (!hasFavara && !hasFahipay && payableCards.isEmpty()) {
|
||||
if (!hasFavara && !hasFahipay) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
@@ -1268,22 +1222,19 @@ class TransferFragment : Fragment() {
|
||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
val preferBml = accounts.firstOrNull { it.accountNumber == defaultNum }?.bank == "BML"
|
||||
val fahipay = fahipayHandler()
|
||||
val cardPayout = cardPayoutHandler()
|
||||
|
||||
resetTransferTypes()
|
||||
startLookupLoading()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val (favara, carriers) = withContext(Dispatchers.IO) {
|
||||
val (favara, fahipayTypes) = withContext(Dispatchers.IO) {
|
||||
coroutineScope {
|
||||
val favara = async { if (hasFavara) lookupFavara(number, preferBml) else null to null }
|
||||
val carriers = async { if (hasFahipay || payableCards.isNotEmpty()) CarrierLookup.query(number) else null }
|
||||
favara.await() to carriers.await()
|
||||
val fahipayTypes = async { if (hasFahipay) fahipay.lookupServices(number) else emptyList() }
|
||||
favara.await() to fahipayTypes.await()
|
||||
}
|
||||
}
|
||||
stopLookupLoading()
|
||||
val fahipayTypes = carriers?.takeIf { hasFahipay }?.let(fahipay::typesFor).orEmpty()
|
||||
val cardTypes = carriers?.let { cardPayout.typesFor(it, payableCards) }.orEmpty()
|
||||
val types = listOfNotNull(favara.first?.let { TransferType.Favara(it) }) + fahipayTypes + cardTypes
|
||||
val types = listOfNotNull(favara.first?.let { TransferType.Favara(it) }) + fahipayTypes
|
||||
if (types.isEmpty()) {
|
||||
Toast.makeText(requireContext(), favara.second ?: getString(R.string.transfer_account_not_found), Toast.LENGTH_SHORT).show()
|
||||
return@launch
|
||||
@@ -1530,19 +1481,6 @@ class TransferFragment : Fragment() {
|
||||
return
|
||||
}
|
||||
|
||||
// Fahipay source: reload / Raastas / bill pay to the picked service
|
||||
if (selectedAccount?.bank == "FAHIPAY") {
|
||||
fahipayHandler().submit()
|
||||
return
|
||||
}
|
||||
|
||||
// Carrier service by BML card: the carrier creates the BML transaction, then it's paid
|
||||
// like a card-only merchant link
|
||||
if (draft.cardPayoutService != null) {
|
||||
cardPayoutHandler().submit()
|
||||
return
|
||||
}
|
||||
|
||||
// BML QR merchant payment — uses shared confirm dialog, no receipt
|
||||
if (bmlHandler().hasQrMerchant) {
|
||||
bmlHandler().submitQrPayment()
|
||||
@@ -1835,23 +1773,6 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A dialog showing only the processing spinner, for work that has to finish before the
|
||||
* confirm dialog can be shown (e.g. a carrier creating the payment). Dismiss it when done.
|
||||
*/
|
||||
internal fun showProcessingDialog(title: String): AlertDialog {
|
||||
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||
imm.hideSoftInputFromWindow(requireView().windowToken, 0)
|
||||
val frame = android.widget.FrameLayout(requireContext())
|
||||
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(title)
|
||||
.setView(frame)
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
showProcessingInDialog(dialog, frame)
|
||||
return dialog
|
||||
}
|
||||
|
||||
internal fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
||||
@@ -2002,12 +1923,10 @@ class TransferFragment : Fragment() {
|
||||
// draft — build it so the amount rules still apply after the view is recreated.
|
||||
val fahipay = if (draft.fahipayService != null) fahipayHandler() else null
|
||||
fahipay?.syncAmountField()
|
||||
val cardPayout = if (draft.cardPayoutService != null) cardPayoutHandler() else null
|
||||
cardPayout?.syncAmountField()
|
||||
if (bmlHandler().isOtpFlowActive) return
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
|
||||
val amountOk = amount > 0 && fahipay?.amountProblem == null && cardPayout?.amountProblem == null
|
||||
val amountOk = amount > 0 && fahipay?.amountProblem == null
|
||||
val hasAll = selectedAccount != null && recipientReady && amountOk && !transferTypePending
|
||||
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||
@@ -2138,7 +2057,6 @@ class TransferFragment : Fragment() {
|
||||
bmlHandler?.clearState()
|
||||
bmlHandler = null
|
||||
fahipayHandler = null
|
||||
cardPayoutHandler = null
|
||||
mfaisaHandler = null
|
||||
// Re-shown from the draft by the next view if still unanswered
|
||||
transferTypeDialog?.setOnDismissListener(null)
|
||||
|
||||
@@ -425,8 +425,13 @@ class BmlTransferHandler(
|
||||
private val cardMerchant get() = draft.bmlCardMerchant
|
||||
|
||||
/** A verified BML card we also hold a login (OTP seed) for — can go through the 3-D Secure step. */
|
||||
private fun verifiedCardCandidates(): List<BankAccount> =
|
||||
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
|
||||
private fun verifiedCardCandidates(): List<BankAccount> {
|
||||
val store = CredentialStore(ctx)
|
||||
val verifiedKeys = sh.sar.basedbank.util.VerifiedCardStore.keys(ctx)
|
||||
return (viewModel.accounts.value ?: emptyList())
|
||||
.filter { isCard(it) && verifiedKeys.contains("bml:${it.accountNumber}") }
|
||||
.filter { store.loadBmlCredentials(it.loginTag.removePrefix("bml_"))?.otpSeed != null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as
|
||||
@@ -455,7 +460,9 @@ class BmlTransferHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCardVerified(account: BankAccount): Boolean = BmlVerifiedCards.isPayable(ctx, account)
|
||||
private fun isCardVerified(account: BankAccount): Boolean =
|
||||
isCard(account) && sh.sar.basedbank.util.VerifiedCardStore.isVerified(ctx, "bml:${account.accountNumber}") &&
|
||||
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed != null
|
||||
|
||||
/** Paints the loaded card-only merchant into the "To" card and locks the amount. */
|
||||
fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
|
||||
@@ -496,12 +503,7 @@ class BmlTransferHandler(
|
||||
confirmCardMerchant(page, src)
|
||||
}
|
||||
|
||||
/**
|
||||
* The card payment's confirm dialog (biometric-gated), then the Pomelo + 3-D Secure payment of
|
||||
* [page] with [src]. Also the send step for carrier services paid by card
|
||||
* ([CardPayoutTransferHandler]), once the carrier has created the BML transaction.
|
||||
*/
|
||||
fun confirmCardMerchant(
|
||||
private fun confirmCardMerchant(
|
||||
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
|
||||
src: BankAccount
|
||||
) {
|
||||
@@ -541,45 +543,42 @@ class BmlTransferHandler(
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout
|
||||
) {
|
||||
val (card, otpSeed) = BmlVerifiedCards.load(ctx, src) ?: run {
|
||||
val stored = sh.sar.basedbank.util.VerifiedCardStore.load(ctx, "bml:${src.accountNumber}")
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val otpSeed = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||
val expiry = stored?.expiry?.split("/") // "MM/YY"
|
||||
if (stored == null || otpSeed == null || expiry?.size != 2) {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
val card = sh.sar.basedbank.api.bml.BmlMerchantCardPayClient.Card(
|
||||
pan = stored.pan,
|
||||
expiryMonth = expiry[0].padStart(2, '0'),
|
||||
expiryYear = expiry[1].takeLast(2),
|
||||
cvv = stored.cvv,
|
||||
holderName = src.accountBriefName
|
||||
)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
BmlMerchantCardPayClient().pay(page, card) { retry ->
|
||||
// A code about to roll over can expire before the ACS checks it, and a
|
||||
// retry in the same window would resend the rejected code: wait for the
|
||||
// next window in both cases. Runs on IO.
|
||||
val left = TOTP_WINDOW_MS - System.currentTimeMillis() % TOTP_WINDOW_MS
|
||||
if (retry || left < TOTP_MIN_LEFT_MS) Thread.sleep(left + 500)
|
||||
Totp.generate(otpSeed)
|
||||
}
|
||||
BmlMerchantCardPayClient().pay(page, card) { _ -> Totp.generate(otpSeed) }
|
||||
}.getOrElse {
|
||||
BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed")
|
||||
}
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
when (result) {
|
||||
is BmlMerchantCardPayClient.Result.Success -> {
|
||||
fragment.showSuccessInDialog(
|
||||
dialog, frame,
|
||||
amountCurrency = page.currency,
|
||||
amountValue = "%.2f".format(page.amount),
|
||||
fromName = src.accountBriefName,
|
||||
toName = page.merchantName
|
||||
) {
|
||||
fragment.clearForm()
|
||||
host?.triggerRefresh()
|
||||
}
|
||||
// Charged, but the merchant may not deliver until it's told
|
||||
if (!result.merchantNotified) {
|
||||
Toast.makeText(ctx, ctx.getString(R.string.bml_card_pay_merchant_not_notified,
|
||||
page.merchantName, page.transactionId), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
is BmlMerchantCardPayClient.Result.Success -> fragment.showSuccessInDialog(
|
||||
dialog, frame,
|
||||
amountCurrency = page.currency,
|
||||
amountValue = "%.2f".format(page.amount),
|
||||
fromName = src.accountBriefName,
|
||||
toName = page.merchantName
|
||||
) {
|
||||
fragment.clearForm()
|
||||
host?.triggerRefresh()
|
||||
}
|
||||
is BmlMerchantCardPayClient.Result.Failure -> {
|
||||
dialog.dismiss()
|
||||
@@ -963,12 +962,6 @@ class BmlTransferHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account)
|
||||
|
||||
private companion object {
|
||||
/** The BML token's TOTP window. */
|
||||
const val TOTP_WINDOW_MS = 30_000L
|
||||
/** Don't send a card payment's 3-D Secure code with less than this left in its window. */
|
||||
const val TOTP_MIN_LEFT_MS = 5_000L
|
||||
}
|
||||
private fun isCard(account: BankAccount) =
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.content.Context
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.VerifiedCardStore
|
||||
|
||||
/**
|
||||
* BML cards that can pay a merchant by card + 3-D Secure: the card is verified (full details in
|
||||
* [VerifiedCardStore]) and belongs to a BML login we hold the OTP seed for, since the 3-D Secure
|
||||
* step is answered with that login's token code.
|
||||
*/
|
||||
object BmlVerifiedCards {
|
||||
|
||||
/** A payable card's details, ready for [BmlMerchantCardPayClient.pay]. */
|
||||
data class Payable(val card: BmlMerchantCardPayClient.Card, val otpSeed: String)
|
||||
|
||||
fun isCard(account: BankAccount) =
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||
|
||||
fun isPayable(ctx: Context, account: BankAccount): Boolean =
|
||||
isCard(account) && VerifiedCardStore.isVerified(ctx, key(account)) && otpSeed(ctx, account) != null
|
||||
|
||||
fun payable(ctx: Context, accounts: List<BankAccount>): List<BankAccount> {
|
||||
val verified = VerifiedCardStore.keys(ctx)
|
||||
return accounts.filter { isCard(it) && key(it) in verified && otpSeed(ctx, it) != null }
|
||||
}
|
||||
|
||||
/** The stored card details and OTP seed for [account], or null when it isn't payable. */
|
||||
fun load(ctx: Context, account: BankAccount): Payable? {
|
||||
val stored = VerifiedCardStore.load(ctx, key(account)) ?: return null
|
||||
val seed = otpSeed(ctx, account) ?: return null
|
||||
val expiry = stored.expiry.split("/") // "MM/YY"
|
||||
if (expiry.size != 2) return null
|
||||
return Payable(
|
||||
card = BmlMerchantCardPayClient.Card(
|
||||
pan = stored.pan,
|
||||
expiryMonth = expiry[0].padStart(2, '0'),
|
||||
expiryYear = expiry[1].takeLast(2),
|
||||
cvv = stored.cvv,
|
||||
holderName = account.accountBriefName
|
||||
),
|
||||
otpSeed = seed
|
||||
)
|
||||
}
|
||||
|
||||
private fun key(account: BankAccount) = "bml:${account.accountNumber}"
|
||||
|
||||
private fun otpSeed(ctx: Context, account: BankAccount) =
|
||||
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguPaymentClient
|
||||
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||
import sh.sar.basedbank.api.ooredoo.OoredooPaymentClient
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferFragment
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* A carrier service a verified BML card can pay, through the carrier's own website and its BML
|
||||
* merchant gateway. The limits are the carrier website's, not Fahipay's.
|
||||
*
|
||||
* Add new services as constants; the exhaustive `when`s over this enum point at every site that
|
||||
* needs updating.
|
||||
*/
|
||||
enum class CardPayoutService(
|
||||
override val label: String,
|
||||
override val destinationLabel: String,
|
||||
@param:DrawableRes override val iconRes: Int,
|
||||
override val minAmount: Int,
|
||||
override val maxAmount: Int?,
|
||||
override val decimalsAllowed: Boolean,
|
||||
override val gstPercent: Int?,
|
||||
/**
|
||||
* Tags the recents paid with this service (`RecentPick.contactCategory`), so picking one
|
||||
* again pays the same way. Not a real contact list: card payouts have no favourites.
|
||||
*/
|
||||
val contactCategory: String,
|
||||
override val gstAdded: Boolean = false,
|
||||
) : PayoutService {
|
||||
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
|
||||
minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8,
|
||||
contactCategory = "CARD_DHIRAAGU_RELOAD") {
|
||||
// Dhiraagu rounds the GST to 2 places and credits the rest
|
||||
override fun creditedAfterGst(amount: BigDecimal): BigDecimal =
|
||||
amount - DhiraaguPaymentClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt())
|
||||
},
|
||||
// Easy Pay sets no limits of its own: any amount with up to 2 decimals, no GST
|
||||
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
|
||||
minAmount = 1, maxAmount = null, decimalsAllowed = true, gstPercent = null,
|
||||
contactCategory = "CARD_DHIRAAGU_BILL"),
|
||||
// Ooredoo credits the whole amount and charges the card 8% GST on top
|
||||
OOREDOO_RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
|
||||
minAmount = 20, maxAmount = null, decimalsAllowed = false, gstPercent = 8,
|
||||
contactCategory = "CARD_RAASTAS", gstAdded = true),
|
||||
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
|
||||
minAmount = 10, maxAmount = null, decimalsAllowed = true, gstPercent = null,
|
||||
contactCategory = "CARD_OOREDOO_BILL");
|
||||
|
||||
companion object {
|
||||
/** The service a recent was paid with, from its [contactCategory], or null when it isn't one. */
|
||||
fun fromContactCategory(categoryId: String?): CardPayoutService? =
|
||||
entries.firstOrNull { it.contactCategory == categoryId }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the Transfer screen's "carrier service by BML card" parts: which services a looked-up
|
||||
* number can be paid with by card, and the picked service's amount rules.
|
||||
*
|
||||
* Sending only differs from paying a card-only BML merchant link in where the BML transaction
|
||||
* comes from: the carrier's website creates it for the number and amount. From there it goes
|
||||
* through [BmlTransferHandler.confirmCardMerchant] — the same confirm dialog, card + 3-D Secure
|
||||
* payment and result as a pasted link.
|
||||
*
|
||||
* Mirrors [FahipayTransferHandler]: the fragment keeps the recipient card and the form state.
|
||||
* Lifetime is bound to the fragment's view.
|
||||
*/
|
||||
class CardPayoutTransferHandler(
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
private val bmlHandler: () -> BmlTransferHandler,
|
||||
) {
|
||||
|
||||
private val ctx get() = fragment.requireContext()
|
||||
private val amountField = PayoutAmountField(binding) { ctx }
|
||||
|
||||
/** The service picked for the current recipient; null when none is. */
|
||||
var service: CardPayoutService?
|
||||
get() = viewModel.transferDraft.cardPayoutService
|
||||
private set(value) { viewModel.transferDraft.cardPayoutService = value }
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/** Account numbers of the cards that can pay by card. Call on the main thread. */
|
||||
fun payableCards(): Set<String> =
|
||||
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
|
||||
.map { it.accountNumber }.toSet()
|
||||
|
||||
/** The card transfer types a carrier lookup [result] allows, payable from [cards]. */
|
||||
fun typesFor(result: CarrierLookup.Result, cards: Set<String>): List<TransferType.Card> {
|
||||
if (cards.isEmpty()) return emptyList()
|
||||
return buildList {
|
||||
when (result.dhiraagu.type) {
|
||||
DhiraaguClient.CustType.RELOAD -> add(CardPayoutService.DHIRAAGU_RELOAD)
|
||||
DhiraaguClient.CustType.BILL_PAY -> add(CardPayoutService.DHIRAAGU_BILL)
|
||||
DhiraaguClient.CustType.UNSUPPORTED -> {}
|
||||
}
|
||||
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) {
|
||||
add(CardPayoutService.OOREDOO_RAASTAS)
|
||||
}
|
||||
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) {
|
||||
add(CardPayoutService.OOREDOO_BILL)
|
||||
}
|
||||
}.map { TransferType.Card(it, result.ownerName, cards) }
|
||||
}
|
||||
|
||||
/** Forgets the picked service and gives back the amount and reference fields. */
|
||||
fun clearState() {
|
||||
if (service == null) return
|
||||
service = null
|
||||
amountField.reset()
|
||||
}
|
||||
|
||||
/** Why the typed amount can't be sent with the picked service, or null when it can. */
|
||||
val amountProblem: String?
|
||||
get() = service?.let(amountField::problem)
|
||||
|
||||
/** Keeps the amount and reference fields in step with the picked service. */
|
||||
fun syncAmountField() {
|
||||
service?.let(amountField::sync)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the recipient card for a picked card transfer type. The fragment has already
|
||||
* switched the source to one of the type's cards.
|
||||
*/
|
||||
fun applyService(type: TransferType.Card, number: String) {
|
||||
service = type.service
|
||||
// None of the payouts take a reference; syncAmountField() disables the box
|
||||
binding.etRemarks.setText("")
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
val displayName = type.ownerName
|
||||
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
||||
?: number
|
||||
fragment.prefillToDirectly(
|
||||
accountNumber = number,
|
||||
displayName = displayName,
|
||||
subtitle = "${type.label} · $number",
|
||||
colorHex = "#E4002B",
|
||||
imageHash = null,
|
||||
contactCategory = type.service.contactCategory
|
||||
)
|
||||
fragment.focusAmount()
|
||||
}
|
||||
|
||||
// ─── Send ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Has the carrier create the BML transaction for the number and amount, then hands its
|
||||
* payment page to the card merchant flow. Nothing is charged until that flow's confirm.
|
||||
*/
|
||||
fun submit() {
|
||||
val svc = service ?: return
|
||||
val src = viewModel.transferDraft.selectedAccount
|
||||
if (src == null || !BmlVerifiedCards.isPayable(ctx, src)) {
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).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
|
||||
val charged = svc.chargedWithGst(amount)
|
||||
|
||||
// Creating the order takes a few round trips; show the payment's processing box meanwhile
|
||||
val processing = fragment.showProcessingDialog(ctx.getString(R.string.transfer))
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val page = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val txnId = when (svc) {
|
||||
CardPayoutService.DHIRAAGU_RELOAD ->
|
||||
DhiraaguPaymentClient().createReloadTransaction(number, amount.intValueExact())
|
||||
CardPayoutService.DHIRAAGU_BILL ->
|
||||
DhiraaguPaymentClient().createBillPayTransaction(number, amount)
|
||||
CardPayoutService.OOREDOO_RAASTAS ->
|
||||
OoredooPaymentClient().createRaastasTransaction(number, amount.intValueExact(), charged)
|
||||
CardPayoutService.OOREDOO_BILL ->
|
||||
OoredooPaymentClient().createBillPayTransaction(number, amount)
|
||||
}
|
||||
BmlMerchantTxnClient().fetchPayPage(txnId)
|
||||
}
|
||||
}
|
||||
processing.dismiss()
|
||||
if (fragment.view == null) return@launch
|
||||
page.onSuccess {
|
||||
when {
|
||||
!it.supportsCard ->
|
||||
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
|
||||
// The carrier's order must be for exactly what was typed (plus GST, if added)
|
||||
it.amount.toBigDecimal().compareTo(charged) != 0 ->
|
||||
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
|
||||
else -> bmlHandler().confirmCardMerchant(it, src)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
val msg = when {
|
||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> ctx.getString(R.string.transfer_bml_txn_lookup_failed)
|
||||
}
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||
|
||||
/**
|
||||
* Which carrier a phone number is on, and how it's billed. Feeds both payout routes: the
|
||||
* Fahipay services and the BML card services each map this to what they can pay.
|
||||
*/
|
||||
object CarrierLookup {
|
||||
|
||||
data class Result(
|
||||
val dhiraagu: DhiraaguClient.Result,
|
||||
val ooredoo: OoredooClient.CustType,
|
||||
) {
|
||||
/** Dhiraagu is the only carrier that hands back an owner name. */
|
||||
val ownerName: String? get() = dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the likelier carrier first based on the leading digit and only falls back to the
|
||||
* other when the first says it doesn't know the number. Blocking — call from IO.
|
||||
*/
|
||||
fun query(number: String): Result =
|
||||
if (number.startsWith("7")) {
|
||||
// Dhiraagu first, fall back to Ooredoo
|
||||
val d = dhiraagu(number)
|
||||
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
|
||||
else OoredooClient.CustType.UNSUPPORTED
|
||||
Result(d, o)
|
||||
} else {
|
||||
// Ooredoo first, fall back to Dhiraagu
|
||||
val o = ooredoo(number)
|
||||
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
|
||||
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
|
||||
Result(d, o)
|
||||
}
|
||||
|
||||
private fun dhiraagu(number: String) =
|
||||
try { DhiraaguClient().validateNumber(number) }
|
||||
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
|
||||
|
||||
private fun ooredoo(number: String) =
|
||||
try { OoredooClient().validateNumber(number) }
|
||||
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
|
||||
}
|
||||
@@ -1,63 +1,58 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.widget.Toast
|
||||
import android.text.InputType
|
||||
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
|
||||
|
||||
/**
|
||||
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
|
||||
* a given number. The limits are Fahipay's — the same carrier service paid by card
|
||||
* ([CardPayoutService]) has its own.
|
||||
* 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. 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.
|
||||
* 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.
|
||||
*/
|
||||
enum class FahipayService(
|
||||
override val label: String,
|
||||
override val destinationLabel: String,
|
||||
@param:DrawableRes override val iconRes: Int,
|
||||
/** The endpoint [FahipayPaymentClient.pay] POSTs this service's payments to. */
|
||||
val paymentPath: String,
|
||||
override val minAmount: Int,
|
||||
override val maxAmount: Int?,
|
||||
override val decimalsAllowed: Boolean,
|
||||
val label: String,
|
||||
val destinationLabel: String,
|
||||
@param:DrawableRes val iconRes: Int,
|
||||
/** Smallest amount the service accepts, in MVR. */
|
||||
val minAmount: Int,
|
||||
/** Largest amount the service accepts, in MVR, or null for no limit. */
|
||||
val maxAmount: Int?,
|
||||
/** Whether the amount may have a fractional part (up to 2 decimal places). */
|
||||
val decimalsAllowed: Boolean,
|
||||
/**
|
||||
* The contact category of this service's Fahipay favourites list (`BankContact.benefCategoryId`,
|
||||
* set by `FahipayContactsClient`). Recents of this service are tagged with it too.
|
||||
*/
|
||||
val contactCategory: String,
|
||||
override val gstPercent: Int? = null,
|
||||
) : PayoutService {
|
||||
/** GST the carrier takes out of the amount before crediting it, in percent, or null for none. */
|
||||
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");
|
||||
|
||||
@@ -79,8 +74,10 @@ 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.
|
||||
*
|
||||
* [submit] sends the payment through [FahipayPaymentClient], with the shared confirm dialog
|
||||
* and an in-dialog success screen (no receipt page yet).
|
||||
* **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.
|
||||
*
|
||||
* 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.
|
||||
@@ -98,8 +95,6 @@ class FahipayTransferHandler(
|
||||
get() = viewModel.transferDraft.fahipayService
|
||||
private set(value) { viewModel.transferDraft.fahipayService = value }
|
||||
|
||||
private val amountField = PayoutAmountField(binding) { ctx }
|
||||
|
||||
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
||||
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||
|
||||
@@ -124,22 +119,64 @@ class FahipayTransferHandler(
|
||||
fun clearState() {
|
||||
if (service == null) return
|
||||
service = null
|
||||
amountField.reset()
|
||||
binding.tilAmount.error = null
|
||||
binding.tilAmount.helperText = null
|
||||
binding.etAmount.inputType = DECIMAL_INPUT
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the typed amount can't be sent with the selected service, or null when it can (or the
|
||||
* field is empty, or no service is selected).
|
||||
* field is empty, or no service is selected). Checks the service's minimum, maximum and
|
||||
* whether it takes decimals.
|
||||
*/
|
||||
val amountProblem: String?
|
||||
get() = service?.let(amountField::problem)
|
||||
get() {
|
||||
val svc = service ?: return null
|
||||
val text = binding.etAmount.text?.toString()?.trim().orEmpty()
|
||||
if (text.isEmpty()) return null
|
||||
val amount = text.toBigDecimalOrNull() ?: return ctx.getString(R.string.transfer_fahipay_amount_invalid)
|
||||
val fraction = amount.stripTrailingZeros().scale()
|
||||
return when {
|
||||
!svc.decimalsAllowed && fraction > 0 -> ctx.getString(R.string.transfer_fahipay_amount_whole)
|
||||
fraction > 2 -> ctx.getString(R.string.transfer_fahipay_amount_decimals)
|
||||
amount < BigDecimal(svc.minAmount) -> ctx.getString(R.string.transfer_fahipay_amount_min, svc.minAmount)
|
||||
svc.maxAmount != null && amount > BigDecimal(svc.maxAmount) ->
|
||||
ctx.getString(R.string.transfer_fahipay_amount_max, "%,d".format(svc.maxAmount))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the amount and reference fields in step with the selected service. A no-op when no
|
||||
* service is selected; [clearState] puts the fields back.
|
||||
* Keeps the amount and reference fields in step with the selected service: the amount
|
||||
* error, the GST note under the amount, and the reference box disabled (none of the
|
||||
* payouts take one). Idempotent — the fragment calls it on every form change. A no-op
|
||||
* when no service is selected; [clearState] puts the fields back.
|
||||
*/
|
||||
fun syncAmountField() {
|
||||
service?.let(amountField::sync)
|
||||
val svc = service ?: return
|
||||
// Whole-number services get a keypad without a decimal point. Only set on change:
|
||||
// setting inputType restarts the keyboard, and this runs on every keystroke.
|
||||
val inputType = if (svc.decimalsAllowed) DECIMAL_INPUT else InputType.TYPE_CLASS_NUMBER
|
||||
if (binding.etAmount.inputType != inputType) binding.etAmount.inputType = inputType
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.tilRemarks.alpha = 0.4f
|
||||
val problem = amountProblem
|
||||
binding.tilAmount.error = problem
|
||||
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
|
||||
}
|
||||
|
||||
/**
|
||||
* What the recipient is credited once GST comes out, for services that charge it. The
|
||||
* amount paid is GST-inclusive, so the credit is amount / (1 + rate), rounded down.
|
||||
*/
|
||||
private fun gstNote(svc: FahipayService): String? {
|
||||
val gst = svc.gstPercent ?: return null
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
|
||||
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
|
||||
val credited = amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
|
||||
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,118 +202,70 @@ class FahipayTransferHandler(
|
||||
fragment.focusAmount()
|
||||
}
|
||||
|
||||
|
||||
/** The Fahipay transfer types a carrier lookup [result] allows. */
|
||||
fun typesFor(result: CarrierLookup.Result): List<TransferType.Fahipay> =
|
||||
servicesFor(result).map { TransferType.Fahipay(it, result.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.
|
||||
* The Fahipay transfer types [number] can be paid with, from the carrier lookup. Dhiraagu is
|
||||
* the only carrier that hands back an owner name. Blocking — call from IO.
|
||||
*/
|
||||
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(amountField.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()
|
||||
}
|
||||
}
|
||||
fun lookupServices(number: String): List<TransferType.Fahipay> {
|
||||
val result = queryCarriers(number)
|
||||
val ownerName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
||||
return servicesFor(result).map { TransferType.Fahipay(it, ownerName) }
|
||||
}
|
||||
|
||||
// ─── Carrier lookup ──────────────────────────────────────────────────────
|
||||
|
||||
private data class CarrierResult(
|
||||
val dhiraagu: DhiraaguClient.Result,
|
||||
val ooredoo: OoredooClient.CustType
|
||||
)
|
||||
|
||||
private fun lookupCarrier(number: String) {
|
||||
fragment.resetTransferTypes()
|
||||
fragment.startLookupLoading()
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val types = withContext(Dispatchers.IO) { typesFor(CarrierLookup.query(number)) }
|
||||
val types = withContext(Dispatchers.IO) { lookupServices(number) }
|
||||
fragment.stopLookupLoading()
|
||||
if (types.isEmpty()) return@launch
|
||||
fragment.offerTransferTypes(number, types)
|
||||
}
|
||||
}
|
||||
|
||||
private fun servicesFor(result: CarrierLookup.Result): List<FahipayService> = buildList {
|
||||
/**
|
||||
* Asks the likelier carrier first based on the leading digit and only falls back to the
|
||||
* other when the first says it doesn't know the number. Blocking — call from IO.
|
||||
*/
|
||||
private fun queryCarriers(number: String): CarrierResult =
|
||||
if (number.startsWith("7")) {
|
||||
// Dhiraagu first, fall back to Ooredoo
|
||||
val d = dhiraagu(number)
|
||||
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
|
||||
else OoredooClient.CustType.UNSUPPORTED
|
||||
CarrierResult(d, o)
|
||||
} else {
|
||||
// Ooredoo first, fall back to Dhiraagu
|
||||
val o = ooredoo(number)
|
||||
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
|
||||
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
|
||||
CarrierResult(d, o)
|
||||
}
|
||||
|
||||
private fun dhiraagu(number: String) =
|
||||
try { DhiraaguClient().validateNumber(number) }
|
||||
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
|
||||
|
||||
private fun ooredoo(number: String) =
|
||||
try { OoredooClient().validateNumber(number) }
|
||||
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
|
||||
|
||||
private fun servicesFor(result: CarrierResult): List<FahipayService> = buildList {
|
||||
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
|
||||
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
|
||||
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
|
||||
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
|
||||
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.content.Context
|
||||
import android.text.InputType
|
||||
import androidx.annotation.DrawableRes
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* A carrier service a phone number can be paid with (reload, Raastas, bill pay), whichever
|
||||
* route pays it — the Fahipay wallet ([FahipayService]) or a verified BML card
|
||||
* ([CardPayoutService]). Each route has its own limits, so each has its own constants.
|
||||
*/
|
||||
interface PayoutService {
|
||||
/** Names it in the "Transfer Type" picker and the recipient card. */
|
||||
val label: String
|
||||
/** Names it in the confirm dialog's "To" block, e.g. "Ooredoo · Raastas". */
|
||||
val destinationLabel: String
|
||||
@get:DrawableRes val iconRes: Int
|
||||
/** Smallest amount the service accepts, in MVR. */
|
||||
val minAmount: Int
|
||||
/** Largest amount the service accepts, in MVR, or null for no limit. */
|
||||
val maxAmount: Int?
|
||||
/** Whether the amount may have a fractional part (up to 2 decimal places). */
|
||||
val decimalsAllowed: Boolean
|
||||
/** GST the carrier charges, in percent, or null for none. [gstAdded] says how. */
|
||||
val gstPercent: Int?
|
||||
/**
|
||||
* False (the usual): GST comes out of the amount, so the number is credited less
|
||||
* ([creditedAfterGst]). True: the number is credited the whole amount and GST is charged on
|
||||
* top of it ([chargedWithGst]).
|
||||
*/
|
||||
val gstAdded: Boolean get() = false
|
||||
|
||||
/**
|
||||
* What the number is credited for a GST-inclusive [amount]: amount / (1 + rate), rounded
|
||||
* down. Null when the service charges no GST, or adds it on top.
|
||||
*/
|
||||
fun creditedAfterGst(amount: BigDecimal): BigDecimal? {
|
||||
val gst = gstPercent ?: return null
|
||||
if (gstAdded) return null
|
||||
return amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
/**
|
||||
* What is paid for [amount]: amount + round2(amount × rate) when GST is added on top,
|
||||
* otherwise [amount] itself.
|
||||
*/
|
||||
fun chargedWithGst(amount: BigDecimal): BigDecimal {
|
||||
val gst = gstPercent?.takeIf { gstAdded } ?: return amount
|
||||
return amount + (amount * BigDecimal(gst).movePointLeft(2)).setScale(2, RoundingMode.HALF_UP)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the Transfer screen's amount and reference fields in step with a picked
|
||||
* [PayoutService]: the amount error, the GST note under the amount, the keypad, and the
|
||||
* reference box disabled (none of the payouts take one). Shared by the Fahipay and card routes.
|
||||
*/
|
||||
class PayoutAmountField(
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val context: () -> Context,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Why the typed amount can't be sent with [svc], or null when it can (or the field is
|
||||
* empty). Checks the service's minimum, maximum and whether it takes decimals.
|
||||
*/
|
||||
fun problem(svc: PayoutService): String? {
|
||||
val ctx = context()
|
||||
val text = binding.etAmount.text?.toString()?.trim().orEmpty()
|
||||
if (text.isEmpty()) return null
|
||||
val amount = text.toBigDecimalOrNull() ?: return ctx.getString(R.string.transfer_fahipay_amount_invalid)
|
||||
val fraction = amount.stripTrailingZeros().scale()
|
||||
return when {
|
||||
!svc.decimalsAllowed && fraction > 0 -> ctx.getString(R.string.transfer_fahipay_amount_whole)
|
||||
fraction > 2 -> ctx.getString(R.string.transfer_fahipay_amount_decimals)
|
||||
amount < BigDecimal(svc.minAmount) -> ctx.getString(R.string.transfer_fahipay_amount_min, svc.minAmount)
|
||||
svc.maxAmount != null && amount > BigDecimal(svc.maxAmount!!) ->
|
||||
ctx.getString(R.string.transfer_fahipay_amount_max, "%,d".format(svc.maxAmount))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Idempotent — the fragment calls it on every form change. */
|
||||
fun sync(svc: PayoutService) {
|
||||
// Whole-number services get a keypad without a decimal point. Only set on change:
|
||||
// setting inputType restarts the keyboard, and this runs on every keystroke.
|
||||
val inputType = if (svc.decimalsAllowed) DECIMAL_INPUT else InputType.TYPE_CLASS_NUMBER
|
||||
if (binding.etAmount.inputType != inputType) binding.etAmount.inputType = inputType
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.tilRemarks.alpha = 0.4f
|
||||
val problem = problem(svc)
|
||||
binding.tilAmount.error = problem
|
||||
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
|
||||
}
|
||||
|
||||
/**
|
||||
* For services that charge GST: what the recipient is credited once it comes out, or what
|
||||
* is paid once it's added on top.
|
||||
*/
|
||||
fun gstNote(svc: PayoutService): String? {
|
||||
val gst = svc.gstPercent ?: return null
|
||||
val ctx = context()
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
|
||||
if (svc.gstAdded) {
|
||||
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_gst_added_hint, gst)
|
||||
return ctx.getString(R.string.transfer_gst_added_pay, "%,.2f".format(svc.chargedWithGst(amount)), gst)
|
||||
}
|
||||
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
|
||||
val credited = svc.creditedAfterGst(amount) ?: return null
|
||||
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
|
||||
}
|
||||
|
||||
/** Gives back the amount and reference fields once no service is picked. */
|
||||
fun reset() {
|
||||
binding.tilAmount.error = null
|
||||
binding.tilAmount.helperText = null
|
||||
binding.etAmount.inputType = DECIMAL_INPUT
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
|
||||
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,4 @@ class TransferDraft {
|
||||
|
||||
// Fahipay
|
||||
var fahipayService: FahipayService? = null
|
||||
|
||||
// Carrier service paid by verified BML card
|
||||
var cardPayoutService: CardPayoutService? = null
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import sh.sar.basedbank.api.models.BankAccount
|
||||
* One option in the Transfer screen's "Transfer Type" picker: a way of paying the number in
|
||||
* the "To" field. Picking one also decides the source account — see [worksFrom].
|
||||
*
|
||||
* Add new kinds as subclasses; the exhaustive `when`s over this type point at every site that
|
||||
* needs updating.
|
||||
* Add new kinds (BML verified card, …) as subclasses; the exhaustive `when`s over this type
|
||||
* point at every site that needs updating.
|
||||
*/
|
||||
sealed interface TransferType {
|
||||
val label: String
|
||||
@@ -39,17 +39,4 @@ sealed interface TransferType {
|
||||
override val badgeRes get() = R.drawable.fahipay_logo
|
||||
override fun worksFrom(account: BankAccount) = account.bank == "FAHIPAY"
|
||||
}
|
||||
|
||||
/**
|
||||
* A carrier service (reload, …) paid by verified BML card through the carrier's own BML
|
||||
* merchant gateway. [cards] are the account numbers of the cards that can pay it — verified,
|
||||
* with an OTP seed for the 3-D Secure step — worked out when the lookup ran.
|
||||
*/
|
||||
data class Card(val service: CardPayoutService, val ownerName: String?, val cards: Set<String>) : TransferType {
|
||||
override val label get() = service.label
|
||||
override val subtitle get() = listOfNotNull(ownerName, "by BML card").joinToString(" · ")
|
||||
override val iconRes get() = service.iconRes
|
||||
override val badgeRes get() = R.drawable.bml_logo_vector
|
||||
override fun worksFrom(account: BankAccount) = account.bank == "BML" && account.accountNumber in cards
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,8 +269,6 @@
|
||||
<string name="transfer_fahipay_amount_max">Maximum is MVR %1$s</string>
|
||||
<string name="transfer_fahipay_gst_hint">%1$d%% GST is deducted from this amount</string>
|
||||
<string name="transfer_fahipay_gst_receive">Recipient receives MVR %1$s after %2$d%% GST</string>
|
||||
<string name="transfer_gst_added_hint">%1$d%% GST is charged on top of this amount</string>
|
||||
<string name="transfer_gst_added_pay">You pay MVR %1$s with %2$d%% GST</string>
|
||||
<string name="transfer_fahipay_phone_only">Fahipay transfers require a 7-digit phone number</string>
|
||||
<string name="transfer_my_accounts">My Accounts</string>
|
||||
<string name="transfer_same_as_from">This is the same account as the sender</string>
|
||||
@@ -308,7 +306,6 @@
|
||||
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
|
||||
<string name="bml_card_pay_no_verified">No verified card available. Verify a BML card first in Manage Card.</string>
|
||||
<string name="bml_card_pay_already_paid">This payment has already been completed.</string>
|
||||
<string name="bml_card_pay_merchant_not_notified">Paid, but %1$s couldn\'t be notified. If it isn\'t credited, contact them with BML transaction %2$s.</string>
|
||||
<string name="bml_qr_payment_success">Payment Successful</string>
|
||||
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
||||
|
||||
|
||||
+2
-2
@@ -17,5 +17,5 @@
|
||||
| [bmlapi/](bmlapi/README.md) | Bank of Maldives — hybrid web/OAuth login, dashboard, transfers, cards, QR payments, tap-to-pay |
|
||||
| [mibapi/](mibapi/README.md) | MIB Faisanet — Blowfish-encrypted API + WebView session, accounts, transfers, contacts |
|
||||
| [fahipayapi/](fahipayapi/README.md) | Fahipay digital wallet — login, balance, history, contacts |
|
||||
| [dhiraaguapi/](dhiraaguapi/README.md) | Dhiraagu Easy Pay / Easy TopUp — number lookup, reload and bill pay by BML card |
|
||||
| [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation, Raastas and bill pay by BML card |
|
||||
| [dhiraaguapi/](dhiraaguapi/README.md) | Dhiraagu Easy Pay — number lookup for reload / bill pay |
|
||||
| [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation for Raastas / bill pay |
|
||||
|
||||
@@ -73,8 +73,6 @@ POST <ACS creq url> otpValue=<token TOTP> → auto-POST form (cres → gateway)
|
||||
POST <gateway callback> cres → auto-POST form (→ mpgsNotification)
|
||||
POST transactions/mpgsNotification/<id> → records the verdict
|
||||
POST …next-action POLL → TRANSACTION_CONFIRMED
|
||||
GET transaction…/<id>?wait=1 → 302 merchant redirectUrl (?…&state=CONFIRMED&signature=…)
|
||||
→ 302 merchant receipt page
|
||||
```
|
||||
|
||||
---
|
||||
@@ -205,10 +203,8 @@ no `action`; their JavaScript posts back to the **same creq URL**. So the creq U
|
||||
`destValue=token`, `selectChannel=token`, `authMethod=OOB`, `otpDest=`, `formReqType=SUBMIT`
|
||||
(keep the hidden `creq` / `otpChannels`).
|
||||
3. **POST channel** → the **OTP entry** page (`otpValue` input). Submit `otpValue=<BML token TOTP>`,
|
||||
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page (still with `otpValue`) and
|
||||
*"The OTP code you entered is incorrect Please try again."* — wait for the next TOTP window,
|
||||
regenerate and retry once. Rejected twice, the payment stops ("The bank rejected the BML token
|
||||
code"). The app also never sends a code with under 5 s left in its window.
|
||||
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page with text containing
|
||||
*"incorrect"* / *"expired"* — regenerate the TOTP and retry once.
|
||||
4. On success the ACS returns a form auto-posting **`cres`** to the Mastercard gateway; the gateway
|
||||
returns a form auto-posting the result (`order.id`, `result=SUCCESS`, …) to
|
||||
**`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded.
|
||||
@@ -227,55 +223,8 @@ Poll `next-action` until the recorded verdict surfaces:
|
||||
| `TRANSACTION_CONFIRMED` | Success |
|
||||
| `TRANSACTION_FAILED` | Declined |
|
||||
|
||||
**A decline after 3-D Secure doesn't arrive this way.** In the Ooredoo capture, the card passed
|
||||
3-D Secure (`mpgsNotification` got `result=SUCCESS`, `gatewayRecommendation=PROCEED`) and was then
|
||||
declined for insufficient funds. The browser's `?wait=1` went to `?error=1` instead of the
|
||||
merchant, and the transaction stayed payable: `state` still `QR_CODE_GENERATED`, `hasError: true`,
|
||||
`allowRetry: true`, and a new `paymentErrorHistory` entry:
|
||||
|
||||
```json
|
||||
{"date":"…","vendor":"mpgs","code":"INSUFFICIENT_FUNDS",
|
||||
"reason":"Transaction declined due to insufficient funds",
|
||||
"customerVisibleDescription":"Insufficient funds. Please use another card or payment method."}
|
||||
```
|
||||
|
||||
The same link was then paid successfully after topping up the card. So while polling,
|
||||
`BmlMerchantCardPayClient` also reads the transaction (the load PATCH,
|
||||
`BmlMerchantTxnClient.paymentErrors`) and stops with `customerVisibleDescription` as soon as an
|
||||
entry newer than the ones there before the attempt shows up.
|
||||
|
||||
## 7. Return to the merchant
|
||||
|
||||
The `mpgsNotification` response is a page whose script sends the browser to
|
||||
`https://transaction.merchants.bankofmaldives.com.mv/<id>?wait=1`. Once the transaction is
|
||||
confirmed, that 302s to the merchant's `redirectUrl` with a BML-signed result, then on to the
|
||||
merchant's own receipt page:
|
||||
|
||||
```
|
||||
GET transaction…/<id>?wait=1
|
||||
→ 302 https://www.dhiraagu.com.mv/api/dhiraagu-bml-response.aspx?transactionId=<id>&state=CONFIRMED&signature=<sha1>
|
||||
→ 302 https://www.dhiraagu.com.mv/services/reload-receipt?pyid=<paymentId> (bill pay: /services/bill-receipt)
|
||||
```
|
||||
|
||||
(FahiPay's is `fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`.)
|
||||
|
||||
Ooredoo's callback isn't a redirect: `my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED` is a
|
||||
200 page whose `<body onload="document.forms['wtmpay'].submit()">` posts the result
|
||||
(`order_id`, `bml_transaction_id`, `bml_response=CONFIRMED`, `payment_status=success`, …) on to
|
||||
`www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml`, which lands on
|
||||
`/payment-status?order_id=…&status=1`. `returnToMerchant` submits such auto-posting forms too
|
||||
(up to 2).
|
||||
|
||||
**This hop is required.** It's how at least Dhiraagu learns it was paid: a test reload that
|
||||
stopped at `TRANSACTION_CONFIRMED` charged the card but never topped up, and opening the
|
||||
`?wait=1` URL in a browser afterwards delivered it. The signature is generated by BML, so the
|
||||
hop can be replayed later from the transaction id alone.
|
||||
|
||||
`BmlMerchantCardPayClient` does it after every confirmed payment (`returnToMerchant`): a browser
|
||||
UA GET that follows the redirects and auto-submitted forms, up to 3 tries, success = the chain
|
||||
ends on a 2xx page. The
|
||||
merchant host may be behind Cloudflare: plain `curl` got a 403 on `dhiraagu.com.mv`, okhttp got
|
||||
through.
|
||||
The merchant's own backend is also notified out-of-band (e.g.
|
||||
`fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`).
|
||||
|
||||
---
|
||||
|
||||
@@ -294,7 +243,6 @@ learn of breakage from a failed live payment.
|
||||
| **Merchant detection** | BML adds other card providers (UnionPay, Apple/Google Pay); non-`mpgs` card provider | Misroute to the wrong flow |
|
||||
| **`window.appData` parsing** | Key moved/obfuscated or made dynamically signed | No `pomeloJsKey` |
|
||||
| **Double-charge** | Confirm poll times out but the charge went through | Retry risks paying twice |
|
||||
| **Return to merchant** | The merchant's `redirectUrl` host blocks the client (Cloudflare) or is down | Charged but not delivered — `Success(merchantNotified = false)`, the app toasts the BML transaction id; opening `…/<id>?wait=1` in a browser delivers it |
|
||||
|
||||
**Maintenance:** re-capture a HAR whenever any party updates; expect to touch the ACS form parser
|
||||
most often; the flow is effectively untestable in CI (no deterministic 3-D Secure double). Keep the
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
# Reload (Easy TopUp, paid by BML card)
|
||||
|
||||
Top up a Dhiraagu prepaid number through the dhiraagu.com.mv **Easy TopUp** page. Dhiraagu only
|
||||
builds the order: the money moves on a **BML Merchant Services transaction** that Dhiraagu creates
|
||||
for it, which is then paid exactly like any card-only BML merchant link
|
||||
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
|
||||
|
||||
Reconstructed from `docs/dhiraaguapi/tmp/dhiraagu_reload_gateway.md` (a Firefox HAR).
|
||||
|
||||
---
|
||||
|
||||
## Flow overview
|
||||
|
||||
```
|
||||
GET /services/easy-topup → nonce #1
|
||||
POST cart&act=recharge (nonce #1) → cartId
|
||||
GET /services/payment-v2?cartid=<cartId> → nonce #2
|
||||
POST merchant&act=form (nonce #2) → BML gateway's merchantId
|
||||
POST payment&act=create (nonce #2) → paymentId, oid
|
||||
POST bml&act=createV2 (nonce #2) → BML transaction url ──┐
|
||||
│
|
||||
── from here: the BML card-only merchant flow ── │
|
||||
GET transaction.merchants…/<id>/paynow ←─────────────────────────────┘
|
||||
… Pomelo tokenise, next-action, Wibmo 3-D Secure, MPGS … → TRANSACTION_CONFIRMED
|
||||
GET transaction.merchants…/<id>?wait=1 → 302 dhiraagu-bml-response.aspx (tops up)
|
||||
→ 302 /services/reload-receipt
|
||||
```
|
||||
|
||||
After the payment the browser is sent `transaction…/<id>?wait=1` →
|
||||
`dhiraagu-bml-response.aspx?transactionId=<id>&state=CONFIRMED&signature=…` →
|
||||
`/services/reload-receipt?pyid=<paymentId>`. **This is what makes Dhiraagu top up the number** —
|
||||
a payment that stopped at BML's `TRANSACTION_CONFIRMED` was charged but not delivered until that
|
||||
URL was opened. The card flow follows it for every merchant, see
|
||||
[BML API → Return to the merchant](../bmlapi/16-card-payment.md#7-return-to-the-merchant).
|
||||
|
||||
**Recovering a stuck reload:** open `https://transaction.merchants.bankofmaldives.com.mv/<id>?wait=1`
|
||||
in a browser. BML signs the callback, so the transaction id is all that's needed. (`curl` gets a
|
||||
Cloudflare 403 on the Dhiraagu hop; a browser works.)
|
||||
|
||||
---
|
||||
|
||||
## Common
|
||||
|
||||
All API calls are `POST https://www.dhiraagu.com.mv/api/sdk-dhr-webapi.ashx?website_id=CA2BB809-3A22-485B-A518-DA6B6DE653A5&sub=<sub>&act=<act>`
|
||||
with a JSON body and these headers:
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `User-Agent` | a browser UA (same as [Number Lookup](01-number-lookup.md)) |
|
||||
| `Content-Type` | `application/json` |
|
||||
| `X-Requested-With` | `XMLHttpRequest` |
|
||||
| `Origin` | `https://www.dhiraagu.com.mv` |
|
||||
| `nonce` | `var nonce = "…"` from the page that makes the call |
|
||||
|
||||
Every response is `{"respStatus":"OK","resp":…}` on success.
|
||||
|
||||
Each page has its own nonce: the cart call uses the Easy TopUp page's, the rest use the payment
|
||||
page's.
|
||||
|
||||
---
|
||||
|
||||
## 1. Settings (optional)
|
||||
|
||||
`GET …&sub=setting&act=reload` — the page reads its limits from here. Thijooree hardcodes them.
|
||||
|
||||
```json
|
||||
{"gstRate":0.08,"dailyLimit":3000,
|
||||
"amountLimit":{"min":20,"max":1080,"message":"Enter a whole number amount between MVR 20 and 1000"},
|
||||
"reloadPerDay":{"easyTopUp":4,"myAccount":6}, …}
|
||||
```
|
||||
|
||||
| Rule | Value |
|
||||
|---|---|
|
||||
| Amount | whole MVR, **20 – 1000** (the message says 1000; `max` says 1080 — Thijooree uses 1000) |
|
||||
| GST | 8%, **included** in the amount |
|
||||
| Per day | MVR 3000, 4 Easy TopUps |
|
||||
|
||||
GST, as the page works it out: `gst = round2(amount × 0.08 / 1.08)`, credited `amount − gst`
|
||||
(MVR 20 → GST 1.48, credited 18.52).
|
||||
|
||||
---
|
||||
|
||||
## 2. Cart
|
||||
|
||||
`sub=cart&act=recharge`, nonce from `GET /services/easy-topup`.
|
||||
|
||||
```json
|
||||
{"formId":2,"serviceNumber":"7XXXXXX","amount":20,"amountGST":1.48,"amountRecharge":18.52,
|
||||
"gstRate":0.08,"memberId":"","memberName":"","memberNId":"","customerId":"","customerCode":"","version":2}
|
||||
```
|
||||
```json
|
||||
{"cartId":"002773ed-…","formId":2,"cartAmount":20.00,"cartExpiry":"…",
|
||||
"paymentUrl":"https://www.dhiraagu.com.mv/services/payment-v2?cartid=002773ed-…", …}
|
||||
```
|
||||
|
||||
The page also calls `sub=dhiraaguIO&act=infoSubscriberStatus` (`{"number"}`) before this, to show
|
||||
the number's status and balance. Thijooree skips it — [Number Lookup](01-number-lookup.md) has
|
||||
already confirmed a prepaid number.
|
||||
|
||||
---
|
||||
|
||||
## 3. Payment gateway
|
||||
|
||||
`sub=merchant&act=form`, `{"formId":2}`, nonce from `GET /services/payment-v2?cartid=<cartId>`.
|
||||
Lists the gateways; **`gatewayId: 1` is Bank of Maldives** (2 = MIB, 3 = DhiraaguPay).
|
||||
|
||||
```json
|
||||
[{"merchantId":"98de333c-…","merchantId2":"3f5cf6b7-…","formId":2,"gatewayId":1,
|
||||
"gatewayName":"Bank of Maldives", …}, …]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Payment
|
||||
|
||||
`sub=payment&act=create`
|
||||
|
||||
```json
|
||||
{"formId":2,"cartId":"<cartId>","gatewayId":1,"dhiraaguPayNumber":"","amount":"20.00",
|
||||
"paymentMerchantId":"<BML merchantId>","memberId":"","tokenize":"","paymentType":"",
|
||||
"recurringFrequency":"","bmlTokenId":""}
|
||||
```
|
||||
```json
|
||||
{"paymentId":"3ea4351b-…","oid":"ET20260006911381","gatewayId":1,"amount":20.00,"paymentStatus":0, …}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. BML transaction
|
||||
|
||||
`sub=bml&act=createV2`, `{"paymentId":"<paymentId>"}`. Returns the BML Merchant Services
|
||||
transaction (amounts in cents):
|
||||
|
||||
```json
|
||||
{"state":"INITIATED","amount":2000,"currency":"MVR","localId":"ET20260006911381",
|
||||
"url":"https://transaction.merchants.bankofmaldives.com.mv/6abfec7afd7f4a4360fc1df4",
|
||||
"redirectUrl":"https://www.dhiraagu.com.mv/api/dhiraagu-bml-response.aspx",
|
||||
"expires":"…(10 min)…","customerReference":"WebApp - Topup", …}
|
||||
```
|
||||
|
||||
The 24-hex id at the end of `url` is the transaction. Its `/paynow` page offers **UnionPay +
|
||||
MPGS cards only, no BML Pay**, so it's paid by card + 3-D Secure.
|
||||
|
||||
---
|
||||
|
||||
## Reload record
|
||||
|
||||
`sub=reload&act=list`, `{"paymentId"}`, nonce from the receipt page — what the receipt page shows:
|
||||
|
||||
```json
|
||||
{"oid":"ET20260006911381","transId":"<BML txn id>","serviceNumber":"7XXXXXX",
|
||||
"amountPay":20.00,"amountTopup":18.52,"amountGST":1.48,
|
||||
"paidStatus":1,"topupStatus":1,"reloadStatusDesc":"Successful", …}
|
||||
```
|
||||
|
||||
Not used by Thijooree yet.
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare
|
||||
|
||||
`www.dhiraagu.com.mv` is behind Cloudflare. The browser capture carries a `cf_clearance` cookie,
|
||||
but [Number Lookup](01-number-lookup.md) already works from the app with plain okhttp and a browser
|
||||
UA, so these calls are made the same way.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Related:** [Number Lookup](01-number-lookup.md) · [BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
|
||||
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
|
||||
|
||||
[← Number Lookup](01-number-lookup.md) · [Bill Pay →](03-bill-pay.md)
|
||||
@@ -1,132 +0,0 @@
|
||||
# Bill Pay (Easy Pay, paid by BML card)
|
||||
|
||||
Pay a Dhiraagu postpaid bill through the dhiraagu.com.mv **Easy Pay** page. Like
|
||||
[Reload](02-reload.md), Dhiraagu only builds the order and the money moves on a **BML Merchant
|
||||
Services transaction** paid by card + 3-D Secure
|
||||
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)). From the payment page on,
|
||||
the two flows are identical; only the first page, the cart call and the form id differ.
|
||||
|
||||
Reconstructed from `docs/dhiraaguapi/tmp/dhiraagu_billpay_gateway.har` (a Firefox HAR) and the
|
||||
Easy Pay page's inline script.
|
||||
|
||||
---
|
||||
|
||||
## Flow overview
|
||||
|
||||
```
|
||||
GET /services/easy-pay → nonce #1
|
||||
GET setting&act=bill (nonce #1) → blocked account statuses / customer types
|
||||
POST dhiraaguIO&act=infoUnlisted (nonce #1) → accountNumber, type, accountStatus, customerType
|
||||
POST cart&act=easyPay (nonce #1) → cartId
|
||||
GET /services/payment-v2?cartid=<cartId> → nonce #2
|
||||
POST merchant&act=form {"formId":1} → BML gateway's merchantId
|
||||
POST payment&act=create (formId 1) → paymentId, oid (EP…)
|
||||
POST bml&act=createV2 → BML transaction url
|
||||
── from here: the BML card-only merchant flow ──
|
||||
GET transaction.merchants…/<id>?wait=1 → 302 dhiraagu-bml-response.aspx (posts the payment)
|
||||
→ 302 /services/bill-receipt?pyid=<paymentId>
|
||||
```
|
||||
|
||||
As with reload, the `?wait=1` return hop is what tells Dhiraagu it was paid.
|
||||
|
||||
Common headers and the `{"respStatus":"OK","resp":…}` envelope are as in
|
||||
[Reload → Common](02-reload.md#common).
|
||||
|
||||
---
|
||||
|
||||
## 1. Settings
|
||||
|
||||
`GET …&sub=setting&act=bill` (no body, so a GET) — rules the page checks the lookup against:
|
||||
|
||||
```json
|
||||
{"settingAppJson1":{"accountStatus":{"val":["F"],…},"customerType":{"val":["P"],…}}}
|
||||
```
|
||||
|
||||
A number whose `accountStatus` or `customerType` is in a `val` list is refused before ordering
|
||||
("Payment for this service could not be accepted… [Account Status: F]" / "The number is not
|
||||
allowed. [Customer Type: P]"). Thijooree applies the same rules, skipping them if the call fails.
|
||||
|
||||
`setting&act=maintenance` has `public.easyPay` — `"Y"` means the page is under maintenance.
|
||||
Not checked.
|
||||
|
||||
---
|
||||
|
||||
## 2. Lookup
|
||||
|
||||
`sub=dhiraaguIO&act=infoUnlisted`, `{"number":"7XXXXXX"}` — the same call as
|
||||
[Number Lookup](01-number-lookup.md), but the bill payment needs more of its answer:
|
||||
|
||||
```json
|
||||
{"respStatus":"OK","accountNumber":"1466154","accountStatus":"W","customerType":"S",
|
||||
"type":"BillPayment","serviceDetails":[{"unlisted":"N","prepaidIndicator":"N"}],
|
||||
"accountOwnerInfo":{"name":"…"}}
|
||||
```
|
||||
|
||||
Note the fields are at the top level, not under `resp`.
|
||||
|
||||
| Field | Use |
|
||||
|---|---|
|
||||
| `accountNumber` | the billing account the cart is made out to |
|
||||
| `type` | `BillPayment`, or `writeOffPayments` for a written-off account — sent as `billType` |
|
||||
| `prepaidIndicator` | `"Y"` is refused ("Prepaid number is not allowed.") |
|
||||
|
||||
The page also accepts the account number itself in place of a service number (then
|
||||
`serviceNumber` is sent empty); Thijooree only pays by phone number.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cart
|
||||
|
||||
`sub=cart&act=easyPay`, nonce from `GET /services/easy-pay`.
|
||||
|
||||
```json
|
||||
{"formId":1,"serviceNumber":"7XXXXXX","accountNumber":"1466154","amount":"1.05",
|
||||
"memberId":"","memberName":"","memberNId":"","billRef":"","billType":"BillPayment"}
|
||||
```
|
||||
```json
|
||||
{"cartId":"8fc33fa4-…","formId":1,"cartJson":[{"accountNumber":"1466154","serviceNumber":"7XXXXXX",
|
||||
"amount":1.05,"billRef":"","billType":"BillPayment"}],"cartAmount":1.05,"cartExpiry":"…(20 min)…",
|
||||
"paymentUrl":"https://www.dhiraagu.com.mv/services/payment-v2?cartid=8fc33fa4-…", …}
|
||||
```
|
||||
|
||||
| Rule | Value |
|
||||
|---|---|
|
||||
| Amount | any positive amount, up to 2 decimal places (the page's only check). No min / max. |
|
||||
| GST | none |
|
||||
|
||||
---
|
||||
|
||||
## 4. Payment page
|
||||
|
||||
Same as [Reload §3–5](02-reload.md#3-payment-gateway) with `formId: 1`:
|
||||
|
||||
- `merchant&act=form` lists DhiraaguPay (3), Bank of Maldives (1) and MIB (2) for "Easy Pay".
|
||||
The BML `merchantId` is the same as reload's.
|
||||
- `payment&act=create` returns an `oid` starting `EP` (reload's start `ET`).
|
||||
- `bml&act=createV2` returns the transaction with `"customerReference":"WebApp - Easy Pay"` and
|
||||
the same `redirectUrl`. Its page is card-only, no BML Pay.
|
||||
|
||||
---
|
||||
|
||||
## Bill record
|
||||
|
||||
`sub=bill&act=list`, `{"paymentId"}`, nonce from the receipt page — what the receipt shows:
|
||||
|
||||
```json
|
||||
[{"oid":"EP20260006911918","transId":"<BML txn id>","accountNumber":"1466154","serviceNumber":"7XXXXXX",
|
||||
"amount":1.05,"billStatus":1,"paidStatus":1,"cbsStatus":1,"cbsReceipt":"EP…-130","billType":"BillPayment", …}]
|
||||
```
|
||||
|
||||
Not used by Thijooree yet.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Related:** [Number Lookup](01-number-lookup.md) · [Reload](02-reload.md) ·
|
||||
[BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
|
||||
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
|
||||
|
||||
[← Reload](02-reload.md)
|
||||
@@ -96,8 +96,6 @@ The API only returns a valid result for numbers currently on the Dhiraagu networ
|
||||
| # | File | Description |
|
||||
|---|---|---|
|
||||
| 1 | [Number Lookup](01-number-lookup.md) | Validate a Dhiraagu number and determine account type |
|
||||
| 2 | [Reload](02-reload.md) | Easy TopUp order → BML merchant transaction, paid by card |
|
||||
| 3 | [Bill Pay](03-bill-pay.md) | Easy Pay order → BML merchant transaction, paid by card |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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` | `thijooree` | Client identifier (`app` in the original Fahipay app) |
|
||||
| `platform` | `BasedBank` | 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=thijooree' \
|
||||
--form 'platform=BasedBank' \
|
||||
--form 'device[available]=true' \
|
||||
--form 'device[platform]=Android' \
|
||||
--form 'device[uuid]=a1b2c3d4e5f60718' \
|
||||
|
||||
@@ -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` | `thijooree` | Client identifier (`app` in the original Fahipay app) |
|
||||
| `platform` | `BasedBank` | 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=thijooree' \
|
||||
--form 'platform=BasedBank' \
|
||||
--form 'device[available]=true' \
|
||||
--form 'device[platform]=Android' \
|
||||
--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) | [Payments →](09-payments.md)
|
||||
[← Saved Favourites](07-contacts.md)
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
# 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,7 +128,6 @@ 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -129,4 +129,4 @@ Always fall back to the other provider's lookup if this API returns `custType: n
|
||||
|
||||
---
|
||||
|
||||
[← README](README.md) · [Raastas →](02-raastas.md)
|
||||
[← README](README.md)
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# Raastas (Quick Pay recharge, paid by BML card)
|
||||
|
||||
Recharge an Ooredoo prepaid number through the ooredoo.mv **Quick Pay** page. Ooredoo creates the
|
||||
order and hands back a **BML Merchant Services transaction**, which is paid like any card-only
|
||||
BML merchant link ([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
|
||||
|
||||
Reconstructed from `docs/ooredooapi/tmp/ooredoo_raastas_bml_card.har` (a Firefox HAR, which also
|
||||
holds a rejected OTP and an insufficient-funds decline on the same transaction).
|
||||
|
||||
---
|
||||
|
||||
## Flow overview
|
||||
|
||||
```
|
||||
GET /ooredoo-prod/QuickPayPackage/v1/numberTypeValidation?… → custType PRE (Number Validation)
|
||||
POST /ooredoo-prod/PaymentGateway/bml → orderID, bmlUrl ──┐
|
||||
│
|
||||
── from here: the BML card-only merchant flow ── │
|
||||
GET transaction.merchants…/<id> ←──────────────────────────────────────────────────┘
|
||||
… Pomelo tokenise, next-action, Wibmo 3-D Secure, MPGS … → TRANSACTION_CONFIRMED
|
||||
GET transaction.merchants…/<id>?wait=1 → 302 my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED
|
||||
(200, auto-submits) → POST www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml
|
||||
→ /payment-status?order_id=<orderID>&statusDesc=sucess&status=1
|
||||
```
|
||||
|
||||
Unlike Dhiraagu, there's no nonce or cart: one POST makes the order and the BML transaction.
|
||||
|
||||
---
|
||||
|
||||
## 1. Order
|
||||
|
||||
`POST https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/bml`
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Content-Type` | `application/json` |
|
||||
| `Accept` | `application/json` |
|
||||
| `Origin` | `https://www.ooredoo.mv` |
|
||||
|
||||
```json
|
||||
{"msisdn":"9609XXXXXX","purchaseAmount":"21.60","amountWithoutGst":"20",
|
||||
"receiverMsisdn":"9609XXXXXX","transType":"recharge","serviceType":"prepaid",
|
||||
"serviceTypeDisplayName":"Mobile"}
|
||||
```
|
||||
```json
|
||||
{"status":"OK","msg":"Successfully generated order id","code":"2000",
|
||||
"data":{"orderID":"36447930","purchaseAmount":"2160","hashSignature":"…",
|
||||
"shortUrl":"https://pay.bml.com.mv/7A86qLZ1QV",
|
||||
"bmlUrl":"https://transaction.merchants.bankofmaldives.com.mv/6ac009f7bd9b264b80ba6abf", …}}
|
||||
```
|
||||
|
||||
The 24-hex id at the end of `bmlUrl` is the transaction (`shortUrl` 301s to the same page). The
|
||||
page is card-only, no BML Pay. The transaction's `localId` is the MSISDN, `customerReference` the
|
||||
order id, and it expires after 7 days.
|
||||
|
||||
### GST
|
||||
|
||||
**Added on top**, not taken out: the number is credited `amountWithoutGst` and the card pays
|
||||
`purchaseAmount = amount + toFixed2(amount × 8 / 100)` (MVR 20 → 21.60). The page's payment
|
||||
method list gives `gstPercent: 8` for BML. This is the opposite of Raastas through Fahipay, where
|
||||
GST comes out of the amount.
|
||||
|
||||
### Limits
|
||||
|
||||
Whole MVR, minimum **20** (before GST, so the card pays at least 21.60). The maximum isn't known;
|
||||
the capture starts on the payment page.
|
||||
|
||||
---
|
||||
|
||||
## 2. Return to Ooredoo
|
||||
|
||||
`?wait=1` 302s to `https://my.ooredoo.mv/bml/response_new.php?transactionId=<id>&state=CONFIRMED&signature=<hash>`.
|
||||
That page is a 200 that auto-submits:
|
||||
|
||||
```html
|
||||
<body onload="document.forms['wtmpay'].submit()">
|
||||
<form method="POST" action="https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml" name="wtmpay">
|
||||
order_id=36447930 amount=21.60000000 msisdn=9609XXXXXX transtype=2
|
||||
bml_transaction_id=<id> bml_hash=<hash> bml_response=CONFIRMED
|
||||
error_code=0 payment_status=success ptype=bml
|
||||
```
|
||||
|
||||
which ends on `https://www.ooredoo.mv/payment-status?order_id=36447930&statusDesc=sucess&status=1`
|
||||
(the receipt: `totalAmount 21.6`, `amountWithoutGst 20`, `gstAmt 1.6`). The card flow submits the
|
||||
form, see [BML API → Return to the merchant](../bmlapi/16-card-payment.md#7-return-to-the-merchant).
|
||||
|
||||
A declined attempt sends `?wait=1` to `transaction…/<id>?error=1` instead, and the same
|
||||
transaction can be paid again.
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare
|
||||
|
||||
`ooredoo.mv` and `my.ooredoo.mv` are behind Cloudflare. The capture carries a `cf_clearance`
|
||||
cookie; okhttp from the phone gets through without one, as with
|
||||
[Number Validation](01-number-validation.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Related:** [Number Validation](01-number-validation.md) · [BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
|
||||
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
|
||||
|
||||
[← Number Validation](01-number-validation.md) · [Bill Pay →](03-bill-pay.md)
|
||||
@@ -1,65 +0,0 @@
|
||||
# Bill Pay (Quick Pay, paid by BML card)
|
||||
|
||||
Pay an Ooredoo postpaid bill through the ooredoo.mv **Quick Pay** bill-pay page. It is the same
|
||||
single order call as [Raastas](02-raastas.md) with a different `transType` / `serviceType`, and
|
||||
no GST. From the order on, it's the BML card-only merchant flow and the same return to Ooredoo.
|
||||
|
||||
Reconstructed from `docs/ooredooapi/tmp/ooredoo_billpay_bml_card.har` (a Firefox HAR).
|
||||
|
||||
---
|
||||
|
||||
## Flow overview
|
||||
|
||||
```
|
||||
GET /bill-pay
|
||||
GET /ooredoo-prod/QuickPayPackage/v1/numberTypeValidation?… → custType POST (Number Validation)
|
||||
POST /ooredoo-prod/PaymentGateway/bml → orderID, bmlUrl
|
||||
── BML card-only merchant flow ── → TRANSACTION_CONFIRMED
|
||||
GET transaction.merchants…/<id>?wait=1 → 302 my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED
|
||||
(200, auto-submits, transtype=1) → POST www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml
|
||||
→ /payment-status?statusDesc=sucess&status=1&order_id=<orderID>
|
||||
```
|
||||
|
||||
The page doesn't look up the outstanding bill: the amount is whatever is typed.
|
||||
|
||||
---
|
||||
|
||||
## Order
|
||||
|
||||
`POST https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/bml`, headers as in
|
||||
[Raastas → Order](02-raastas.md#1-order).
|
||||
|
||||
```json
|
||||
{"msisdn":"9609XXXXXX","purchaseAmount":"10.01","amountWithoutGst":"10.01",
|
||||
"receiverMsisdn":"9609XXXXXX","transType":"billpay","serviceType":"Mobile",
|
||||
"serviceTypeDisplayName":"Mobile"}
|
||||
```
|
||||
```json
|
||||
{"status":"OK","msg":"Successfully generated order id","code":"2000",
|
||||
"data":{"orderID":"36447962","purchaseAmount":"1001","shortUrl":"https://pay.bml.com.mv/…",
|
||||
"bmlUrl":"https://transaction.merchants.bankofmaldives.com.mv/6ac011e099f9d890856e2d33", …}}
|
||||
```
|
||||
|
||||
| | Raastas | Bill Pay |
|
||||
|---|---|---|
|
||||
| `transType` | `recharge` | `billpay` |
|
||||
| `serviceType` | `prepaid` | `Mobile` |
|
||||
| `amountWithoutGst` | amount credited | = `purchaseAmount` |
|
||||
| GST | 8% added on top | none |
|
||||
| Return form `transtype` | `2` | `1` |
|
||||
|
||||
### Limits
|
||||
|
||||
Minimum **MVR 10**, decimals allowed (up to 2 places). The maximum isn't known.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Related:** [Number Validation](01-number-validation.md) · [Raastas](02-raastas.md) ·
|
||||
[BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
|
||||
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
|
||||
|
||||
[← Raastas](02-raastas.md)
|
||||
@@ -81,8 +81,6 @@ The API expects the full MSISDN including country code `960` (e.g. `9609654321`)
|
||||
| # | File | Description |
|
||||
|---|---|---|
|
||||
| 1 | [Number Validation](01-number-validation.md) | Validate an Ooredoo number and determine account type |
|
||||
| 2 | [Raastas](02-raastas.md) | Quick Pay recharge order → BML merchant transaction, paid by card |
|
||||
| 3 | [Bill Pay](03-bill-pay.md) | Quick Pay bill payment order → BML merchant transaction, paid by card |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Transfer
|
||||
|
||||
The transfer screen initiates account-to-account fund transfers and phone payments. It supports MIB, BML, Fahipay and M-Faisa as sources and handles all bank-specific authentication and OTP steps. A phone number can also be paid as a carrier service (reload, Raastas, bill pay) from the Fahipay wallet or, for Dhiraagu Reload, a verified BML card.
|
||||
The transfer screen initiates account-to-account fund transfers. It supports MIB, BML, and Fahipay as source banks and handles all bank-specific authentication and OTP steps.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,12 +38,11 @@ A dropdown lists all visible accounts parsed via `AccountListParser.from(acc)?.b
|
||||
|
||||
## Recipient Entry
|
||||
|
||||
The user can specify a recipient in these ways:
|
||||
The user can specify a recipient in three ways:
|
||||
|
||||
1. **Manual entry** — type an account number or phone number directly
|
||||
2. **Contact picker** — opens `ContactPickerSheetFragment` to select a saved contact. A Fahipay favourite opens as its payout service straight away
|
||||
1. **Manual entry** — type an account number directly
|
||||
2. **Contact picker** — opens `ContactPickerSheetFragment` to select a saved contact
|
||||
3. **QR scan** — launches [QrScannerActivity](25-qr-scanner.md); a PayMV QR result pre-fills the account number, amount, and remarks; a BML ebanking / pay.bml URL switches the form into [BML QR merchant payment](20-transfer-flows.md#bml-qr-merchant-payment-flow) mode
|
||||
4. **BML Merchant Services transaction ID or link** — paid through BML Pay (QR flow) or, for card-only merchants, a verified card ([Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md))
|
||||
|
||||
---
|
||||
|
||||
@@ -65,22 +64,10 @@ After the user finishes entering a recipient account number, the app calls the s
|
||||
|
||||
- **MIB**: account name lookup via MIB API
|
||||
- **BML**: beneficiary lookup via BML API
|
||||
- **Fahipay**: phone numbers only — the Dhiraagu / Ooredoo carrier lookup decides which payout services apply
|
||||
- **Fahipay**: account name resolution via Fahipay API
|
||||
|
||||
The resolved name is displayed below the account number field for the user to confirm.
|
||||
|
||||
### Phone numbers — Transfer Type picker
|
||||
|
||||
A phone number searched with no source yet (or from a BML card that can pay by card) is looked up every way it can be paid, in parallel: Favara (MIB / BML), and the carrier lookup when the user has a Fahipay wallet or a verified BML card. Each result is a **transfer type**:
|
||||
|
||||
| Type | Example | Pays from |
|
||||
|---|---|---|
|
||||
| Favara Transfer | bank account behind the number | MIB or BML account |
|
||||
| Fahipay service | Raastas, Ooredoo Bill Pay, Dhiraagu Reload, Dhiraagu Bill Pay | Fahipay wallet |
|
||||
| Card service | Dhiraagu Reload, Dhiraagu Bill Pay, Raastas, Ooredoo Bill Pay (BML badge) | Verified BML card |
|
||||
|
||||
One option is applied straight away; with more, a picker opens and Send stays disabled until one is chosen. Picking a type also picks a source that can pay it. Fahipay and card services clear and disable the Remarks field and apply their own amount rules (minimum, maximum, whole amounts, 8% GST note). Details: [Transfer Flows → Transfer Type picker](20-transfer-flows.md#transfer-type-picker).
|
||||
|
||||
---
|
||||
|
||||
## Biometric Gate
|
||||
@@ -113,33 +100,18 @@ When the source is a BML USD account and the destination is a MIB account but no
|
||||
5. Re-submits with OTP
|
||||
6. On success, shows `TransferReceiptFragment`
|
||||
|
||||
### Fahipay Payout (reload, Raastas, bill pay)
|
||||
### Fahipay Transfer
|
||||
|
||||
1. Checks the amount against the picked service's rules
|
||||
2. Confirm dialog (with the GST note for Raastas), then the biometric gate if enabled
|
||||
3. One POST to the service's Fahipay payment endpoint ([Fahipay API → Payments](../fahipayapi/09-payments.md))
|
||||
4. On success, the result shows inside the dialog (no receipt page yet), then the form clears
|
||||
|
||||
See [Transfer Flows → Fahipay source](20-transfer-flows.md#fahipay-source).
|
||||
|
||||
### Carrier Service by BML Card (Dhiraagu Reload / Bill Pay, Ooredoo Raastas / Bill Pay)
|
||||
|
||||
1. Checks the amount against the carrier's rules (Dhiraagu reload: MVR 20–1000, whole amounts, 8% GST included; Dhiraagu bill pay: from MVR 1, up to 2 decimals, no GST; Raastas: from MVR 20, whole amounts, 8% GST added on top; Ooredoo bill pay: from MVR 10, up to 2 decimals, no GST)
|
||||
2. A "Processing..." dialog shows while the carrier creates the order and its BML merchant transaction ([Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md), [Ooredoo API → Raastas](../ooredooapi/02-raastas.md), [→ Bill Pay](../ooredooapi/03-bill-pay.md))
|
||||
3. From there it is the card-only merchant flow: the same confirm dialog and warning, biometric gate, card + 3-D Secure payment, and the return to the carrier (`?wait=1`) that tops the number up or posts the bill payment. A decline (e.g. insufficient funds) or a rejected token code ends it with the bank's message
|
||||
4. On success, the result shows inside the dialog; if Dhiraagu couldn't be notified, a toast gives the BML transaction id
|
||||
|
||||
See [Transfer Flows → Carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card).
|
||||
|
||||
### BML Merchant Payment (QR / card-only link)
|
||||
|
||||
A BML QR, or a BML Merchant Services link whose merchant takes BML Pay, is paid from a BML card through the QR flow. A card-only merchant link is paid with a verified card (Pomelo + 3-D Secure), followed by the return to the merchant. Neither saves a receipt. See [Transfer Flows → BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) and [Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md).
|
||||
1. Validates fields
|
||||
2. (If biometric gate) prompts biometrics
|
||||
3. Submits via Fahipay API using stored `authID` + session cookie
|
||||
4. On success, shows `TransferReceiptFragment`
|
||||
|
||||
---
|
||||
|
||||
## Transfer Receipt
|
||||
|
||||
On success of a bank transfer (MIB, BML, M-Faisa) the fragment navigates to `TransferReceiptFragment` passing the completed transfer details. Fahipay payouts, card services and merchant payments show their result inside the confirm dialog instead.
|
||||
On success the fragment navigates to `TransferReceiptFragment` passing the completed transfer details.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -49,13 +49,11 @@ Each option is a `TransferType` (`ui/home/transfer/TransferType.kt`):
|
||||
|---|---|---|---|
|
||||
| `Favara(info)` | Favara Transfer | `favara_logo` | MIB or BML account |
|
||||
| `Fahipay(service, ownerName)` | the service's label, e.g. Raastas | `FahipayService.iconRes`: `ooredoo_logo` / `dhiraagu_logo` | Fahipay wallet |
|
||||
| `Card(service, ownerName, cards)` | the service's label, e.g. Dhiraagu Reload | `CardPayoutService.iconRes`, with a BML badge | One of `cards`: verified BML cards that can pay by card (see [Carrier services by BML card](#carrier-services-by-bml-card)) |
|
||||
|
||||
Picking an option also picks the source (`TransferFragment.applyTransferType`). If the selected source can't pay that type, Thijooree switches it:
|
||||
|
||||
- **Favara:** the default account, if it's MIB or BML. Otherwise the source is cleared and the user is asked to pick one.
|
||||
- **Fahipay:** the user's Fahipay wallet.
|
||||
- **Card:** the default card, if it's one of the type's cards. Otherwise the first of them.
|
||||
|
||||
Then the recipient card is filled in. The options, the number they were looked up for and the pick are kept in `TransferDraft`. If the view is recreated while the popup is still unanswered, it opens again.
|
||||
|
||||
@@ -66,11 +64,9 @@ The options are dropped when the "To" number is edited, the recipient is cleared
|
||||
Two lookups run in parallel:
|
||||
|
||||
- **Favara / IPS lookup.** Uses any logged-in MIB or BML session. The default account's bank goes first, the other is the fallback.
|
||||
- **Carrier lookup** (`CarrierLookup.query`, see Fahipay source below). Only runs when the user has a Fahipay wallet or a BML card that can pay by card. One lookup feeds both.
|
||||
- **Carrier lookup** (see Fahipay source below). Only runs when the user has a Fahipay wallet.
|
||||
|
||||
Everything that resolves is offered as a transfer type. Favara comes first, then the Fahipay services, then the card services.
|
||||
|
||||
The same lookup runs when the source is already a BML card that can pay by card and a phone number is searched. If nothing resolves, the Favara lookup's error is shown as a toast.
|
||||
Everything that resolves is offered as a transfer type. Favara comes first, then the Fahipay services. If nothing resolves, the Favara lookup's error is shown as a toast.
|
||||
|
||||
Any other input with no source selected falls back to the default account as the source, and then the normal lookup for that bank runs.
|
||||
|
||||
@@ -105,7 +101,7 @@ Each Fahipay favourites list is one payout service (`FahipayService.contactCateg
|
||||
| `FAHIPAY_OOREDOO_BILL` | Ooredoo Bill Pay |
|
||||
| `FAHIPAY_DHIRAAGU_BILL` | Dhiraagu Bill Pay |
|
||||
|
||||
So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyServiceContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked:
|
||||
So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyFahipayContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked:
|
||||
|
||||
- the contact picker sheet (the row's category goes back as `ContactPickerSheetFragment.KEY_CATEGORY`)
|
||||
- the "To" field's search-as-you-type dropdown
|
||||
@@ -135,87 +131,10 @@ 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.
|
||||
|
||||
### Carrier services by BML card
|
||||
|
||||
A carrier service can also be paid with a verified BML card, through the carrier's own website
|
||||
and its BML merchant gateway, instead of the Fahipay wallet: **Dhiraagu Reload**, **Dhiraagu
|
||||
Bill Pay**, **Ooredoo Raastas** and **Ooredoo Bill Pay** (`CardPayoutService`, `ui/home/transfer/CardPayoutTransferHandler.kt`).
|
||||
|
||||
**Which cards.** A card qualifies when it's verified and its BML login has an OTP seed, the same
|
||||
rule as card-only merchant links (`BmlVerifiedCards`, see
|
||||
[Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md)). The
|
||||
type remembers those cards (`TransferType.Card.cards`). Picking a card that isn't one of them
|
||||
drops the pick, like any other source that can't pay the picked type.
|
||||
|
||||
**Which services.**
|
||||
|
||||
| Carrier result | Service |
|
||||
|---|---|
|
||||
| Dhiraagu `RELOAD` | Dhiraagu Reload |
|
||||
| Dhiraagu `BILL_PAY` | Dhiraagu Bill Pay |
|
||||
| Ooredoo `PRE` or `HYBRID` | Raastas |
|
||||
| Ooredoo `POST` or `HYBRID` | Ooredoo Bill Pay |
|
||||
|
||||
**Amount rules.** The carrier website's, not Fahipay's. They're checked the same way, through
|
||||
the shared `PayoutAmountField`:
|
||||
|
||||
| Service | Min (MVR) | Max (MVR) | Decimals | GST |
|
||||
|---|---|---|---|---|
|
||||
| Dhiraagu Reload | 20 | 1,000 | no | 8%, included (credit = amount − round2(amount × 0.08 / 1.08)) |
|
||||
| Dhiraagu Bill Pay | 1 | none | up to 2 places | none |
|
||||
| Raastas | 20 | none | no | 8%, **added** (charged = amount + round2(amount × 0.08)) |
|
||||
| Ooredoo Bill Pay | 10 | none | up to 2 places | none |
|
||||
|
||||
Easy Pay itself sets no minimum or maximum; the MVR 1 floor is Thijooree's. The Ooredoo maximums
|
||||
aren't known.
|
||||
|
||||
Raastas by card is the one service where GST is added on top (`PayoutService.gstAdded`): the
|
||||
number is credited what's typed, the card pays more, and the note under the amount says what's
|
||||
paid ("You pay MVR 21.60 with 8% GST"). The order check in step 2 below compares against
|
||||
`chargedWithGst`.
|
||||
|
||||
**Reference.** None. The field is cleared and disabled, as for the Fahipay services.
|
||||
|
||||
**Recents.** As with Fahipay services, the recent is saved with the service's category
|
||||
(`CardPayoutService.contactCategory`: `CARD_DHIRAAGU_RELOAD`, `CARD_DHIRAAGU_BILL`,
|
||||
`CARD_RAASTAS`, `CARD_OOREDOO_BILL`). Picking it again applies that service with no lookup, so
|
||||
the default card (or another payable card) is selected rather than the default account
|
||||
(`TransferFragment.applyServiceContact`). With no payable card left, it toasts and stops. A number
|
||||
keeps one recent, so paying it another way replaces the category.
|
||||
|
||||
**Sending.** The only part that differs from paying a card-only BML merchant link is where the
|
||||
BML transaction comes from:
|
||||
|
||||
1. `CardPayoutTransferHandler.submit()` has the carrier create it for the number and amount
|
||||
(`DhiraaguPaymentClient.createReloadTransaction` / `createBillPayTransaction`,
|
||||
`OoredooPaymentClient.createRaastasTransaction` / `createBillPayTransaction`, see
|
||||
[Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md)
|
||||
and [Ooredoo API → Raastas](../ooredooapi/02-raastas.md), [→ Bill Pay](../ooredooapi/03-bill-pay.md)).
|
||||
Bill pay looks the number up again first, for the billing account the order is made out to.
|
||||
That takes a few round trips, so the payment's "Processing..." box shows meanwhile
|
||||
(`TransferFragment.showProcessingDialog`) and closes before the confirm dialog opens.
|
||||
2. Its payment page is loaded (`BmlMerchantTxnClient.fetchPayPage`). If it doesn't take cards, or
|
||||
its amount isn't the one typed, the payment stops with a toast.
|
||||
3. The page goes to `BmlTransferHandler.confirmCardMerchant`, so from here it's the merchant-link
|
||||
card flow: the same confirm dialog and warning, biometric gate, Pomelo + 3-D Secure payment,
|
||||
and success / failure handling.
|
||||
|
||||
Nothing is charged before the confirm dialog. A cancelled confirm leaves an unpaid Dhiraagu order,
|
||||
which expires on its own.
|
||||
|
||||
### BML source
|
||||
|
||||
1. If the input type is `MIB_ACCOUNT`, calls `BmlValidateClient.verifyMibAccount()`.
|
||||
@@ -280,13 +199,6 @@ Source: Fahipay
|
||||
FAHIPAY_TRANSFER, RAASTAS, OOREDOO_BILL, DHIRAAGU_RELOAD, DHIRAAGU_BILL
|
||||
```
|
||||
|
||||
```
|
||||
Transfer type: Card (verified BML card)
|
||||
|
||||
└── Carrier creates a BML merchant transaction → card-only merchant flow
|
||||
DHIRAAGU_RELOAD, DHIRAAGU_BILL, OOREDOO_RAASTAS, OOREDOO_BILL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rejected Combinations
|
||||
@@ -415,7 +327,6 @@ The transfer button is only enabled when all of the following are true:
|
||||
- Amount is greater than `0`
|
||||
- If transfer types are on offer, one has been picked
|
||||
- For a Fahipay service, the amount meets that service's rules (see [Amount rules](#amount-rules))
|
||||
- For a carrier service by card, the amount meets that service's rules (see [Carrier services by BML card](#carrier-services-by-bml-card))
|
||||
- No connectivity error for `NO_INTERNET` or for the source bank
|
||||
|
||||
---
|
||||
|
||||
@@ -7,9 +7,7 @@ Two linked features:
|
||||
full card details (PAN, expiry, CVV) encrypted on-device.
|
||||
2. **Merchant card payment** — on [Transfer](07-transfer.md), a BML Merchant Services transaction ID
|
||||
whose merchant has **no BML Pay** is paid with a verified card via the Pomelo + 3-D Secure flow
|
||||
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)). The same flow pays
|
||||
[carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card) (Dhiraagu
|
||||
Reload and Bill Pay, Ooredoo Raastas and Bill Pay), once the carrier has created the transaction.
|
||||
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
|
||||
|
||||
> ⚠️ The merchant card flow is scraped browser/ACS traffic, not a stable API. Storing the CVV is a
|
||||
> security/PCI liability. See the API doc's
|
||||
@@ -108,28 +106,23 @@ into the Transfer screen rather than a one-off dialog, mirroring the BML QR merc
|
||||
and empties the amount and re-enables remarks.
|
||||
|
||||
A card is only offered when it is **both** verified **and** belongs to a BML login the app has an
|
||||
OTP seed for (`BmlVerifiedCards.payable` / `isPayable`, `ui/home/transfer/BmlVerifiedCards.kt`) —
|
||||
the 3-D Secure step needs that seed.
|
||||
OTP seed for (`verifiedCardCandidates`, `:428`; `isCardVerified`, `:463`) — the 3-D Secure step
|
||||
needs that seed.
|
||||
|
||||
### Send
|
||||
|
||||
`submitCardPayment` → `confirmCardMerchant` shows the shared transfer confirm dialog
|
||||
(biometric-gated), then `executeCardMerchant` runs, off the main thread.
|
||||
`CardPayoutTransferHandler` calls `confirmCardMerchant` directly with the page of the
|
||||
transaction a carrier created:
|
||||
`submitCardPayment` (`:496`) → `confirmCardMerchant` (`:506`) shows the shared transfer confirm
|
||||
dialog (biometric-gated), then `executeCardMerchant` (`:534`) runs, off the main thread:
|
||||
|
||||
```
|
||||
BmlMerchantCardPayClient().pay(page, card) { Totp.generate(otpSeed) }
|
||||
```
|
||||
|
||||
where `card` and `otpSeed` come from `BmlVerifiedCards.load` — the `VerifiedCardStore` entry
|
||||
(expiry split `MM/YY` → month/year) and the card's BML login seed. The client (`api/bml/BmlMerchantCardPayClient.kt`) performs the whole
|
||||
where `card` comes from `VerifiedCardStore` (expiry split `MM/YY` → month/year) and `otpSeed` is the
|
||||
card's BML login seed. The client (`api/bml/BmlMerchantCardPayClient.kt`) performs the whole
|
||||
Pomelo + MPGS + Wibmo 3-D Secure sequence — feeding the BML token TOTP into the ACS OTP form
|
||||
automatically, retrying once if the first code expired. Once BML confirms, it loads
|
||||
`<id>?wait=1` and follows the redirects to the merchant, which is how the merchant learns it was
|
||||
paid (Dhiraagu doesn't top up without it). Outcome is shown in the shared processing/success
|
||||
dialog; failures surface as a toast. If the merchant couldn't be reached, success also toasts
|
||||
the merchant name and BML transaction id (`bml_card_pay_merchant_not_notified`).
|
||||
automatically, retrying once if the first code expired. Outcome is shown in the shared
|
||||
processing/success dialog; failures surface as a toast.
|
||||
|
||||
### Key assumption
|
||||
|
||||
@@ -152,8 +145,6 @@ another login/person, or a card whose 3-D Secure only offers SMS/email OTP.
|
||||
| `api/bml/BmlMerchantTxnClient.kt` | `fetchPayPage` (merchant-type detection), `announceBrowser`, QR payload |
|
||||
| `api/bml/BmlMerchantCardPayClient.kt` | Pomelo tokenise + 3-D Secure card payment |
|
||||
| `ui/home/transfer/BmlTransferHandler.kt` | On-screen card merchant mode + payment |
|
||||
| `ui/home/transfer/BmlVerifiedCards.kt` | Which cards can pay by card; loads their details |
|
||||
| `ui/home/transfer/CardPayoutTransferHandler.kt` | Carrier services by card: carrier creates the transaction, then `confirmCardMerchant` |
|
||||
| `ui/home/TransferFragment.kt` | Transaction-ID lookup + routing |
|
||||
|
||||
---
|
||||
|
||||
@@ -15,7 +15,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|
||||
| [04 — Accounts](04-accounts.md) | Account list grouped display, AccountsAdapter, profile images, quick-transfer shortcut |
|
||||
| [05 — Account History](05-account-history.md) | Paginated transaction history, search, infinite scroll |
|
||||
| [06 — Transfer History](06-transfer-history.md) | Multi-bank merged transfer history, parallel loading |
|
||||
| [07 — Transfer](07-transfer.md) | Recipient lookup, transfer type picker, MIB/BML/Fahipay transfers, Fahipay payouts, Dhiraagu Reload by BML card, QR, biometric gate, BML OTP |
|
||||
| [07 — Transfer](07-transfer.md) | Recipient lookup, MIB/BML/Fahipay transfer flows, QR, biometric gate, BML OTP |
|
||||
| [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet |
|
||||
| [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt |
|
||||
| [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators |
|
||||
@@ -34,7 +34,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|
||||
| [26 — Circular Nav](26-circular-nav.md) | Radial 4-slot wheel UI with lock centre |
|
||||
| [27 — Settings: Notifications](27-settings-notifications.md) | Opt-in flow: permission → battery opt → service start |
|
||||
| [28 — Settings: About](28-settings-about.md) | Version, T&Cs, donate buttons |
|
||||
| [29 — Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md) | NFC/manual card verification + card-only BML merchant payment (links and carrier services), return to merchant |
|
||||
| [29 — Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md) | NFC/manual card verification + card-only BML merchant payment |
|
||||
|
||||
## Reference
|
||||
|
||||
@@ -42,5 +42,5 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|
||||
|---|---|
|
||||
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, per-bank references, real samples, Fahipay WIP, parsing reference |
|
||||
| [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model |
|
||||
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type picker, Fahipay services, carrier services by BML card, routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
|
||||
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
|
||||
| [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy |
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
- updated dhivehi transaltions (thank you @quillfires)
|
||||
- improved fahipay support
|
||||
- new UI to select transfer type (Favara, Reload, Raastas, Billpay)
|
||||
- Ooredoo Raastas via Fahipay
|
||||
- Ooredo Billpay via Fahipay
|
||||
- Dhiraagu reload via Fahipay
|
||||
- Dhiraagu billpay via Fahipay
|
||||
- Ooredoo Raastas via BML verified cards
|
||||
- Ooredoo Billpay via BML verified cards
|
||||
- Dhiraagu Reload via BML verifed cards
|
||||
- Dhiraagu billpay via BML verified cards
|
||||
Reference in New Issue
Block a user