add support for reload via BML veried cards
Auto Tag on Version Change / check-version (push) Successful in 3s

This commit is contained in:
2026-10-02 23:29:28 +05:00
parent 7a4b7a1712
commit 9ab7f979fa
12 changed files with 701 additions and 163 deletions
@@ -10,6 +10,7 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.PAGE_ORIGIN
import java.security.KeyFactory import java.security.KeyFactory
import java.security.spec.MGF1ParameterSpec import java.security.spec.MGF1ParameterSpec
import java.security.spec.X509EncodedKeySpec import java.security.spec.X509EncodedKeySpec
@@ -34,6 +35,9 @@ import android.util.Base64
* 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.
* 6. Return to the merchant: `GET <txn>?wait=1` redirects to the merchant's `redirectUrl` with a
* signed `state=CONFIRMED` — the browser's last hop, and how some merchants (Dhiraagu) learn
* they were paid. Without it the card is charged but the merchant never delivers.
* *
* 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.
@@ -49,7 +53,8 @@ class BmlMerchantCardPayClient {
) )
sealed class Result { sealed class Result {
object Success : Result() /** Paid. [merchantNotified] is false when the return to the merchant (step 6) failed. */
data class Success(val merchantNotified: Boolean) : Result()
data class Failure(val message: String) : Result() data class Failure(val message: String) : Result()
} }
@@ -130,7 +135,7 @@ class BmlMerchantCardPayClient {
var threeDsUrl: String? = null var threeDsUrl: String? = null
for (attempt in 0..MAX_POLLS) { for (attempt in 0..MAX_POLLS) {
when (action.optString("action")) { when (action.optString("action")) {
"TRANSACTION_CONFIRMED" -> return Result.Success "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")
} }
threeDsUrl = action.optString("3dsUrl").ifBlank { null } threeDsUrl = action.optString("3dsUrl").ifBlank { null }
@@ -146,7 +151,7 @@ class BmlMerchantCardPayClient {
// 5. Wait for the gateway's verdict to reach BML // 5. Wait for the gateway's verdict to reach BML
repeat(MAX_POLLS * 2) { repeat(MAX_POLLS * 2) {
when (poll(pk, txnId).optString("action")) { when (poll(pk, txnId).optString("action")) {
"TRANSACTION_CONFIRMED" -> return Result.Success "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")
} }
Thread.sleep(POLL_MS / 2) Thread.sleep(POLL_MS / 2)
@@ -154,6 +159,31 @@ class BmlMerchantCardPayClient {
return Result.Failure("Payment status unknown — check with the merchant before retrying") return Result.Failure("Payment status unknown — check with the merchant before retrying")
} }
/** The payment went through: return to the merchant, then report success either way. */
private fun confirmed(txnId: String) = Result.Success(merchantNotified = returnToMerchant(txnId))
/**
* What the browser does once the payment page sees the confirmation: loads `<txn>?wait=1`,
* which 302s to the merchant's `redirectUrl` (`…?transactionId=<id>&state=CONFIRMED&signature=…`)
* and on to its receipt page. Follows the redirects and retries a couple of times; true when
* the chain ended on a 2xx page.
*/
private fun returnToMerchant(txnId: String): Boolean {
repeat(RETURN_ATTEMPTS) { attempt ->
if (attempt > 0) Thread.sleep(RETURN_RETRY_MS)
val ok = runCatching {
client.newCall(Request.Builder()
.url("$PAGE_ORIGIN/$txnId?wait=1")
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "en-US,en;q=0.9")
.build()
).execute().use { it.isSuccessful }
}.getOrDefault(false)
if (ok) return true
}
return false
}
/** 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
@@ -297,5 +327,7 @@ class BmlMerchantCardPayClient {
private val JSON = "application/json".toMediaType() private val JSON = "application/json".toMediaType()
private const val POLL_MS = 5_000L private const val POLL_MS = 5_000L
private const val MAX_POLLS = 10 private const val MAX_POLLS = 10
private const val RETURN_ATTEMPTS = 3
private const val RETURN_RETRY_MS = 2_000L
} }
} }
@@ -0,0 +1,144 @@
package sh.sar.basedbank.api.dhiraagu
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* Dhiraagu prepaid reload through the dhiraagu.com.mv "Easy TopUp" page, paid by card on BML's
* merchant gateway. Dhiraagu only builds the order; the money moves on the BML Merchant Services
* transaction [createBmlTransaction] hands back, which is paid like any card-only BML merchant
* link. See `docs/dhiraaguapi/02-reload.md`.
*
* Every call blocks, so run it on an IO thread.
*/
class DhiraaguReloadClient {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Creates the reload order for [number] and the BML transaction paying for it: cart →
* merchant (the BML gateway entry) → payment → BML transaction. [amount] is the whole MVR
* amount paid, GST included. Returns the 24-hex BML transaction id. Throws with Dhiraagu's
* wording when a step is refused.
*/
fun createBmlTransaction(number: String, amount: Int): String {
val topupNonce = pageNonce("$BASE/services/easy-topup")
val gst = gstOf(amount)
val cart = api("cart", "recharge", topupNonce, JSONObject()
.put("formId", FORM_ID)
.put("serviceNumber", number)
.put("amount", amount)
.put("amountGST", gst.toDouble())
.put("amountRecharge", (BigDecimal(amount) - gst).toDouble())
.put("gstRate", GST_RATE)
.put("memberId", "").put("memberName", "").put("memberNId", "")
.put("customerId", "").put("customerCode", "")
.put("version", 2))
val cartId = cart.optString("cartId").ifBlank { throw Exception("Dhiraagu didn't create the order") }
// The payment page carries its own nonce, used for the rest of the order
val paymentNonce = pageNonce("$BASE/services/payment-v2?cartid=$cartId")
val merchants = apiList("merchant", "form", paymentNonce, JSONObject().put("formId", FORM_ID))
val bml = (0 until merchants.length()).map { merchants.getJSONObject(it) }
.firstOrNull { it.optInt("gatewayId") == GATEWAY_BML }
?: throw Exception("Dhiraagu isn't taking BML card payments right now")
val payment = api("payment", "create", paymentNonce, JSONObject()
.put("formId", FORM_ID)
.put("cartId", cartId)
.put("gatewayId", GATEWAY_BML)
.put("dhiraaguPayNumber", "")
.put("amount", String.format(Locale.US, "%.2f", amount.toDouble()))
.put("paymentMerchantId", bml.getString("merchantId"))
.put("memberId", "").put("tokenize", "").put("paymentType", "")
.put("recurringFrequency", "").put("bmlTokenId", ""))
val paymentId = payment.optString("paymentId").ifBlank { throw Exception("Dhiraagu didn't create the payment") }
val txn = api("bml", "createV2", paymentNonce, JSONObject().put("paymentId", paymentId))
return TXN_URL.find(txn.optString("url"))?.groupValues?.get(1)
?: throw Exception("BML didn't create the transaction")
}
// ── HTTP ─────────────────────────────────────────────────────────────────
/** Every page embeds a `var nonce = "…"` that its API calls send as the `nonce` header. */
private fun pageNonce(url: String): String =
NONCE.find(get(url))?.groupValues?.get(1) ?: throw Exception("Dhiraagu page didn't load")
private fun get(url: String): String = client.newCall(
Request.Builder().url(url)
.header("User-Agent", UA)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.build()
).execute().use { r ->
if (r.code in 500..599) throw BankServerException("Dhiraagu")
r.body?.string().orEmpty()
}
private fun api(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
call(sub, act, nonce, body).getJSONObject("resp")
private fun apiList(sub: String, act: String, nonce: String, body: JSONObject): JSONArray =
call(sub, act, nonce, body).getJSONArray("resp")
/** POSTs to `sdk-dhr-webapi.ashx`; throws unless `respStatus` is OK. */
private fun call(sub: String, act: String, nonce: String, body: JSONObject): JSONObject {
val text = client.newCall(
Request.Builder().url("$API?website_id=$WEBSITE_ID&sub=$sub&act=$act")
.post(body.toString().toRequestBody(JSON))
.header("User-Agent", UA)
.header("Accept", "application/json, text/javascript, */*; q=0.01")
.header("X-Requested-With", "XMLHttpRequest")
.header("Origin", BASE)
.header("nonce", nonce)
.build()
).execute().use { r ->
if (r.code in 500..599) throw BankServerException("Dhiraagu")
r.body?.string().orEmpty()
}
val obj = try { JSONObject(text) } catch (_: Exception) {
throw Exception("Unexpected response from Dhiraagu")
}
if (obj.optString("respStatus") != "OK") {
throw Exception(obj.optString("respMsg").ifBlank { obj.optString("resp") }.ifBlank { "Dhiraagu refused the reload" })
}
return obj
}
companion object {
private const val BASE = "https://www.dhiraagu.com.mv"
private const val API = "$BASE/api/sdk-dhr-webapi.ashx"
private const val WEBSITE_ID = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
private val JSON = "application/json".toMediaType()
private val NONCE = Regex("""var nonce = "([^"]+)"""")
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
/** Easy TopUp's form id across cart / merchant / payment. */
private const val FORM_ID = 2
/** Bank of Maldives in `merchant&act=form` (1 = BML, 2 = MIB, 3 = DhiraaguPay). */
private const val GATEWAY_BML = 1
private const val GST_RATE = 0.08
/**
* The GST inside a GST-inclusive [amount], as the page works it out:
* `amount × rate / (1 + rate)`, to 2 places. The number is credited `amount − gst`.
*/
fun gstOf(amount: Int): BigDecimal {
val rate = BigDecimal(GST_RATE.toString())
return (BigDecimal(amount) * rate).divide(BigDecimal.ONE + rate, 2, RoundingMode.HALF_UP)
}
}
}
@@ -51,6 +51,9 @@ import sh.sar.basedbank.databinding.ItemPickerRowBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding 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.CardPayoutTransferHandler
import sh.sar.basedbank.ui.home.transfer.CarrierLookup
import sh.sar.basedbank.ui.home.transfer.FahipayService import sh.sar.basedbank.ui.home.transfer.FahipayService
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
@@ -158,6 +161,12 @@ class TransferFragment : Fragment() {
private fun fahipayHandler(): FahipayTransferHandler = private fun fahipayHandler(): FahipayTransferHandler =
fahipayHandler ?: FahipayTransferHandler(this, binding, viewModel).also { fahipayHandler = it } fahipayHandler ?: FahipayTransferHandler(this, binding, viewModel).also { fahipayHandler = it }
/** Lazy: created the first time a phone lookup offers carrier services by BML card. */
private var cardPayoutHandler: CardPayoutTransferHandler? = null
private fun cardPayoutHandler(): CardPayoutTransferHandler =
cardPayoutHandler ?: CardPayoutTransferHandler(this, binding, viewModel) { bmlHandler() }
.also { cardPayoutHandler = it }
/** Lazy: created the first time the user selects an MFAISA source account. */ /** Lazy: created the first time the user selects an MFAISA source account. */
private var mfaisaHandler: MfaisaTransferHandler? = null private var mfaisaHandler: MfaisaTransferHandler? = null
private fun mfaisaHandler(): MfaisaTransferHandler = private fun mfaisaHandler(): MfaisaTransferHandler =
@@ -625,6 +634,7 @@ class TransferFragment : Fragment() {
draft.transferTypeNumber = "" draft.transferTypeNumber = ""
draft.transferType = null draft.transferType = null
fahipayHandler?.clearState() fahipayHandler?.clearState()
cardPayoutHandler?.clearState()
transferTypeDialog?.dismiss() transferTypeDialog?.dismiss()
updateTransferButton() updateTransferButton()
} }
@@ -648,6 +658,12 @@ class TransferFragment : Fragment() {
// The default account when it can do Favara, otherwise leave the user to choose // The default account when it can do Favara, otherwise leave the user to choose
is TransferType.Favara -> accounts.firstOrNull { it.accountNumber == defaultNum && type.worksFrom(it) } is TransferType.Favara -> accounts.firstOrNull { it.accountNumber == defaultNum && type.worksFrom(it) }
is TransferType.Fahipay -> accounts.firstOrNull(type::worksFrom) is TransferType.Fahipay -> accounts.firstOrNull(type::worksFrom)
// The default card when it can pay, otherwise any card that can
is TransferType.Card -> {
val defaultCard = CredentialStore(requireContext()).getDefaultCardAccountNumber()
accounts.firstOrNull { it.accountNumber == defaultCard && type.worksFrom(it) }
?: accounts.firstOrNull(type::worksFrom)
}
} }
if (pick != null) selectSourceAccount(pick) if (pick != null) selectSourceAccount(pick)
else { else {
@@ -658,9 +674,17 @@ class TransferFragment : Fragment() {
when (type) { when (type) {
is TransferType.Favara -> { is TransferType.Favara -> {
fahipayHandler?.clearState() fahipayHandler?.clearState()
cardPayoutHandler?.clearState()
showFavaraRecipient(type.info) showFavaraRecipient(type.info)
} }
is TransferType.Fahipay -> fahipayHandler().applyService(type, number) is TransferType.Fahipay -> {
cardPayoutHandler?.clearState()
fahipayHandler().applyService(type, number)
}
is TransferType.Card -> {
fahipayHandler?.clearState()
cardPayoutHandler().applyService(type, number)
}
} }
updateTransferButton() updateTransferButton()
} }
@@ -1006,6 +1030,7 @@ class TransferFragment : Fragment() {
resolvedDestCurrency = "" resolvedDestCurrency = ""
resolvedToOwnAccount = null resolvedToOwnAccount = null
fahipayHandler?.clearState() fahipayHandler?.clearState()
cardPayoutHandler?.clearState()
if (!keepTransferTypes) resetTransferTypes() if (!keepTransferTypes) resetTransferTypes()
mfaisaHandler?.clearState() mfaisaHandler?.clearState()
binding.cardToInfo.visibility = View.GONE binding.cardToInfo.visibility = View.GONE
@@ -1145,6 +1170,12 @@ class TransferFragment : Fragment() {
lookupPhoneForAnySource(accountNumber) lookupPhoneForAnySource(accountNumber)
return return
} }
// A card that can pay by card: the number may be a carrier service it can pay too
if (selectedAccount?.let { BmlVerifiedCards.isPayable(requireContext(), it) } == true &&
AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
lookupPhoneForAnySource(accountNumber)
return
}
if (selectedAccount == null) { if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
@@ -1206,15 +1237,17 @@ class TransferFragment : Fragment() {
} }
/** /**
* A phone number searched before any source is picked. Runs the Favara lookup (through MIB * A phone number searched before any source is picked (or from a card that can pay by
* or BML, whichever is logged in) and, when the user has a Fahipay wallet, the Dhiraagu / * card). Runs the Favara lookup (through MIB or BML, whichever is logged in) and, when the
* Ooredoo carrier lookup — in parallel — and offers whatever came back as transfer types. * user has a Fahipay wallet or a card that can pay by card, the Dhiraagu / Ooredoo carrier
* lookup — in parallel — and offers whatever came back as transfer types.
*/ */
private fun lookupPhoneForAnySource(number: String) { private fun lookupPhoneForAnySource(number: String) {
val accounts = viewModel.accounts.value ?: emptyList() val accounts = viewModel.accounts.value ?: emptyList()
val hasFahipay = accounts.any { it.bank == "FAHIPAY" } val hasFahipay = accounts.any { it.bank == "FAHIPAY" }
val payableCards = cardPayoutHandler().payableCards()
val hasFavara = mibHandler.session != null || bmlSessionFor(null) != null val hasFavara = mibHandler.session != null || bmlSessionFor(null) != null
if (!hasFavara && !hasFahipay) { if (!hasFavara && !hasFahipay && payableCards.isEmpty()) {
Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show()
return return
} }
@@ -1222,19 +1255,22 @@ class TransferFragment : Fragment() {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
val preferBml = accounts.firstOrNull { it.accountNumber == defaultNum }?.bank == "BML" val preferBml = accounts.firstOrNull { it.accountNumber == defaultNum }?.bank == "BML"
val fahipay = fahipayHandler() val fahipay = fahipayHandler()
val cardPayout = cardPayoutHandler()
resetTransferTypes() resetTransferTypes()
startLookupLoading() startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val (favara, fahipayTypes) = withContext(Dispatchers.IO) { val (favara, carriers) = withContext(Dispatchers.IO) {
coroutineScope { coroutineScope {
val favara = async { if (hasFavara) lookupFavara(number, preferBml) else null to null } val favara = async { if (hasFavara) lookupFavara(number, preferBml) else null to null }
val fahipayTypes = async { if (hasFahipay) fahipay.lookupServices(number) else emptyList() } val carriers = async { if (hasFahipay || payableCards.isNotEmpty()) CarrierLookup.query(number) else null }
favara.await() to fahipayTypes.await() favara.await() to carriers.await()
} }
} }
stopLookupLoading() stopLookupLoading()
val types = listOfNotNull(favara.first?.let { TransferType.Favara(it) }) + fahipayTypes val fahipayTypes = carriers?.takeIf { hasFahipay }?.let(fahipay::typesFor).orEmpty()
val cardTypes = carriers?.let { cardPayout.typesFor(it, payableCards) }.orEmpty()
val types = listOfNotNull(favara.first?.let { TransferType.Favara(it) }) + fahipayTypes + cardTypes
if (types.isEmpty()) { if (types.isEmpty()) {
Toast.makeText(requireContext(), favara.second ?: getString(R.string.transfer_account_not_found), Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), favara.second ?: getString(R.string.transfer_account_not_found), Toast.LENGTH_SHORT).show()
return@launch return@launch
@@ -1487,6 +1523,13 @@ class TransferFragment : Fragment() {
return return
} }
// Carrier service by BML card: the carrier creates the BML transaction, then it's paid
// like a card-only merchant link
if (draft.cardPayoutService != null) {
cardPayoutHandler().submit()
return
}
// BML QR merchant payment — uses shared confirm dialog, no receipt // BML QR merchant payment — uses shared confirm dialog, no receipt
if (bmlHandler().hasQrMerchant) { if (bmlHandler().hasQrMerchant) {
bmlHandler().submitQrPayment() bmlHandler().submitQrPayment()
@@ -1779,6 +1822,23 @@ class TransferFragment : Fragment() {
} }
} }
/**
* A dialog showing only the processing spinner, for work that has to finish before the
* confirm dialog can be shown (e.g. a carrier creating the payment). Dismiss it when done.
*/
internal fun showProcessingDialog(title: String): AlertDialog {
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.hideSoftInputFromWindow(requireView().windowToken, 0)
val frame = android.widget.FrameLayout(requireContext())
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(title)
.setView(frame)
.setCancelable(false)
.show()
showProcessingInDialog(dialog, frame)
return dialog
}
internal fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) { internal fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
@@ -1929,10 +1989,12 @@ class TransferFragment : Fragment() {
// draft — build it so the amount rules still apply after the view is recreated. // draft — build it so the amount rules still apply after the view is recreated.
val fahipay = if (draft.fahipayService != null) fahipayHandler() else null val fahipay = if (draft.fahipayService != null) fahipayHandler() else null
fahipay?.syncAmountField() fahipay?.syncAmountField()
val cardPayout = if (draft.cardPayoutService != null) cardPayoutHandler() else null
cardPayout?.syncAmountField()
if (bmlHandler().isOtpFlowActive) return if (bmlHandler().isOtpFlowActive) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0 val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank() val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
val amountOk = amount > 0 && fahipay?.amountProblem == null val amountOk = amount > 0 && fahipay?.amountProblem == null && cardPayout?.amountProblem == null
val hasAll = selectedAccount != null && recipientReady && amountOk && !transferTypePending val hasAll = selectedAccount != null && recipientReady && amountOk && !transferTypePending
if (!hasAll) { binding.btnTransfer.isEnabled = false; return } if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
val errors = viewModel.connectivityErrors.value ?: emptySet() val errors = viewModel.connectivityErrors.value ?: emptySet()
@@ -2063,6 +2125,7 @@ class TransferFragment : Fragment() {
bmlHandler?.clearState() bmlHandler?.clearState()
bmlHandler = null bmlHandler = null
fahipayHandler = null fahipayHandler = null
cardPayoutHandler = null
mfaisaHandler = null mfaisaHandler = null
// Re-shown from the draft by the next view if still unanswered // Re-shown from the draft by the next view if still unanswered
transferTypeDialog?.setOnDismissListener(null) transferTypeDialog?.setOnDismissListener(null)
@@ -425,13 +425,8 @@ class BmlTransferHandler(
private val cardMerchant get() = draft.bmlCardMerchant private val cardMerchant get() = draft.bmlCardMerchant
/** A verified BML card we also hold a login (OTP seed) for — can go through the 3-D Secure step. */ /** A verified BML card we also hold a login (OTP seed) for — can go through the 3-D Secure step. */
private fun verifiedCardCandidates(): List<BankAccount> { private fun verifiedCardCandidates(): List<BankAccount> =
val store = CredentialStore(ctx) BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
val verifiedKeys = sh.sar.basedbank.util.VerifiedCardStore.keys(ctx)
return (viewModel.accounts.value ?: emptyList())
.filter { isCard(it) && verifiedKeys.contains("bml:${it.accountNumber}") }
.filter { store.loadBmlCredentials(it.loginTag.removePrefix("bml_"))?.otpSeed != null }
}
/** /**
* Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as * Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as
@@ -460,9 +455,7 @@ class BmlTransferHandler(
} }
} }
private fun isCardVerified(account: BankAccount): Boolean = private fun isCardVerified(account: BankAccount): Boolean = BmlVerifiedCards.isPayable(ctx, account)
isCard(account) && sh.sar.basedbank.util.VerifiedCardStore.isVerified(ctx, "bml:${account.accountNumber}") &&
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed != null
/** Paints the loaded card-only merchant into the "To" card and locks the amount. */ /** Paints the loaded card-only merchant into the "To" card and locks the amount. */
fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) { fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
@@ -503,7 +496,12 @@ class BmlTransferHandler(
confirmCardMerchant(page, src) confirmCardMerchant(page, src)
} }
private fun confirmCardMerchant( /**
* The card payment's confirm dialog (biometric-gated), then the Pomelo + 3-D Secure payment of
* [page] with [src]. Also the send step for carrier services paid by card
* ([CardPayoutTransferHandler]), once the carrier has created the BML transaction.
*/
fun confirmCardMerchant(
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage, page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
src: BankAccount src: BankAccount
) { ) {
@@ -543,22 +541,11 @@ class BmlTransferHandler(
dialog: AlertDialog, dialog: AlertDialog,
frame: android.widget.FrameLayout frame: android.widget.FrameLayout
) { ) {
val stored = sh.sar.basedbank.util.VerifiedCardStore.load(ctx, "bml:${src.accountNumber}") val (card, otpSeed) = BmlVerifiedCards.load(ctx, src) ?: run {
val loginId = src.loginTag.removePrefix("bml_")
val otpSeed = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
val expiry = stored?.expiry?.split("/") // "MM/YY"
if (stored == null || otpSeed == null || expiry?.size != 2) {
dialog.dismiss() dialog.dismiss()
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show() Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return return
} }
val card = sh.sar.basedbank.api.bml.BmlMerchantCardPayClient.Card(
pan = stored.pan,
expiryMonth = expiry[0].padStart(2, '0'),
expiryYear = expiry[1].takeLast(2),
cvv = stored.cvv,
holderName = src.accountBriefName
)
fragment.viewLifecycleOwner.lifecycleScope.launch { fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
@@ -570,15 +557,22 @@ class BmlTransferHandler(
} }
if (fragment.view == null) return@launch if (fragment.view == null) return@launch
when (result) { when (result) {
is BmlMerchantCardPayClient.Result.Success -> fragment.showSuccessInDialog( is BmlMerchantCardPayClient.Result.Success -> {
dialog, frame, fragment.showSuccessInDialog(
amountCurrency = page.currency, dialog, frame,
amountValue = "%.2f".format(page.amount), amountCurrency = page.currency,
fromName = src.accountBriefName, amountValue = "%.2f".format(page.amount),
toName = page.merchantName fromName = src.accountBriefName,
) { toName = page.merchantName
fragment.clearForm() ) {
host?.triggerRefresh() fragment.clearForm()
host?.triggerRefresh()
}
// Charged, but the merchant may not deliver until it's told
if (!result.merchantNotified) {
Toast.makeText(ctx, ctx.getString(R.string.bml_card_pay_merchant_not_notified,
page.merchantName, page.transactionId), Toast.LENGTH_LONG).show()
}
} }
is BmlMerchantCardPayClient.Result.Failure -> { is BmlMerchantCardPayClient.Result.Failure -> {
dialog.dismiss() dialog.dismiss()
@@ -962,6 +956,5 @@ class BmlTransferHandler(
} }
} }
private fun isCard(account: BankAccount) = private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account)
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
} }
@@ -0,0 +1,52 @@
package sh.sar.basedbank.ui.home.transfer
import android.content.Context
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.VerifiedCardStore
/**
* BML cards that can pay a merchant by card + 3-D Secure: the card is verified (full details in
* [VerifiedCardStore]) and belongs to a BML login we hold the OTP seed for, since the 3-D Secure
* step is answered with that login's token code.
*/
object BmlVerifiedCards {
/** A payable card's details, ready for [BmlMerchantCardPayClient.pay]. */
data class Payable(val card: BmlMerchantCardPayClient.Card, val otpSeed: String)
fun isCard(account: BankAccount) =
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
fun isPayable(ctx: Context, account: BankAccount): Boolean =
isCard(account) && VerifiedCardStore.isVerified(ctx, key(account)) && otpSeed(ctx, account) != null
fun payable(ctx: Context, accounts: List<BankAccount>): List<BankAccount> {
val verified = VerifiedCardStore.keys(ctx)
return accounts.filter { isCard(it) && key(it) in verified && otpSeed(ctx, it) != null }
}
/** The stored card details and OTP seed for [account], or null when it isn't payable. */
fun load(ctx: Context, account: BankAccount): Payable? {
val stored = VerifiedCardStore.load(ctx, key(account)) ?: return null
val seed = otpSeed(ctx, account) ?: return null
val expiry = stored.expiry.split("/") // "MM/YY"
if (expiry.size != 2) return null
return Payable(
card = BmlMerchantCardPayClient.Card(
pan = stored.pan,
expiryMonth = expiry[0].padStart(2, '0'),
expiryYear = expiry[1].takeLast(2),
cvv = stored.cvv,
holderName = account.accountBriefName
),
otpSeed = seed
)
}
private fun key(account: BankAccount) = "bml:${account.accountNumber}"
private fun otpSeed(ctx: Context, account: BankAccount) =
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed
}
@@ -0,0 +1,173 @@
package sh.sar.basedbank.ui.home.transfer
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguReloadClient
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import java.math.BigDecimal
import java.math.RoundingMode
/**
* A carrier service a verified BML card can pay, through the carrier's own website and its BML
* merchant gateway. The limits are the carrier website's, not Fahipay's.
*
* Only Dhiraagu reload so far. Add the others as constants once their send path lands; the
* exhaustive `when`s over this enum point at every site that needs updating.
*/
enum class CardPayoutService(
override val label: String,
override val destinationLabel: String,
@param:DrawableRes override val iconRes: Int,
override val minAmount: Int,
override val maxAmount: Int?,
override val decimalsAllowed: Boolean,
override val gstPercent: Int?,
) : PayoutService {
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8) {
// Dhiraagu rounds the GST to 2 places and credits the rest
override fun creditedAfterGst(amount: BigDecimal): BigDecimal =
amount - DhiraaguReloadClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt())
};
}
/**
* Owns the Transfer screen's "carrier service by BML card" parts: which services a looked-up
* number can be paid with by card, and the picked service's amount rules.
*
* Sending only differs from paying a card-only BML merchant link in where the BML transaction
* comes from: the carrier's website creates it for the number and amount. From there it goes
* through [BmlTransferHandler.confirmCardMerchant] — the same confirm dialog, card + 3-D Secure
* payment and result as a pasted link.
*
* Mirrors [FahipayTransferHandler]: the fragment keeps the recipient card and the form state.
* Lifetime is bound to the fragment's view.
*/
class CardPayoutTransferHandler(
private val fragment: TransferFragment,
private val binding: FragmentTransferBinding,
private val viewModel: HomeViewModel,
private val bmlHandler: () -> BmlTransferHandler,
) {
private val ctx get() = fragment.requireContext()
private val amountField = PayoutAmountField(binding) { ctx }
/** The service picked for the current recipient; null when none is. */
var service: CardPayoutService?
get() = viewModel.transferDraft.cardPayoutService
private set(value) { viewModel.transferDraft.cardPayoutService = value }
// ─── Public API the fragment calls ───────────────────────────────────────
/** Account numbers of the cards that can pay by card. Call on the main thread. */
fun payableCards(): Set<String> =
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
.map { it.accountNumber }.toSet()
/** The card transfer types a carrier lookup [result] allows, payable from [cards]. */
fun typesFor(result: CarrierLookup.Result, cards: Set<String>): List<TransferType.Card> {
if (cards.isEmpty()) return emptyList()
return buildList {
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(CardPayoutService.DHIRAAGU_RELOAD)
}.map { TransferType.Card(it, result.ownerName, cards) }
}
/** Forgets the picked service and gives back the amount and reference fields. */
fun clearState() {
if (service == null) return
service = null
amountField.reset()
}
/** Why the typed amount can't be sent with the picked service, or null when it can. */
val amountProblem: String?
get() = service?.let(amountField::problem)
/** Keeps the amount and reference fields in step with the picked service. */
fun syncAmountField() {
service?.let(amountField::sync)
}
/**
* Fills the recipient card for a picked card transfer type. The fragment has already
* switched the source to one of the type's cards.
*/
fun applyService(type: TransferType.Card, number: String) {
service = type.service
// None of the payouts take a reference; syncAmountField() disables the box
binding.etRemarks.setText("")
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = type.ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
fragment.prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "${type.label} · $number",
colorHex = "#E4002B",
imageHash = null
)
fragment.focusAmount()
}
// ─── Send ────────────────────────────────────────────────────────────────
/**
* Has the carrier create the BML transaction for the number and amount, then hands its
* payment page to the card merchant flow. Nothing is charged until that flow's confirm.
*/
fun submit() {
val svc = service ?: return
val src = viewModel.transferDraft.selectedAccount
if (src == null || !BmlVerifiedCards.isPayable(ctx, src)) {
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
val number = viewModel.transferDraft.transferTypeNumber
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
// Creating the order takes a few round trips; show the payment's processing box meanwhile
val processing = fragment.showProcessingDialog(ctx.getString(R.string.transfer))
fragment.viewLifecycleOwner.lifecycleScope.launch {
val page = withContext(Dispatchers.IO) {
runCatching {
val txnId = when (svc) {
CardPayoutService.DHIRAAGU_RELOAD ->
DhiraaguReloadClient().createBmlTransaction(number, amount.intValueExact())
}
BmlMerchantTxnClient().fetchPayPage(txnId)
}
}
processing.dismiss()
if (fragment.view == null) return@launch
page.onSuccess {
when {
!it.supportsCard ->
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
// The carrier's order must be for exactly what was typed
it.amount.toBigDecimal().compareTo(amount) != 0 ->
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
else -> bmlHandler().confirmCardMerchant(it, src)
}
}.onFailure { e ->
val msg = when {
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
!e.message.isNullOrBlank() -> e.message!!
else -> ctx.getString(R.string.transfer_bml_txn_lookup_failed)
}
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
}
}
}
}
@@ -0,0 +1,46 @@
package sh.sar.basedbank.ui.home.transfer
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.OoredooClient
/**
* Which carrier a phone number is on, and how it's billed. Feeds both payout routes: the
* Fahipay services and the BML card services each map this to what they can pay.
*/
object CarrierLookup {
data class Result(
val dhiraagu: DhiraaguClient.Result,
val ooredoo: OoredooClient.CustType,
) {
/** Dhiraagu is the only carrier that hands back an owner name. */
val ownerName: String? get() = dhiraagu.ownerName.takeIf { it.isNotBlank() }
}
/**
* Asks the likelier carrier first based on the leading digit and only falls back to the
* other when the first says it doesn't know the number. Blocking — call from IO.
*/
fun query(number: String): Result =
if (number.startsWith("7")) {
// Dhiraagu first, fall back to Ooredoo
val d = dhiraagu(number)
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
else OoredooClient.CustType.UNSUPPORTED
Result(d, o)
} else {
// Ooredoo first, fall back to Dhiraagu
val o = ooredoo(number)
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
Result(d, o)
}
private fun dhiraagu(number: String) =
try { DhiraaguClient().validateNumber(number) }
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
private fun ooredoo(number: String) =
try { OoredooClient().validateNumber(number) }
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
}
@@ -1,6 +1,5 @@
package sh.sar.basedbank.ui.home.transfer package sh.sar.basedbank.ui.home.transfer
import android.text.InputType
import android.widget.Toast import android.widget.Toast
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
@@ -20,37 +19,31 @@ import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.util.AccountInputParser import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import java.math.BigDecimal
import java.math.RoundingMode
/** /**
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to * A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
* a given number; [label] names it in the "Transfer Type" picker and the recipient card, * a given number. The limits are Fahipay's — the same carrier service paid by card
* [destinationLabel] in the confirm dialog's "To" block. * ([CardPayoutService]) has its own.
* *
* Wallet-to-wallet Fahipay transfer is not here yet. Add it as a constant once its send path * Wallet-to-wallet Fahipay transfer is not here yet. Add it as a constant once its send path
* lands, and the exhaustive `when`s over this enum will point at every site that needs updating. * lands, and the exhaustive `when`s over this enum will point at every site that needs updating.
*/ */
enum class FahipayService( enum class FahipayService(
val label: String, override val label: String,
val destinationLabel: String, override val destinationLabel: String,
@param:DrawableRes val iconRes: Int, @param:DrawableRes override val iconRes: Int,
/** The endpoint [FahipayPaymentClient.pay] POSTs this service's payments to. */ /** The endpoint [FahipayPaymentClient.pay] POSTs this service's payments to. */
val paymentPath: String, val paymentPath: String,
/** Smallest amount the service accepts, in MVR. */ override val minAmount: Int,
val minAmount: Int, override val maxAmount: Int?,
/** Largest amount the service accepts, in MVR, or null for no limit. */ override val decimalsAllowed: Boolean,
val maxAmount: Int?,
/** Whether the amount may have a fractional part (up to 2 decimal places). */
val decimalsAllowed: Boolean,
/** /**
* The contact category of this service's Fahipay favourites list (`BankContact.benefCategoryId`, * The contact category of this service's Fahipay favourites list (`BankContact.benefCategoryId`,
* set by `FahipayContactsClient`). Recents of this service are tagged with it too. * set by `FahipayContactsClient`). Recents of this service are tagged with it too.
*/ */
val contactCategory: String, val contactCategory: String,
/** GST the carrier takes out of the amount before crediting it, in percent, or null for none. */ override val gstPercent: Int? = null,
val gstPercent: Int? = null, ) : PayoutService {
) {
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo, RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
paymentPath = "actions/payment/ooredoo/recharge/", paymentPath = "actions/payment/ooredoo/recharge/",
minAmount = 11, maxAmount = null, decimalsAllowed = false, minAmount = 11, maxAmount = null, decimalsAllowed = false,
@@ -105,6 +98,8 @@ class FahipayTransferHandler(
get() = viewModel.transferDraft.fahipayService get() = viewModel.transferDraft.fahipayService
private set(value) { viewModel.transferDraft.fahipayService = value } private set(value) { viewModel.transferDraft.fahipayService = value }
private val amountField = PayoutAmountField(binding) { ctx }
/** How the confirm dialog names the destination, or "" when nothing is selected. */ /** How the confirm dialog names the destination, or "" when nothing is selected. */
val destinationLabel: String get() = service?.destinationLabel.orEmpty() val destinationLabel: String get() = service?.destinationLabel.orEmpty()
@@ -129,64 +124,22 @@ class FahipayTransferHandler(
fun clearState() { fun clearState() {
if (service == null) return if (service == null) return
service = null service = null
binding.tilAmount.error = null amountField.reset()
binding.tilAmount.helperText = null
binding.etAmount.inputType = DECIMAL_INPUT
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
} }
/** /**
* Why the typed amount can't be sent with the selected service, or null when it can (or the * Why the typed amount can't be sent with the selected service, or null when it can (or the
* field is empty, or no service is selected). Checks the service's minimum, maximum and * field is empty, or no service is selected).
* whether it takes decimals.
*/ */
val amountProblem: String? val amountProblem: String?
get() { get() = service?.let(amountField::problem)
val svc = service ?: return null
val text = binding.etAmount.text?.toString()?.trim().orEmpty()
if (text.isEmpty()) return null
val amount = text.toBigDecimalOrNull() ?: return ctx.getString(R.string.transfer_fahipay_amount_invalid)
val fraction = amount.stripTrailingZeros().scale()
return when {
!svc.decimalsAllowed && fraction > 0 -> ctx.getString(R.string.transfer_fahipay_amount_whole)
fraction > 2 -> ctx.getString(R.string.transfer_fahipay_amount_decimals)
amount < BigDecimal(svc.minAmount) -> ctx.getString(R.string.transfer_fahipay_amount_min, svc.minAmount)
svc.maxAmount != null && amount > BigDecimal(svc.maxAmount) ->
ctx.getString(R.string.transfer_fahipay_amount_max, "%,d".format(svc.maxAmount))
else -> null
}
}
/** /**
* Keeps the amount and reference fields in step with the selected service: the amount * Keeps the amount and reference fields in step with the selected service. A no-op when no
* error, the GST note under the amount, and the reference box disabled (none of the * service is selected; [clearState] puts the fields back.
* payouts take one). Idempotent — the fragment calls it on every form change. A no-op
* when no service is selected; [clearState] puts the fields back.
*/ */
fun syncAmountField() { fun syncAmountField() {
val svc = service ?: return service?.let(amountField::sync)
// Whole-number services get a keypad without a decimal point. Only set on change:
// setting inputType restarts the keyboard, and this runs on every keystroke.
val inputType = if (svc.decimalsAllowed) DECIMAL_INPUT else InputType.TYPE_CLASS_NUMBER
if (binding.etAmount.inputType != inputType) binding.etAmount.inputType = inputType
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
val problem = amountProblem
binding.tilAmount.error = problem
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
}
/**
* What the recipient is credited once GST comes out, for services that charge it. The
* amount paid is GST-inclusive, so the credit is amount / (1 + rate), rounded down.
*/
private fun gstNote(svc: FahipayService): String? {
val gst = svc.gstPercent ?: return null
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
val credited = amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
} }
/** /**
@@ -212,15 +165,10 @@ class FahipayTransferHandler(
fragment.focusAmount() fragment.focusAmount()
} }
/**
* The Fahipay transfer types [number] can be paid with, from the carrier lookup. Dhiraagu is /** The Fahipay transfer types a carrier lookup [result] allows. */
* the only carrier that hands back an owner name. Blocking — call from IO. fun typesFor(result: CarrierLookup.Result): List<TransferType.Fahipay> =
*/ servicesFor(result).map { TransferType.Fahipay(it, result.ownerName) }
fun lookupServices(number: String): List<TransferType.Fahipay> {
val result = queryCarriers(number)
val ownerName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
return servicesFor(result).map { TransferType.Fahipay(it, ownerName) }
}
// ─── Send ──────────────────────────────────────────────────────────────── // ─── Send ────────────────────────────────────────────────────────────────
@@ -252,7 +200,7 @@ class FahipayTransferHandler(
toName = toName, toName = toName,
toNumber = number, toNumber = number,
toDetail = svc.destinationLabel, toDetail = svc.destinationLabel,
warningTexts = listOfNotNull(gstNote(svc)) warningTexts = listOfNotNull(amountField.gstNote(svc))
) )
fragment.showConfirmWithBiometric( fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer), title = ctx.getString(R.string.transfer),
@@ -314,58 +262,21 @@ class FahipayTransferHandler(
// ─── Carrier lookup ────────────────────────────────────────────────────── // ─── Carrier lookup ──────────────────────────────────────────────────────
private data class CarrierResult(
val dhiraagu: DhiraaguClient.Result,
val ooredoo: OoredooClient.CustType
)
private fun lookupCarrier(number: String) { private fun lookupCarrier(number: String) {
fragment.resetTransferTypes() fragment.resetTransferTypes()
fragment.startLookupLoading() fragment.startLookupLoading()
fragment.viewLifecycleOwner.lifecycleScope.launch { fragment.viewLifecycleOwner.lifecycleScope.launch {
val types = withContext(Dispatchers.IO) { lookupServices(number) } val types = withContext(Dispatchers.IO) { typesFor(CarrierLookup.query(number)) }
fragment.stopLookupLoading() fragment.stopLookupLoading()
if (types.isEmpty()) return@launch if (types.isEmpty()) return@launch
fragment.offerTransferTypes(number, types) fragment.offerTransferTypes(number, types)
} }
} }
/** private fun servicesFor(result: CarrierLookup.Result): List<FahipayService> = buildList {
* Asks the likelier carrier first based on the leading digit and only falls back to the
* other when the first says it doesn't know the number. Blocking — call from IO.
*/
private fun queryCarriers(number: String): CarrierResult =
if (number.startsWith("7")) {
// Dhiraagu first, fall back to Ooredoo
val d = dhiraagu(number)
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
else OoredooClient.CustType.UNSUPPORTED
CarrierResult(d, o)
} else {
// Ooredoo first, fall back to Dhiraagu
val o = ooredoo(number)
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
CarrierResult(d, o)
}
private fun dhiraagu(number: String) =
try { DhiraaguClient().validateNumber(number) }
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
private fun ooredoo(number: String) =
try { OoredooClient().validateNumber(number) }
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
private fun servicesFor(result: CarrierResult): List<FahipayService> = buildList {
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD) if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL) if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS) if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL) if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
} }
private companion object {
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
}
} }
@@ -0,0 +1,107 @@
package sh.sar.basedbank.ui.home.transfer
import android.content.Context
import android.text.InputType
import androidx.annotation.DrawableRes
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentTransferBinding
import java.math.BigDecimal
import java.math.RoundingMode
/**
* A carrier service a phone number can be paid with (reload, Raastas, bill pay), whichever
* route pays it — the Fahipay wallet ([FahipayService]) or a verified BML card
* ([CardPayoutService]). Each route has its own limits, so each has its own constants.
*/
interface PayoutService {
/** Names it in the "Transfer Type" picker and the recipient card. */
val label: String
/** Names it in the confirm dialog's "To" block, e.g. "Ooredoo · Raastas". */
val destinationLabel: String
@get:DrawableRes val iconRes: Int
/** Smallest amount the service accepts, in MVR. */
val minAmount: Int
/** Largest amount the service accepts, in MVR, or null for no limit. */
val maxAmount: Int?
/** Whether the amount may have a fractional part (up to 2 decimal places). */
val decimalsAllowed: Boolean
/** GST the carrier takes out of the amount before crediting it, in percent, or null for none. */
val gstPercent: Int?
/**
* What the number is credited for a GST-inclusive [amount]: amount / (1 + rate), rounded
* down. Null when the service charges no GST.
*/
fun creditedAfterGst(amount: BigDecimal): BigDecimal? {
val gst = gstPercent ?: return null
return amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
}
}
/**
* Keeps the Transfer screen's amount and reference fields in step with a picked
* [PayoutService]: the amount error, the GST note under the amount, the keypad, and the
* reference box disabled (none of the payouts take one). Shared by the Fahipay and card routes.
*/
class PayoutAmountField(
private val binding: FragmentTransferBinding,
private val context: () -> Context,
) {
/**
* Why the typed amount can't be sent with [svc], or null when it can (or the field is
* empty). Checks the service's minimum, maximum and whether it takes decimals.
*/
fun problem(svc: PayoutService): String? {
val ctx = context()
val text = binding.etAmount.text?.toString()?.trim().orEmpty()
if (text.isEmpty()) return null
val amount = text.toBigDecimalOrNull() ?: return ctx.getString(R.string.transfer_fahipay_amount_invalid)
val fraction = amount.stripTrailingZeros().scale()
return when {
!svc.decimalsAllowed && fraction > 0 -> ctx.getString(R.string.transfer_fahipay_amount_whole)
fraction > 2 -> ctx.getString(R.string.transfer_fahipay_amount_decimals)
amount < BigDecimal(svc.minAmount) -> ctx.getString(R.string.transfer_fahipay_amount_min, svc.minAmount)
svc.maxAmount != null && amount > BigDecimal(svc.maxAmount!!) ->
ctx.getString(R.string.transfer_fahipay_amount_max, "%,d".format(svc.maxAmount))
else -> null
}
}
/** Idempotent — the fragment calls it on every form change. */
fun sync(svc: PayoutService) {
// Whole-number services get a keypad without a decimal point. Only set on change:
// setting inputType restarts the keyboard, and this runs on every keystroke.
val inputType = if (svc.decimalsAllowed) DECIMAL_INPUT else InputType.TYPE_CLASS_NUMBER
if (binding.etAmount.inputType != inputType) binding.etAmount.inputType = inputType
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
val problem = problem(svc)
binding.tilAmount.error = problem
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
}
/** What the recipient is credited once GST comes out, for services that charge it. */
fun gstNote(svc: PayoutService): String? {
val gst = svc.gstPercent ?: return null
val ctx = context()
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
val credited = svc.creditedAfterGst(amount) ?: return null
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
}
/** Gives back the amount and reference fields once no service is picked. */
fun reset() {
binding.tilAmount.error = null
binding.tilAmount.helperText = null
binding.etAmount.inputType = DECIMAL_INPUT
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
}
private companion object {
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
}
}
@@ -57,4 +57,7 @@ class TransferDraft {
// Fahipay // Fahipay
var fahipayService: FahipayService? = null var fahipayService: FahipayService? = null
// Carrier service paid by verified BML card
var cardPayoutService: CardPayoutService? = null
} }
@@ -9,8 +9,8 @@ import sh.sar.basedbank.api.models.BankAccount
* One option in the Transfer screen's "Transfer Type" picker: a way of paying the number in * One option in the Transfer screen's "Transfer Type" picker: a way of paying the number in
* the "To" field. Picking one also decides the source account — see [worksFrom]. * the "To" field. Picking one also decides the source account — see [worksFrom].
* *
* Add new kinds (BML verified card, …) as subclasses; the exhaustive `when`s over this type * Add new kinds as subclasses; the exhaustive `when`s over this type point at every site that
* point at every site that needs updating. * needs updating.
*/ */
sealed interface TransferType { sealed interface TransferType {
val label: String val label: String
@@ -39,4 +39,17 @@ sealed interface TransferType {
override val badgeRes get() = R.drawable.fahipay_logo override val badgeRes get() = R.drawable.fahipay_logo
override fun worksFrom(account: BankAccount) = account.bank == "FAHIPAY" override fun worksFrom(account: BankAccount) = account.bank == "FAHIPAY"
} }
/**
* A carrier service (reload, …) paid by verified BML card through the carrier's own BML
* merchant gateway. [cards] are the account numbers of the cards that can pay it — verified,
* with an OTP seed for the 3-D Secure step — worked out when the lookup ran.
*/
data class Card(val service: CardPayoutService, val ownerName: String?, val cards: Set<String>) : TransferType {
override val label get() = service.label
override val subtitle get() = listOfNotNull(ownerName, "by BML card").joinToString(" · ")
override val iconRes get() = service.iconRes
override val badgeRes get() = R.drawable.bml_logo_vector
override fun worksFrom(account: BankAccount) = account.bank == "BML" && account.accountNumber in cards
}
} }
+1
View File
@@ -306,6 +306,7 @@
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string> <string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
<string name="bml_card_pay_no_verified">No verified card available. Verify a BML card first in Manage Card.</string> <string name="bml_card_pay_no_verified">No verified card available. Verify a BML card first in Manage Card.</string>
<string name="bml_card_pay_already_paid">This payment has already been completed.</string> <string name="bml_card_pay_already_paid">This payment has already been completed.</string>
<string name="bml_card_pay_merchant_not_notified">Paid, but %1$s couldn\'t be notified. If it isn\'t credited, contact them with BML transaction %2$s.</string>
<string name="bml_qr_payment_success">Payment Successful</string> <string name="bml_qr_payment_success">Payment Successful</string>
<string name="bml_qr_select_account">Select a BML account to pay from</string> <string name="bml_qr_select_account">Select a BML account to pay from</string>