From 8795d5f7587cf5d5e6db3fbeacec516670026ef4 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Sat, 3 Oct 2026 01:08:33 +0500 Subject: [PATCH] ooredoo raastas via bml card --- .gitignore | 2 + .../api/bml/BmlMerchantCardPayClient.kt | 58 +++++++--- .../basedbank/api/bml/BmlMerchantTxnClient.kt | 30 ++++- .../api/ooredoo/OoredooPaymentClient.kt | 74 ++++++++++++ .../ui/home/transfer/BmlTransferHandler.kt | 16 ++- .../transfer/CardPayoutTransferHandler.kt | 22 +++- .../ui/home/transfer/PayoutService.kt | 29 ++++- app/src/main/res/values/strings.xml | 2 + docs/README.md | 2 +- docs/bmlapi/16-card-payment.md | 33 +++++- docs/ooredooapi/01-number-validation.md | 2 +- docs/ooredooapi/02-raastas.md | 107 ++++++++++++++++++ docs/ooredooapi/README.md | 1 + docs/thijooree/07-transfer.md | 10 +- docs/thijooree/20-transfer-flows.md | 22 +++- ...card-verification-and-merchant-card-pay.md | 2 +- 16 files changed, 370 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/api/ooredoo/OoredooPaymentClient.kt create mode 100644 docs/ooredooapi/02-raastas.md diff --git a/.gitignore b/.gitignore index c008d66..f477196 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,8 @@ docs/bmlapi/tmp docs/fahipayapi/tmp docs/mfaisaapi/tmp docs/dhiraaguapi/tmp +docs/ooredooapi/tmp +docs/ooredooapi/tmp tmp app/key.jks .kotlin/* diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt index 1478327..3993778 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt @@ -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 ?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 `?wait=1`, * which 302s to the merchant's `redirectUrl` (`…?transactionId=&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 + /** `` and the like. */ + private val AUTO_SUBMIT = Regex("""onload\s*=\s*("[^"]*|'[^']*)\.submit\(\)""", RegexOption.IGNORE_CASE) } } diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt index 7e94663..13a7354 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt @@ -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 { + 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") diff --git a/app/src/main/java/sh/sar/basedbank/api/ooredoo/OoredooPaymentClient.kt b/app/src/main/java/sh/sar/basedbank/api/ooredoo/OoredooPaymentClient.kt new file mode 100644 index 0000000..96e9893 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/ooredoo/OoredooPaymentClient.kt @@ -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})""") + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt index fd5f6bd..c6d8af8 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt @@ -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 + } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/CardPayoutTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/CardPayoutTransferHandler.kt index a5709c4..fdab6ed 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/CardPayoutTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/CardPayoutTransferHandler.kt @@ -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) } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/PayoutService.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/PayoutService.kt index 5eddd55..d7c5d41 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/PayoutService.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/PayoutService.kt @@ -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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf15e47..3aaffaa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -269,6 +269,8 @@ Maximum is MVR %1$s %1$d%% GST is deducted from this amount Recipient receives MVR %1$s after %2$d%% GST + %1$d%% GST is charged on top of this amount + You pay MVR %1$s with %2$d%% GST Fahipay transfers require a 7-digit phone number My Accounts This is the same account as the sender diff --git a/docs/README.md b/docs/README.md index 7c814fe..c53780e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,4 +18,4 @@ | [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 for Raastas / bill pay | +| [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation, Raastas by BML card | diff --git a/docs/bmlapi/16-card-payment.md b/docs/bmlapi/16-card-payment.md index 6b42b78..bcc4c34 100644 --- a/docs/bmlapi/16-card-payment.md +++ b/docs/bmlapi/16-card-payment.md @@ -205,8 +205,10 @@ 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=`, - `formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page with text containing - *"incorrect"* / *"expired"* — regenerate the TOTP and retry once. + `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. 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/`**. Follow both so the verdict is recorded. @@ -225,6 +227,23 @@ 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 @@ -240,13 +259,21 @@ GET transaction…/?wait=1 (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 `` 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, up to 3 tries, success = the chain ends on a 2xx page. The +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. diff --git a/docs/ooredooapi/01-number-validation.md b/docs/ooredooapi/01-number-validation.md index 663fb49..a9c7fbe 100644 --- a/docs/ooredooapi/01-number-validation.md +++ b/docs/ooredooapi/01-number-validation.md @@ -129,4 +129,4 @@ Always fall back to the other provider's lookup if this API returns `custType: n --- -[← README](README.md) +[← README](README.md) · [Raastas →](02-raastas.md) diff --git a/docs/ooredooapi/02-raastas.md b/docs/ooredooapi/02-raastas.md new file mode 100644 index 0000000..a3a96d4 --- /dev/null +++ b/docs/ooredooapi/02-raastas.md @@ -0,0 +1,107 @@ +# 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…/ ←──────────────────────────────────────────────────┘ +… Pomelo tokenise, next-action, Wibmo 3-D Secure, MPGS … → TRANSACTION_CONFIRMED +GET transaction.merchants…/?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=&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=&state=CONFIRMED&signature=`. +That page is a 200 that auto-submits: + +```html + +
+ order_id=36447930 amount=21.60000000 msisdn=9609XXXXXX transtype=2 + bml_transaction_id= bml_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…/?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) diff --git a/docs/ooredooapi/README.md b/docs/ooredooapi/README.md index c5be9a4..204fde3 100644 --- a/docs/ooredooapi/README.md +++ b/docs/ooredooapi/README.md @@ -81,6 +81,7 @@ 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 | --- diff --git a/docs/thijooree/07-transfer.md b/docs/thijooree/07-transfer.md index 34ea2c6..af2ef4c 100644 --- a/docs/thijooree/07-transfer.md +++ b/docs/thijooree/07-transfer.md @@ -77,7 +77,7 @@ A phone number searched with no source yet (or from a BML card that can pay by c |---|---|---| | 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 (BML badge) | Verified BML card | +| Card service | Dhiraagu Reload, Dhiraagu Bill Pay, Raastas (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). @@ -122,11 +122,11 @@ When the source is a BML USD account and the destination is a MIB account but no See [Transfer Flows → Fahipay source](20-transfer-flows.md#fahipay-source). -### Carrier Service by BML Card (Dhiraagu Reload / Bill Pay) +### Carrier Service by BML Card (Dhiraagu Reload / Bill Pay, Ooredoo Raastas) -1. Checks the amount against Dhiraagu's rules (reload: MVR 20–1000, whole amounts, 8% GST included; bill pay: from MVR 1, up to 2 decimals, no GST) -2. A "Processing..." dialog shows while Dhiraagu creates the order and its BML merchant transaction ([Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/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 Dhiraagu (`?wait=1`) that tops the number up or posts the bill payment +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) +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)) +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). diff --git a/docs/thijooree/20-transfer-flows.md b/docs/thijooree/20-transfer-flows.md index a12167b..2f713a1 100644 --- a/docs/thijooree/20-transfer-flows.md +++ b/docs/thijooree/20-transfer-flows.md @@ -151,8 +151,8 @@ None of the Fahipay services take a reference. Picking one clears the Reference ### 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. Only **Dhiraagu Reload** and -**Dhiraagu Bill Pay** so far (`CardPayoutService`, `ui/home/transfer/CardPayoutTransferHandler.kt`). +and its BML merchant gateway, instead of the Fahipay wallet: **Dhiraagu Reload**, **Dhiraagu +Bill Pay** and **Ooredoo Raastas** so far (`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 @@ -166,6 +166,7 @@ drops the pick, like any other source that can't pay the picked type. |---|---| | Dhiraagu `RELOAD` | Dhiraagu Reload | | Dhiraagu `BILL_PAY` | Dhiraagu Bill Pay | +| Ooredoo `PRE` or `HYBRID` | Raastas | **Amount rules.** The carrier website's, not Fahipay's. They're checked the same way, through the shared `PayoutAmountField`: @@ -174,8 +175,15 @@ the shared `PayoutAmountField`: |---|---|---|---|---| | 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)) | -Easy Pay itself sets no minimum or maximum; the MVR 1 floor is Thijooree's. +Easy Pay itself sets no minimum or maximum; the MVR 1 floor is Thijooree's. Raastas' maximum +isn'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. @@ -183,8 +191,10 @@ Easy Pay itself sets no minimum or maximum; the MVR 1 floor is Thijooree's. BML transaction comes from: 1. `CardPayoutTransferHandler.submit()` has the carrier create it for the number and amount - (`DhiraaguPaymentClient.createReloadTransaction` / `createBillPayTransaction`, see - [Dhiraagu API → Reload](../dhiraaguapi/02-reload.md) and [→ Bill Pay](../dhiraaguapi/03-bill-pay.md)). + (`DhiraaguPaymentClient.createReloadTransaction` / `createBillPayTransaction`, + `OoredooPaymentClient.createRaastasTransaction`, 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 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. @@ -265,7 +275,7 @@ Source: Fahipay Transfer type: Card (verified BML card) └── Carrier creates a BML merchant transaction → card-only merchant flow - DHIRAAGU_RELOAD, DHIRAAGU_BILL + DHIRAAGU_RELOAD, DHIRAAGU_BILL, OOREDOO_RAASTAS ``` --- diff --git a/docs/thijooree/29-card-verification-and-merchant-card-pay.md b/docs/thijooree/29-card-verification-and-merchant-card-pay.md index 63c3734..1cfb7f8 100644 --- a/docs/thijooree/29-card-verification-and-merchant-card-pay.md +++ b/docs/thijooree/29-card-verification-and-merchant-card-pay.md @@ -9,7 +9,7 @@ Two linked features: 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), once the carrier has created the transaction. + Reload and Bill Pay, Ooredoo Raastas), once the carrier has created the transaction. > ⚠️ 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