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
+2
View File
@@ -18,6 +18,8 @@ docs/bmlapi/tmp
docs/fahipayapi/tmp docs/fahipayapi/tmp
docs/mfaisaapi/tmp docs/mfaisaapi/tmp
docs/dhiraaguapi/tmp docs/dhiraaguapi/tmp
docs/ooredooapi/tmp
docs/ooredooapi/tmp
tmp tmp
app/key.jks app/key.jks
.kotlin/* .kotlin/*
@@ -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 * 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 * 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`. * 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 * 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 * signed `state=CONFIRMED` — the browser's last hop, and how merchants (Dhiraagu, Ooredoo)
* they were paid. Without it the card is charged but the merchant never delivers. * 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 * Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS
* session cookies. * session cookies.
@@ -90,7 +92,10 @@ class BmlMerchantCardPayClient {
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments") val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
val txnId = page.transactionId 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 // 1-2. Credentials, then tokenise the card with Pomelo
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk) val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
@@ -154,6 +159,11 @@ class BmlMerchantCardPayClient {
"TRANSACTION_CONFIRMED" -> return confirmed(txnId) "TRANSACTION_CONFIRMED" -> return confirmed(txnId)
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment") "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) Thread.sleep(POLL_MS / 2)
} }
return Result.Failure("Payment status unknown — check with the merchant before retrying") 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`, * 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=…`) * 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 * and on to its receipt page. Some merchants' callback page instead auto-submits a form on
* the chain ended on a 2xx page. * 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 { private fun returnToMerchant(txnId: String): Boolean {
repeat(RETURN_ATTEMPTS) { attempt -> repeat(RETURN_ATTEMPTS) { attempt ->
if (attempt > 0) Thread.sleep(RETURN_RETRY_MS) if (attempt > 0) Thread.sleep(RETURN_RETRY_MS)
val ok = runCatching { val ok = runCatching {
client.newCall(Request.Builder() var request = browserNav(Request.Builder().url("$PAGE_ORIGIN/$txnId?wait=1"))
.url("$PAGE_ORIGIN/$txnId?wait=1") for (hop in 0..MAX_AUTO_SUBMITS) {
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") val (code, html, url) = client.newCall(request).execute().use {
.header("Accept-Language", "en-US,en;q=0.9") Triple(it.code, it.body?.string().orEmpty(), it.request.url.toString())
.build() }
).execute().use { it.isSuccessful } 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) }.getOrDefault(false)
if (ok) return true if (ok) return true
} }
return false 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. */ /** Drives the ACS challenge. Returns null on success, or a Failure to stop the payment. */
private fun runThreeDs(threeDsUrl: String, otp: (Boolean) -> String): Result? { 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 // 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. // 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 var retry = false
for (attempt in 0..1) { for (attempt in 0..1) {
form = AcsForm.parse(html, acsUrl) ?: break form = AcsForm.parse(html, acsUrl) ?: break
@@ -213,9 +237,10 @@ class BmlMerchantCardPayClient {
form.fields["otpValue"] = otp(retry) form.fields["otpValue"] = otp(retry)
form.fields["formReqType"] = "SUBMIT" form.fields["formReqType"] = "SUBMIT"
html = execText(form.toRequest()) html = execText(form.toRequest())
if (!html.contains("incorrect", true) && !html.contains("expired", true)) break if (!otpRejected(html)) break
retry = true 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 // 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. // gateway's own auto-post back to BML) so the verdict is recorded before we poll.
repeat(3) { repeat(3) {
@@ -226,6 +251,9 @@ class BmlMerchantCardPayClient {
return null return null
} }
private fun otpRejected(html: String) = html.contains("name=\"otpValue\"") &&
(html.contains("incorrect", true) || html.contains("expired", true))
// ── next-action helpers ────────────────────────────────────────────────── // ── next-action helpers ──────────────────────────────────────────────────
private fun nextAction(pk: String, body: JSONObject): JSONObject = private fun nextAction(pk: String, body: JSONObject): JSONObject =
@@ -329,5 +357,9 @@ class BmlMerchantCardPayClient {
private const val MAX_POLLS = 10 private const val MAX_POLLS = 10
private const val RETURN_ATTEMPTS = 3 private const val RETURN_ATTEMPTS = 3
private const val RETURN_RETRY_MS = 2_000L 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") 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) { * The PATCHes the page sends on load: register this "browser" and clear any FX selection.
patch(transactionId, JSONObject().put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}")) * 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")) 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 { private fun patch(transactionId: String, body: JSONObject): JSONObject {
val request = Request.Builder() val request = Request.Builder()
.url("$API_BASE/transactions/$transactionId") .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 { fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
runCatching { 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 { }.getOrElse {
BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed") BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed")
} }
@@ -957,4 +964,11 @@ class BmlTransferHandler(
} }
private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account) 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.bml.BmlMerchantTxnClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguPaymentClient 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.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeViewModel import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment import sh.sar.basedbank.ui.home.TransferFragment
@@ -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 * 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. * 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 * No Ooredoo bill pay yet. Add it as a constant once its send path lands; the exhaustive `when`s
* `when`s over this enum point at every site that needs updating. * over this enum point at every site that needs updating.
*/ */
enum class CardPayoutService( enum class CardPayoutService(
override val label: String, override val label: String,
@@ -31,6 +33,7 @@ enum class CardPayoutService(
override val maxAmount: Int?, override val maxAmount: Int?,
override val decimalsAllowed: Boolean, override val decimalsAllowed: Boolean,
override val gstPercent: Int?, override val gstPercent: Int?,
override val gstAdded: Boolean = false,
) : PayoutService { ) : PayoutService {
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo, DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8) { 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 // 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, 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.BILL_PAY -> add(CardPayoutService.DHIRAAGU_BILL)
DhiraaguClient.CustType.UNSUPPORTED -> {} 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) } }.map { TransferType.Card(it, result.ownerName, cards) }
} }
@@ -143,6 +152,7 @@ class CardPayoutTransferHandler(
val number = viewModel.transferDraft.transferTypeNumber val number = viewModel.transferDraft.transferTypeNumber
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull() val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return 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 // Creating the order takes a few round trips; show the payment's processing box meanwhile
val processing = fragment.showProcessingDialog(ctx.getString(R.string.transfer)) val processing = fragment.showProcessingDialog(ctx.getString(R.string.transfer))
@@ -154,6 +164,8 @@ class CardPayoutTransferHandler(
DhiraaguPaymentClient().createReloadTransaction(number, amount.intValueExact()) DhiraaguPaymentClient().createReloadTransaction(number, amount.intValueExact())
CardPayoutService.DHIRAAGU_BILL -> CardPayoutService.DHIRAAGU_BILL ->
DhiraaguPaymentClient().createBillPayTransaction(number, amount) DhiraaguPaymentClient().createBillPayTransaction(number, amount)
CardPayoutService.OOREDOO_RAASTAS ->
OoredooPaymentClient().createRaastasTransaction(number, amount.intValueExact(), charged)
} }
BmlMerchantTxnClient().fetchPayPage(txnId) BmlMerchantTxnClient().fetchPayPage(txnId)
} }
@@ -164,8 +176,8 @@ class CardPayoutTransferHandler(
when { when {
!it.supportsCard -> !it.supportsCard ->
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show() 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 // The carrier's order must be for exactly what was typed (plus GST, if added)
it.amount.toBigDecimal().compareTo(amount) != 0 -> it.amount.toBigDecimal().compareTo(charged) != 0 ->
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show() Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
else -> bmlHandler().confirmCardMerchant(it, src) else -> bmlHandler().confirmCardMerchant(it, src)
} }
@@ -25,17 +25,33 @@ interface PayoutService {
val maxAmount: Int? val maxAmount: Int?
/** Whether the amount may have a fractional part (up to 2 decimal places). */ /** Whether the amount may have a fractional part (up to 2 decimal places). */
val decimalsAllowed: Boolean 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? 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 * 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? { fun creditedAfterGst(amount: BigDecimal): BigDecimal? {
val gst = gstPercent ?: return null val gst = gstPercent ?: return null
if (gstAdded) return null
return amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN) 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 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? { fun gstNote(svc: PayoutService): String? {
val gst = svc.gstPercent ?: return null val gst = svc.gstPercent ?: return null
val ctx = context() val ctx = context()
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull() 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) if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
val credited = svc.creditedAfterGst(amount) ?: return null val credited = svc.creditedAfterGst(amount) ?: return null
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst) 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_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_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_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_fahipay_phone_only">Fahipay transfers require a 7-digit phone number</string>
<string name="transfer_my_accounts">My Accounts</string> <string name="transfer_my_accounts">My Accounts</string>
<string name="transfer_same_as_from">This is the same account as the sender</string> <string name="transfer_same_as_from">This is the same account as the sender</string>
+1 -1
View File
@@ -18,4 +18,4 @@
| [mibapi/](mibapi/README.md) | MIB Faisanet — Blowfish-encrypted API + WebView session, accounts, transfers, contacts | | [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 | | [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 | | [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 |
+30 -3
View File
@@ -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` `destValue=token`, `selectChannel=token`, `authMethod=OOB`, `otpDest=`, `formReqType=SUBMIT`
(keep the hidden `creq` / `otpChannels`). (keep the hidden `creq` / `otpChannels`).
3. **POST channel** → the **OTP entry** page (`otpValue` input). Submit `otpValue=<BML token TOTP>`, 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 with text containing `formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page (still with `otpValue`) and
*"incorrect"* / *"expired"* — regenerate the TOTP and retry once. *"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 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 returns a form auto-posting the result (`order.id`, `result=SUCCESS`, …) to
**`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded. **`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded.
@@ -225,6 +227,23 @@ Poll `next-action` until the recorded verdict surfaces:
| `TRANSACTION_CONFIRMED` | Success | | `TRANSACTION_CONFIRMED` | Success |
| `TRANSACTION_FAILED` | Declined | | `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 ## 7. Return to the merchant
The `mpgsNotification` response is a page whose script sends the browser to The `mpgsNotification` response is a page whose script sends the browser to
@@ -240,13 +259,21 @@ GET transaction…/<id>?wait=1
(FahiPay's is `fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`.) (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 **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 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 `?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. hop can be replayed later from the transaction id alone.
`BmlMerchantCardPayClient` does it after every confirmed payment (`returnToMerchant`): a browser `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 merchant host may be behind Cloudflare: plain `curl` got a 403 on `dhiraagu.com.mv`, okhttp got
through. through.
+1 -1
View File
@@ -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)
+107
View File
@@ -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…/<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).
---
&nbsp;
---
**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)
+1
View File
@@ -81,6 +81,7 @@ The API expects the full MSISDN including country code `960` (e.g. `9609654321`)
| # | File | Description | | # | File | Description |
|---|---|---| |---|---|---|
| 1 | [Number Validation](01-number-validation.md) | Validate an Ooredoo number and determine account type | | 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 |
--- ---
+5 -5
View File
@@ -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 | | Favara Transfer | bank account behind the number | MIB or BML account |
| Fahipay service | Raastas, Ooredoo Bill Pay, Dhiraagu Reload, Dhiraagu Bill Pay | Fahipay wallet | | 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). 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). 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) 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 Dhiraagu creates the order and its BML merchant transaction ([Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md)) 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 Dhiraagu (`?wait=1`) that tops the number up or posts the bill payment 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 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). See [Transfer Flows → Carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card).
+16 -6
View File
@@ -151,8 +151,8 @@ None of the Fahipay services take a reference. Picking one clears the Reference
### Carrier services by BML card ### Carrier services by BML card
A carrier service can also be paid with a verified BML card, through the carrier's own website 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 and its BML merchant gateway, instead of the Fahipay wallet: **Dhiraagu Reload**, **Dhiraagu
**Dhiraagu Bill Pay** so far (`CardPayoutService`, `ui/home/transfer/CardPayoutTransferHandler.kt`). 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 **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 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 `RELOAD` | Dhiraagu Reload |
| Dhiraagu `BILL_PAY` | Dhiraagu Bill Pay | | 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 **Amount rules.** The carrier website's, not Fahipay's. They're checked the same way, through
the shared `PayoutAmountField`: 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 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 | | 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. **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: BML transaction comes from:
1. `CardPayoutTransferHandler.submit()` has the carrier create it for the number and amount 1. `CardPayoutTransferHandler.submit()` has the carrier create it for the number and amount
(`DhiraaguPaymentClient.createReloadTransaction` / `createBillPayTransaction`, see (`DhiraaguPaymentClient.createReloadTransaction` / `createBillPayTransaction`,
[Dhiraagu API → Reload](../dhiraaguapi/02-reload.md) and [→ Bill Pay](../dhiraaguapi/03-bill-pay.md)). `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. 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 That takes a few round trips, so the payment's "Processing..." box shows meanwhile
(`TransferFragment.showProcessingDialog`) and closes before the confirm dialog opens. (`TransferFragment.showProcessingDialog`) and closes before the confirm dialog opens.
@@ -265,7 +275,7 @@ Source: Fahipay
Transfer type: Card (verified BML card) Transfer type: Card (verified BML card)
└── Carrier creates a BML merchant transaction → card-only merchant flow └── Carrier creates a BML merchant transaction → card-only merchant flow
DHIRAAGU_RELOAD, DHIRAAGU_BILL DHIRAAGU_RELOAD, DHIRAAGU_BILL, OOREDOO_RAASTAS
``` ```
--- ---
@@ -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 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 ([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 [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 > ⚠️ 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 > security/PCI liability. See the API doc's