ooredoo raastas via bml card
Auto Tag on Version Change / check-version (push) Successful in 6s

This commit is contained in:
2026-10-03 01:08:33 +05:00
parent 0d4f0074c7
commit 8795d5f758
16 changed files with 370 additions and 42 deletions
@@ -34,10 +34,12 @@ 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.
* 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 some merchants (Dhiraagu) learn
* they were paid. Without it the card is charged but the merchant never delivers.
* 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.
*
* Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS
* session cookies.
@@ -90,7 +92,10 @@ class BmlMerchantCardPayClient {
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
val txnId = page.transactionId
runCatching { BmlMerchantTxnClient().announceBrowser(txnId) }
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() }
// 1-2. Credentials, then tokenise the card with Pomelo
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
@@ -154,6 +159,11 @@ class BmlMerchantCardPayClient {
"TRANSACTION_CONFIRMED" -> return confirmed(txnId)
"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")
@@ -165,25 +175,37 @@ class BmlMerchantCardPayClient {
/**
* 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. Follows the redirects and retries a couple of times; true when
* the chain ended on a 2xx page.
* 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 {
client.newCall(Request.Builder()
.url("$PAGE_ORIGIN/$txnId?wait=1")
.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()
).execute().use { it.isSuccessful }
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
@@ -206,6 +228,8 @@ 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
@@ -213,9 +237,10 @@ class BmlMerchantCardPayClient {
form.fields["otpValue"] = otp(retry)
form.fields["formReqType"] = "SUBMIT"
html = execText(form.toRequest())
if (!html.contains("incorrect", true) && !html.contains("expired", true)) break
if (!otpRejected(html)) 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) {
@@ -226,6 +251,9 @@ 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 =
@@ -329,5 +357,9 @@ class BmlMerchantCardPayClient {
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,12 +113,36 @@ 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. */
fun announceBrowser(transactionId: String) {
patch(transactionId, JSONObject().put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}"))
/**
* 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))
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")
@@ -0,0 +1,74 @@
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) through the ooredoo.mv Quick Pay page, 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`.
*
* 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 {
val msisdn = "960$number"
val body = JSONObject()
.put("msisdn", msisdn)
.put("purchaseAmount", String.format(Locale.US, "%.2f", charged.setScale(2, RoundingMode.HALF_UP)))
.put("amountWithoutGst", amount.toString())
.put("receiverMsisdn", msisdn)
.put("transType", "recharge")
.put("serviceType", "prepaid")
.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)
.header("Referer", "$BASE/payment?source=Recharge&transType=2")
.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 recharge" })
}
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")
}
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})""")
}
}
@@ -550,7 +550,14 @@ class BmlTransferHandler(
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
BmlMerchantCardPayClient().pay(page, card) { _ -> Totp.generate(otpSeed) }
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)
}
}.getOrElse {
BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed")
}
@@ -957,4 +964,11 @@ 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
}
}
@@ -10,6 +10,8 @@ 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
@@ -20,8 +22,8 @@ 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.
*
* Only Dhiraagu so far. Add the others as constants once their send path lands; the exhaustive
* `when`s over this enum point at every site that needs updating.
* No Ooredoo bill pay yet. Add it as a constant once its send path lands; the exhaustive `when`s
* over this enum point at every site that needs updating.
*/
enum class CardPayoutService(
override val label: String,
@@ -31,6 +33,7 @@ enum class CardPayoutService(
override val maxAmount: Int?,
override val decimalsAllowed: Boolean,
override val gstPercent: Int?,
override val gstAdded: Boolean = false,
) : PayoutService {
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8) {
@@ -40,7 +43,10 @@ enum class CardPayoutService(
},
// 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);
minAmount = 1, maxAmount = null, decimalsAllowed = true, gstPercent = null),
// 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, gstAdded = true);
}
/**
@@ -86,6 +92,9 @@ class CardPayoutTransferHandler(
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)
}
}.map { TransferType.Card(it, result.ownerName, cards) }
}
@@ -143,6 +152,7 @@ class CardPayoutTransferHandler(
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))
@@ -154,6 +164,8 @@ class CardPayoutTransferHandler(
DhiraaguPaymentClient().createReloadTransaction(number, amount.intValueExact())
CardPayoutService.DHIRAAGU_BILL ->
DhiraaguPaymentClient().createBillPayTransaction(number, amount)
CardPayoutService.OOREDOO_RAASTAS ->
OoredooPaymentClient().createRaastasTransaction(number, amount.intValueExact(), charged)
}
BmlMerchantTxnClient().fetchPayPage(txnId)
}
@@ -164,8 +176,8 @@ class CardPayoutTransferHandler(
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
it.amount.toBigDecimal().compareTo(amount) != 0 ->
// 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)
}
@@ -25,17 +25,33 @@ interface PayoutService {
val maxAmount: Int?
/** Whether the amount may have a fractional part (up to 2 decimal places). */
val decimalsAllowed: Boolean
/** GST the carrier takes out of the amount before crediting it, in percent, or null for none. */
/** 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.
* 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)
}
}
/**
@@ -81,11 +97,18 @@ class PayoutAmountField(
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
}
/** What the recipient is credited once GST comes out, for services that charge it. */
/**
* 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)
+2
View File
@@ -269,6 +269,8 @@
<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>