Compare commits
11
Commits
097e7e627f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4805219f4
|
||
|
|
4c680ad22c
|
||
|
|
494a42c1a8
|
||
|
|
9ace8dd724
|
||
|
|
a3449167db
|
||
|
|
1612e21471
|
||
|
|
f02b05b180
|
||
|
|
5abd981096
|
||
|
|
bb76f4e591
|
||
|
|
8795d5f758
|
||
|
|
0d4f0074c7
|
@@ -24,20 +24,11 @@ jobs:
|
|||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
|
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
BEFORE="${{ github.event.before }}"
|
if git tag -l | grep -q "^v${VERSION}$"; then
|
||||||
if [ -z "$BEFORE" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
|
echo "Tag v${VERSION} already exists, skipping"
|
||||||
BEFORE="HEAD~1"
|
|
||||||
fi
|
|
||||||
PREV_VERSION_CODE=$(git show "${BEFORE}:app/build.gradle.kts" 2>/dev/null | grep 'versionCode = ' | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
|
||||||
|
|
||||||
if [ "$VERSION_CODE" = "$PREV_VERSION_CODE" ]; then
|
|
||||||
echo "versionCode unchanged (${VERSION_CODE}), skipping"
|
|
||||||
echo "should_release=false" >> $GITHUB_OUTPUT
|
echo "should_release=false" >> $GITHUB_OUTPUT
|
||||||
elif git tag -l | grep -q "^v${VERSION}$"; then
|
|
||||||
echo "versionCode changed (${PREV_VERSION_CODE} -> ${VERSION_CODE}) but tag v${VERSION} already exists; bump versionName"
|
|
||||||
exit 1
|
|
||||||
else
|
else
|
||||||
echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})"
|
echo "New version detected: v${VERSION}"
|
||||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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/*
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 34
|
versionCode = 35
|
||||||
versionName = "1.0.33"
|
versionName = "1.0.35"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -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,90 @@
|
|||||||
|
package sh.sar.basedbank.api.ooredoo
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ooredoo prepaid recharge (Raastas) and bill payment through the ooredoo.mv Quick Pay pages,
|
||||||
|
* paid by card on BML's merchant gateway. Ooredoo only creates the order; the money moves on the
|
||||||
|
* BML Merchant Services transaction it hands back, which is paid like any card-only BML merchant
|
||||||
|
* link. See `docs/ooredooapi/02-raastas.md` and `docs/ooredooapi/03-bill-pay.md`.
|
||||||
|
*
|
||||||
|
* Every call blocks, so run it on an IO thread.
|
||||||
|
*/
|
||||||
|
class OoredooPaymentClient {
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the recharge order for [number] (7 digits) and the BML transaction paying for it.
|
||||||
|
* [amount] is what the number is credited, in whole MVR; the card pays [charged], which is
|
||||||
|
* that plus GST. Returns the 24-hex BML transaction id. Throws with Ooredoo's wording when
|
||||||
|
* the order is refused.
|
||||||
|
*/
|
||||||
|
fun createRaastasTransaction(number: String, amount: Int, charged: BigDecimal): String =
|
||||||
|
createOrder(number, charged, amount.toString(), transType = "recharge", serviceType = "prepaid")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the bill payment order for postpaid [number] (7 digits) and the BML transaction
|
||||||
|
* paying for it. [amount] is MVR, up to 2 decimal places; no GST. Returns the 24-hex BML
|
||||||
|
* transaction id. Throws with Ooredoo's wording when the order is refused.
|
||||||
|
*/
|
||||||
|
fun createBillPayTransaction(number: String, amount: BigDecimal): String =
|
||||||
|
createOrder(number, amount, money(amount), transType = "billpay", serviceType = "Mobile")
|
||||||
|
|
||||||
|
/** `POST PaymentGateway/bml` — one call makes the order and its BML transaction. */
|
||||||
|
private fun createOrder(
|
||||||
|
number: String, charged: BigDecimal, amountWithoutGst: String, transType: String, serviceType: String,
|
||||||
|
): String {
|
||||||
|
val msisdn = "960$number"
|
||||||
|
val body = JSONObject()
|
||||||
|
.put("msisdn", msisdn)
|
||||||
|
.put("purchaseAmount", money(charged))
|
||||||
|
.put("amountWithoutGst", amountWithoutGst)
|
||||||
|
.put("receiverMsisdn", msisdn)
|
||||||
|
.put("transType", transType)
|
||||||
|
.put("serviceType", serviceType)
|
||||||
|
.put("serviceTypeDisplayName", "Mobile")
|
||||||
|
val text = client.newCall(
|
||||||
|
Request.Builder().url("$BASE/ooredoo-prod/PaymentGateway/bml")
|
||||||
|
.post(body.toString().toRequestBody(JSON))
|
||||||
|
.header("User-Agent", UA)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Origin", BASE)
|
||||||
|
.build()
|
||||||
|
).execute().use { r ->
|
||||||
|
if (r.code in 500..599) throw BankServerException("Ooredoo")
|
||||||
|
r.body?.string().orEmpty()
|
||||||
|
}
|
||||||
|
val obj = try { JSONObject(text) } catch (_: Exception) {
|
||||||
|
throw Exception("Unexpected response from Ooredoo")
|
||||||
|
}
|
||||||
|
if (obj.optString("status") != "OK" || obj.optString("code") != "2000") {
|
||||||
|
throw Exception(obj.optString("msg").ifBlank { "Ooredoo refused the payment" })
|
||||||
|
}
|
||||||
|
val data = obj.optJSONObject("data") ?: throw Exception("Ooredoo didn't create the order")
|
||||||
|
return TXN_URL.find(data.optString("bmlUrl"))?.groupValues?.get(1)
|
||||||
|
?: throw Exception("BML didn't create the transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun money(amount: BigDecimal) =
|
||||||
|
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val BASE = "https://www.ooredoo.mv"
|
||||||
|
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||||
|
private val JSON = "application/json".toMediaType()
|
||||||
|
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
|||||||
import sh.sar.basedbank.databinding.ItemTransferTypeBinding
|
import sh.sar.basedbank.databinding.ItemTransferTypeBinding
|
||||||
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.BmlVerifiedCards
|
import sh.sar.basedbank.ui.home.transfer.BmlVerifiedCards
|
||||||
|
import sh.sar.basedbank.ui.home.transfer.CardPayoutService
|
||||||
import sh.sar.basedbank.ui.home.transfer.CardPayoutTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.CardPayoutTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.CarrierLookup
|
import sh.sar.basedbank.ui.home.transfer.CarrierLookup
|
||||||
import sh.sar.basedbank.ui.home.transfer.FahipayService
|
import sh.sar.basedbank.ui.home.transfer.FahipayService
|
||||||
@@ -407,7 +408,7 @@ class TransferFragment : Fragment() {
|
|||||||
return@setFragmentResultListener
|
return@setFragmentResultListener
|
||||||
}
|
}
|
||||||
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
||||||
if (applyFahipayContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) {
|
if (applyServiceContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) {
|
||||||
return@setFragmentResultListener
|
return@setFragmentResultListener
|
||||||
}
|
}
|
||||||
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
|
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
|
||||||
@@ -453,7 +454,7 @@ class TransferFragment : Fragment() {
|
|||||||
// Pre-select contact if navigated from contacts page or QR scan
|
// Pre-select contact if navigated from contacts page or QR scan
|
||||||
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
||||||
val name = arguments?.getString(ARG_NAME) ?: account
|
val name = arguments?.getString(ARG_NAME) ?: account
|
||||||
if (applyFahipayContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let
|
if (applyServiceContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let
|
||||||
prefillToDirectly(
|
prefillToDirectly(
|
||||||
accountNumber = account,
|
accountNumber = account,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
@@ -1125,7 +1126,7 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
binding.etTo.setOnItemClickListener { _, _, position, _ ->
|
binding.etTo.setOnItemClickListener { _, _, position, _ ->
|
||||||
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
|
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
|
||||||
if (applyFahipayContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
|
if (applyServiceContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
|
||||||
return@setOnItemClickListener
|
return@setOnItemClickListener
|
||||||
}
|
}
|
||||||
prefillToDirectly(
|
prefillToDirectly(
|
||||||
@@ -1141,15 +1142,27 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A saved Fahipay favourite: its list ([categoryId]) already says which service pays it, so
|
* A saved Fahipay favourite, or a recent paid with a Fahipay or card service: its category
|
||||||
* that service is applied as the only transfer type, with no carrier lookup. That switches
|
* ([categoryId]) already says which service pays it, so that service is applied as the only
|
||||||
* the source to the Fahipay wallet and brings in the service's amount rules, the same as a
|
* transfer type, with no carrier lookup. That switches the source to the Fahipay wallet or
|
||||||
* searched number. Returns false, doing nothing, when [categoryId] isn't a Fahipay list.
|
* the default card and brings in the service's amount rules, the same as a searched number.
|
||||||
|
* Returns false, doing nothing, when [categoryId] isn't one of those.
|
||||||
*/
|
*/
|
||||||
private fun applyFahipayContact(number: String, categoryId: String?, name: String): Boolean {
|
private fun applyServiceContact(number: String, categoryId: String?, name: String): Boolean {
|
||||||
val service = FahipayService.fromContactCategory(categoryId) ?: return false
|
val ownerName = name.takeIf { it.isNotBlank() && it != number }
|
||||||
|
val type = FahipayService.fromContactCategory(categoryId)?.let { TransferType.Fahipay(it, ownerName) }
|
||||||
|
?: CardPayoutService.fromContactCategory(categoryId)?.let { service ->
|
||||||
|
// The cards that could pay it then may not be payable now
|
||||||
|
val cards = cardPayoutHandler().payableCards()
|
||||||
|
if (cards.isEmpty()) {
|
||||||
|
Toast.makeText(requireContext(), R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
TransferType.Card(service, ownerName, cards)
|
||||||
|
}
|
||||||
|
?: return false
|
||||||
clearRecipient()
|
clearRecipient()
|
||||||
offerTransferTypes(number, listOf(TransferType.Fahipay(service, name.takeIf { it.isNotBlank() && it != number })))
|
offerTransferTypes(number, listOf(type))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* Add new services as constants; the exhaustive `when`s over this enum point at every site that
|
||||||
* `when`s over this enum point at every site that needs updating.
|
* needs updating.
|
||||||
*/
|
*/
|
||||||
enum class CardPayoutService(
|
enum class CardPayoutService(
|
||||||
override val label: String,
|
override val label: String,
|
||||||
@@ -31,16 +33,37 @@ 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?,
|
||||||
|
/**
|
||||||
|
* Tags the recents paid with this service (`RecentPick.contactCategory`), so picking one
|
||||||
|
* again pays the same way. Not a real contact list: card payouts have no favourites.
|
||||||
|
*/
|
||||||
|
val contactCategory: String,
|
||||||
|
override val gstAdded: Boolean = false,
|
||||||
) : PayoutService {
|
) : 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,
|
||||||
|
contactCategory = "CARD_DHIRAAGU_RELOAD") {
|
||||||
// Dhiraagu rounds the GST to 2 places and credits the rest
|
// Dhiraagu rounds the GST to 2 places and credits the rest
|
||||||
override fun creditedAfterGst(amount: BigDecimal): BigDecimal =
|
override fun creditedAfterGst(amount: BigDecimal): BigDecimal =
|
||||||
amount - DhiraaguPaymentClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt())
|
amount - DhiraaguPaymentClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt())
|
||||||
},
|
},
|
||||||
// Easy Pay sets no limits of its own: any amount with up to 2 decimals, no GST
|
// 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,
|
||||||
|
contactCategory = "CARD_DHIRAAGU_BILL"),
|
||||||
|
// Ooredoo credits the whole amount and charges the card 8% GST on top
|
||||||
|
OOREDOO_RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
|
||||||
|
minAmount = 20, maxAmount = null, decimalsAllowed = false, gstPercent = 8,
|
||||||
|
contactCategory = "CARD_RAASTAS", gstAdded = true),
|
||||||
|
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
|
||||||
|
minAmount = 10, maxAmount = null, decimalsAllowed = true, gstPercent = null,
|
||||||
|
contactCategory = "CARD_OOREDOO_BILL");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The service a recent was paid with, from its [contactCategory], or null when it isn't one. */
|
||||||
|
fun fromContactCategory(categoryId: String?): CardPayoutService? =
|
||||||
|
entries.firstOrNull { it.contactCategory == categoryId }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,6 +109,12 @@ 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)
|
||||||
|
}
|
||||||
|
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) {
|
||||||
|
add(CardPayoutService.OOREDOO_BILL)
|
||||||
|
}
|
||||||
}.map { TransferType.Card(it, result.ownerName, cards) }
|
}.map { TransferType.Card(it, result.ownerName, cards) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +151,8 @@ class CardPayoutTransferHandler(
|
|||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
subtitle = "${type.label} · $number",
|
subtitle = "${type.label} · $number",
|
||||||
colorHex = "#E4002B",
|
colorHex = "#E4002B",
|
||||||
imageHash = null
|
imageHash = null,
|
||||||
|
contactCategory = type.service.contactCategory
|
||||||
)
|
)
|
||||||
fragment.focusAmount()
|
fragment.focusAmount()
|
||||||
}
|
}
|
||||||
@@ -143,6 +173,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 +185,10 @@ 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)
|
||||||
|
CardPayoutService.OOREDOO_BILL ->
|
||||||
|
OoredooPaymentClient().createBillPayTransaction(number, amount)
|
||||||
}
|
}
|
||||||
BmlMerchantTxnClient().fetchPayPage(txnId)
|
BmlMerchantTxnClient().fetchPayPage(txnId)
|
||||||
}
|
}
|
||||||
@@ -164,8 +199,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)
|
||||||
|
|||||||
@@ -261,7 +261,6 @@
|
|||||||
<string name="transfer_to">އެކައުންޓް ނަންބަރު ނުވަތަ ފަވާރާ އައިޑީ</string>
|
<string name="transfer_to">އެކައުންޓް ނަންބަރު ނުވަތަ ފަވާރާ އައިޑީ</string>
|
||||||
<string name="transfer_label_from">ފޮނުވާ އެކައުންޓް</string>
|
<string name="transfer_label_from">ފޮނުވާ އެކައުންޓް</string>
|
||||||
<string name="transfer_label_to">ލިބޭ އެކައުންޓް</string>
|
<string name="transfer_label_to">ލިބޭ އެކައުންޓް</string>
|
||||||
<string name="transfer_select_service">ޚިދުމަތް އިޚްތިޔާރުކުރޭ</string>
|
|
||||||
<string name="transfer_fahipay_phone_only">ފަހިޕޭއިން ފައިސާ ފޮނުވޭނީ 7 އަދަދުގެ ފޯނު ނަންބަރަކަށް</string>
|
<string name="transfer_fahipay_phone_only">ފަހިޕޭއިން ފައިސާ ފޮނުވޭނީ 7 އަދަދުގެ ފޯނު ނަންބަރަކަށް</string>
|
||||||
<string name="transfer_my_accounts">އަމިއްލަ އެކައުންޓްތައް</string>
|
<string name="transfer_my_accounts">އަމިއްލަ އެކައުންޓްތައް</string>
|
||||||
<string name="transfer_same_as_from">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string>
|
<string name="transfer_same_as_from">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string>
|
||||||
|
|||||||
@@ -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
@@ -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 and bill pay by BML card |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Related:** [Number Validation](01-number-validation.md) · [BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
|
||||||
|
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
|
||||||
|
|
||||||
|
[← Number Validation](01-number-validation.md) · [Bill Pay →](03-bill-pay.md)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Bill Pay (Quick Pay, paid by BML card)
|
||||||
|
|
||||||
|
Pay an Ooredoo postpaid bill through the ooredoo.mv **Quick Pay** bill-pay page. It is the same
|
||||||
|
single order call as [Raastas](02-raastas.md) with a different `transType` / `serviceType`, and
|
||||||
|
no GST. From the order on, it's the BML card-only merchant flow and the same return to Ooredoo.
|
||||||
|
|
||||||
|
Reconstructed from `docs/ooredooapi/tmp/ooredoo_billpay_bml_card.har` (a Firefox HAR).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow overview
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /bill-pay
|
||||||
|
GET /ooredoo-prod/QuickPayPackage/v1/numberTypeValidation?… → custType POST (Number Validation)
|
||||||
|
POST /ooredoo-prod/PaymentGateway/bml → orderID, bmlUrl
|
||||||
|
── BML card-only merchant flow ── → TRANSACTION_CONFIRMED
|
||||||
|
GET transaction.merchants…/<id>?wait=1 → 302 my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED
|
||||||
|
(200, auto-submits, transtype=1) → POST www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml
|
||||||
|
→ /payment-status?statusDesc=sucess&status=1&order_id=<orderID>
|
||||||
|
```
|
||||||
|
|
||||||
|
The page doesn't look up the outstanding bill: the amount is whatever is typed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Order
|
||||||
|
|
||||||
|
`POST https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/bml`, headers as in
|
||||||
|
[Raastas → Order](02-raastas.md#1-order).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"msisdn":"9609XXXXXX","purchaseAmount":"10.01","amountWithoutGst":"10.01",
|
||||||
|
"receiverMsisdn":"9609XXXXXX","transType":"billpay","serviceType":"Mobile",
|
||||||
|
"serviceTypeDisplayName":"Mobile"}
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{"status":"OK","msg":"Successfully generated order id","code":"2000",
|
||||||
|
"data":{"orderID":"36447962","purchaseAmount":"1001","shortUrl":"https://pay.bml.com.mv/…",
|
||||||
|
"bmlUrl":"https://transaction.merchants.bankofmaldives.com.mv/6ac011e099f9d890856e2d33", …}}
|
||||||
|
```
|
||||||
|
|
||||||
|
| | Raastas | Bill Pay |
|
||||||
|
|---|---|---|
|
||||||
|
| `transType` | `recharge` | `billpay` |
|
||||||
|
| `serviceType` | `prepaid` | `Mobile` |
|
||||||
|
| `amountWithoutGst` | amount credited | = `purchaseAmount` |
|
||||||
|
| GST | 8% added on top | none |
|
||||||
|
| Return form `transtype` | `2` | `1` |
|
||||||
|
|
||||||
|
### Limits
|
||||||
|
|
||||||
|
Minimum **MVR 10**, decimals allowed (up to 2 places). The maximum isn't known.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Related:** [Number Validation](01-number-validation.md) · [Raastas](02-raastas.md) ·
|
||||||
|
[BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
|
||||||
|
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
|
||||||
|
|
||||||
|
[← Raastas](02-raastas.md)
|
||||||
@@ -81,6 +81,8 @@ 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 |
|
||||||
|
| 3 | [Bill Pay](03-bill-pay.md) | Quick Pay bill payment order → BML merchant transaction, paid by card |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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, Ooredoo Bill Pay (BML badge) | Verified BML card |
|
||||||
|
|
||||||
One option is applied straight away; with more, a picker opens and Send stays disabled until one is chosen. Picking a type also picks a source that can pay it. Fahipay and card services clear and disable the Remarks field and apply their own amount rules (minimum, maximum, whole amounts, 8% GST note). Details: [Transfer Flows → Transfer Type picker](20-transfer-flows.md#transfer-type-picker).
|
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 / Bill Pay)
|
||||||
|
|
||||||
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; Ooredoo bill pay: from MVR 10, 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))
|
2. A "Processing..." dialog shows while the carrier creates the order and its BML merchant transaction ([Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md), [Ooredoo API → Raastas](../ooredooapi/02-raastas.md), [→ Bill Pay](../ooredooapi/03-bill-pay.md))
|
||||||
3. From there it is the card-only merchant flow: the same confirm dialog and warning, biometric gate, card + 3-D Secure payment, and the return to 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).
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ Each Fahipay favourites list is one payout service (`FahipayService.contactCateg
|
|||||||
| `FAHIPAY_OOREDOO_BILL` | Ooredoo Bill Pay |
|
| `FAHIPAY_OOREDOO_BILL` | Ooredoo Bill Pay |
|
||||||
| `FAHIPAY_DHIRAAGU_BILL` | Dhiraagu Bill Pay |
|
| `FAHIPAY_DHIRAAGU_BILL` | Dhiraagu Bill Pay |
|
||||||
|
|
||||||
So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyFahipayContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked:
|
So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyServiceContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked:
|
||||||
|
|
||||||
- the contact picker sheet (the row's category goes back as `ContactPickerSheetFragment.KEY_CATEGORY`)
|
- the contact picker sheet (the row's category goes back as `ContactPickerSheetFragment.KEY_CATEGORY`)
|
||||||
- the "To" field's search-as-you-type dropdown
|
- the "To" field's search-as-you-type dropdown
|
||||||
@@ -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**, **Ooredoo Raastas** and **Ooredoo Bill Pay** (`CardPayoutService`, `ui/home/transfer/CardPayoutTransferHandler.kt`).
|
||||||
|
|
||||||
**Which cards.** A card qualifies when it's verified and its BML login has an OTP seed, the same
|
**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,8 @@ 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 |
|
||||||
|
| Ooredoo `POST` or `HYBRID` | Ooredoo Bill Pay |
|
||||||
|
|
||||||
**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,17 +176,34 @@ 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)) |
|
||||||
|
| Ooredoo Bill Pay | 10 | none | up to 2 places | none |
|
||||||
|
|
||||||
Easy Pay itself sets no minimum or maximum; the MVR 1 floor is Thijooree's.
|
Easy Pay itself sets no minimum or maximum; the MVR 1 floor is Thijooree's. The Ooredoo maximums
|
||||||
|
aren't known.
|
||||||
|
|
||||||
|
Raastas by card is the one service where GST is added on top (`PayoutService.gstAdded`): the
|
||||||
|
number is credited what's typed, the card pays more, and the note under the amount says what's
|
||||||
|
paid ("You pay MVR 21.60 with 8% GST"). The order check in step 2 below compares against
|
||||||
|
`chargedWithGst`.
|
||||||
|
|
||||||
**Reference.** None. The field is cleared and disabled, as for the Fahipay services.
|
**Reference.** None. The field is cleared and disabled, as for the Fahipay services.
|
||||||
|
|
||||||
|
**Recents.** As with Fahipay services, the recent is saved with the service's category
|
||||||
|
(`CardPayoutService.contactCategory`: `CARD_DHIRAAGU_RELOAD`, `CARD_DHIRAAGU_BILL`,
|
||||||
|
`CARD_RAASTAS`, `CARD_OOREDOO_BILL`). Picking it again applies that service with no lookup, so
|
||||||
|
the default card (or another payable card) is selected rather than the default account
|
||||||
|
(`TransferFragment.applyServiceContact`). With no payable card left, it toasts and stops. A number
|
||||||
|
keeps one recent, so paying it another way replaces the category.
|
||||||
|
|
||||||
**Sending.** The only part that differs from paying a card-only BML merchant link is where the
|
**Sending.** The only part that differs from paying a card-only BML merchant link is where the
|
||||||
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` / `createBillPayTransaction`, see
|
||||||
|
[Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md)
|
||||||
|
and [Ooredoo API → Raastas](../ooredooapi/02-raastas.md), [→ Bill Pay](../ooredooapi/03-bill-pay.md)).
|
||||||
Bill pay looks the number up again first, for the billing account the order is made out to.
|
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 +284,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, OOREDOO_BILL
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 and Bill Pay), 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
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
- updated dhivehi transaltions (thank you @quillfires)
|
||||||
|
- improved fahipay support
|
||||||
|
- new UI to select transfer type (Favara, Reload, Raastas, Billpay)
|
||||||
|
- Ooredoo Raastas via Fahipay
|
||||||
|
- Ooredo Billpay via Fahipay
|
||||||
|
- Dhiraagu reload via Fahipay
|
||||||
|
- Dhiraagu billpay via Fahipay
|
||||||
|
- Ooredoo Raastas via BML verified cards
|
||||||
|
- Ooredoo Billpay via BML verified cards
|
||||||
|
- Dhiraagu Reload via BML verifed cards
|
||||||
|
- Dhiraagu billpay via BML verified cards
|
||||||
Reference in New Issue
Block a user