forked from thijooree/android
add support for reload via BML veried cards
This commit is contained in:
@@ -10,6 +10,7 @@ import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
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.spec.MGF1ParameterSpec
|
||||
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
|
||||
* result to the Mastercard gateway, which posts it back to BML's `mpgsNotification`.
|
||||
* 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
|
||||
* session cookies.
|
||||
@@ -49,7 +53,8 @@ class BmlMerchantCardPayClient {
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -130,7 +135,7 @@ class BmlMerchantCardPayClient {
|
||||
var threeDsUrl: String? = null
|
||||
for (attempt in 0..MAX_POLLS) {
|
||||
when (action.optString("action")) {
|
||||
"TRANSACTION_CONFIRMED" -> return Result.Success
|
||||
"TRANSACTION_CONFIRMED" -> return confirmed(txnId)
|
||||
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
|
||||
}
|
||||
threeDsUrl = action.optString("3dsUrl").ifBlank { null }
|
||||
@@ -146,7 +151,7 @@ class BmlMerchantCardPayClient {
|
||||
// 5. Wait for the gateway's verdict to reach BML
|
||||
repeat(MAX_POLLS * 2) {
|
||||
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")
|
||||
}
|
||||
Thread.sleep(POLL_MS / 2)
|
||||
@@ -154,6 +159,31 @@ class BmlMerchantCardPayClient {
|
||||
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. */
|
||||
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
|
||||
@@ -297,5 +327,7 @@ class BmlMerchantCardPayClient {
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private const val POLL_MS = 5_000L
|
||||
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.ItemTransferTypeBinding
|
||||
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.FahipayTransferHandler
|
||||
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
||||
@@ -158,6 +161,12 @@ class TransferFragment : Fragment() {
|
||||
private fun fahipayHandler(): FahipayTransferHandler =
|
||||
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. */
|
||||
private var mfaisaHandler: MfaisaTransferHandler? = null
|
||||
private fun mfaisaHandler(): MfaisaTransferHandler =
|
||||
@@ -625,6 +634,7 @@ class TransferFragment : Fragment() {
|
||||
draft.transferTypeNumber = ""
|
||||
draft.transferType = null
|
||||
fahipayHandler?.clearState()
|
||||
cardPayoutHandler?.clearState()
|
||||
transferTypeDialog?.dismiss()
|
||||
updateTransferButton()
|
||||
}
|
||||
@@ -648,6 +658,12 @@ class TransferFragment : Fragment() {
|
||||
// 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.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)
|
||||
else {
|
||||
@@ -658,9 +674,17 @@ class TransferFragment : Fragment() {
|
||||
when (type) {
|
||||
is TransferType.Favara -> {
|
||||
fahipayHandler?.clearState()
|
||||
cardPayoutHandler?.clearState()
|
||||
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()
|
||||
}
|
||||
@@ -1006,6 +1030,7 @@ class TransferFragment : Fragment() {
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
fahipayHandler?.clearState()
|
||||
cardPayoutHandler?.clearState()
|
||||
if (!keepTransferTypes) resetTransferTypes()
|
||||
mfaisaHandler?.clearState()
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
@@ -1145,6 +1170,12 @@ class TransferFragment : Fragment() {
|
||||
lookupPhoneForAnySource(accountNumber)
|
||||
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) {
|
||||
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
|
||||
* or BML, whichever is logged in) and, when the user has a Fahipay wallet, the Dhiraagu /
|
||||
* Ooredoo carrier lookup — in parallel — and offers whatever came back as transfer types.
|
||||
* A phone number searched before any source is picked (or from a card that can pay by
|
||||
* card). Runs the Favara lookup (through MIB or BML, whichever is logged in) and, when the
|
||||
* 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) {
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val hasFahipay = accounts.any { it.bank == "FAHIPAY" }
|
||||
val payableCards = cardPayoutHandler().payableCards()
|
||||
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()
|
||||
return
|
||||
}
|
||||
@@ -1222,19 +1255,22 @@ class TransferFragment : Fragment() {
|
||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
val preferBml = accounts.firstOrNull { it.accountNumber == defaultNum }?.bank == "BML"
|
||||
val fahipay = fahipayHandler()
|
||||
val cardPayout = cardPayoutHandler()
|
||||
|
||||
resetTransferTypes()
|
||||
startLookupLoading()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val (favara, fahipayTypes) = withContext(Dispatchers.IO) {
|
||||
val (favara, carriers) = withContext(Dispatchers.IO) {
|
||||
coroutineScope {
|
||||
val favara = async { if (hasFavara) lookupFavara(number, preferBml) else null to null }
|
||||
val fahipayTypes = async { if (hasFahipay) fahipay.lookupServices(number) else emptyList() }
|
||||
favara.await() to fahipayTypes.await()
|
||||
val carriers = async { if (hasFahipay || payableCards.isNotEmpty()) CarrierLookup.query(number) else null }
|
||||
favara.await() to carriers.await()
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
Toast.makeText(requireContext(), favara.second ?: getString(R.string.transfer_account_not_found), Toast.LENGTH_SHORT).show()
|
||||
return@launch
|
||||
@@ -1487,6 +1523,13 @@ class TransferFragment : Fragment() {
|
||||
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
|
||||
if (bmlHandler().hasQrMerchant) {
|
||||
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) {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.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.
|
||||
val fahipay = if (draft.fahipayService != null) fahipayHandler() else null
|
||||
fahipay?.syncAmountField()
|
||||
val cardPayout = if (draft.cardPayoutService != null) cardPayoutHandler() else null
|
||||
cardPayout?.syncAmountField()
|
||||
if (bmlHandler().isOtpFlowActive) return
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||
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
|
||||
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||
@@ -2063,6 +2125,7 @@ class TransferFragment : Fragment() {
|
||||
bmlHandler?.clearState()
|
||||
bmlHandler = null
|
||||
fahipayHandler = null
|
||||
cardPayoutHandler = null
|
||||
mfaisaHandler = null
|
||||
// Re-shown from the draft by the next view if still unanswered
|
||||
transferTypeDialog?.setOnDismissListener(null)
|
||||
|
||||
@@ -425,13 +425,8 @@ class BmlTransferHandler(
|
||||
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. */
|
||||
private fun verifiedCardCandidates(): List<BankAccount> {
|
||||
val store = CredentialStore(ctx)
|
||||
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 }
|
||||
}
|
||||
private fun verifiedCardCandidates(): List<BankAccount> =
|
||||
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
isCard(account) && sh.sar.basedbank.util.VerifiedCardStore.isVerified(ctx, "bml:${account.accountNumber}") &&
|
||||
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed != null
|
||||
private fun isCardVerified(account: BankAccount): Boolean = BmlVerifiedCards.isPayable(ctx, account)
|
||||
|
||||
/** Paints the loaded card-only merchant into the "To" card and locks the amount. */
|
||||
fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
|
||||
@@ -503,7 +496,12 @@ class BmlTransferHandler(
|
||||
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,
|
||||
src: BankAccount
|
||||
) {
|
||||
@@ -543,22 +541,11 @@ class BmlTransferHandler(
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout
|
||||
) {
|
||||
val stored = sh.sar.basedbank.util.VerifiedCardStore.load(ctx, "bml:${src.accountNumber}")
|
||||
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) {
|
||||
val (card, otpSeed) = BmlVerifiedCards.load(ctx, src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||
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 {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
@@ -570,15 +557,22 @@ class BmlTransferHandler(
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
when (result) {
|
||||
is BmlMerchantCardPayClient.Result.Success -> fragment.showSuccessInDialog(
|
||||
dialog, frame,
|
||||
amountCurrency = page.currency,
|
||||
amountValue = "%.2f".format(page.amount),
|
||||
fromName = src.accountBriefName,
|
||||
toName = page.merchantName
|
||||
) {
|
||||
fragment.clearForm()
|
||||
host?.triggerRefresh()
|
||||
is BmlMerchantCardPayClient.Result.Success -> {
|
||||
fragment.showSuccessInDialog(
|
||||
dialog, frame,
|
||||
amountCurrency = page.currency,
|
||||
amountValue = "%.2f".format(page.amount),
|
||||
fromName = src.accountBriefName,
|
||||
toName = page.merchantName
|
||||
) {
|
||||
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 -> {
|
||||
dialog.dismiss()
|
||||
@@ -962,6 +956,5 @@ class BmlTransferHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCard(account: BankAccount) =
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||
private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import android.text.InputType
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.DrawableRes
|
||||
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.util.AccountInputParser
|
||||
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 given number; [label] names it in the "Transfer Type" picker and the recipient card,
|
||||
* [destinationLabel] in the confirm dialog's "To" block.
|
||||
* a given number. The limits are Fahipay's — the same carrier service paid by card
|
||||
* ([CardPayoutService]) has its own.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
enum class FahipayService(
|
||||
val label: String,
|
||||
val destinationLabel: String,
|
||||
@param:DrawableRes val iconRes: Int,
|
||||
override val label: String,
|
||||
override val destinationLabel: String,
|
||||
@param:DrawableRes override val iconRes: Int,
|
||||
/** The endpoint [FahipayPaymentClient.pay] POSTs this service's payments to. */
|
||||
val paymentPath: String,
|
||||
/** 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,
|
||||
override val minAmount: Int,
|
||||
override val maxAmount: Int?,
|
||||
override val decimalsAllowed: Boolean,
|
||||
/**
|
||||
* The contact category of this service's Fahipay favourites list (`BankContact.benefCategoryId`,
|
||||
* set by `FahipayContactsClient`). Recents of this service are tagged with it too.
|
||||
*/
|
||||
val contactCategory: String,
|
||||
/** GST the carrier takes out of the amount before crediting it, in percent, or null for none. */
|
||||
val gstPercent: Int? = null,
|
||||
) {
|
||||
override val gstPercent: Int? = null,
|
||||
) : PayoutService {
|
||||
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
|
||||
paymentPath = "actions/payment/ooredoo/recharge/",
|
||||
minAmount = 11, maxAmount = null, decimalsAllowed = false,
|
||||
@@ -105,6 +98,8 @@ class FahipayTransferHandler(
|
||||
get() = viewModel.transferDraft.fahipayService
|
||||
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. */
|
||||
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||
|
||||
@@ -129,64 +124,22 @@ class FahipayTransferHandler(
|
||||
fun clearState() {
|
||||
if (service == null) return
|
||||
service = null
|
||||
binding.tilAmount.error = null
|
||||
binding.tilAmount.helperText = null
|
||||
binding.etAmount.inputType = DECIMAL_INPUT
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
amountField.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* whether it takes decimals.
|
||||
* field is empty, or no service is selected).
|
||||
*/
|
||||
val amountProblem: String?
|
||||
get() {
|
||||
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
|
||||
}
|
||||
}
|
||||
get() = service?.let(amountField::problem)
|
||||
|
||||
/**
|
||||
* Keeps the amount and reference fields in step with the selected service: the amount
|
||||
* error, the GST note under the amount, and the reference box disabled (none of the
|
||||
* 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.
|
||||
* Keeps the amount and reference fields in step with the selected service. A no-op when no
|
||||
* service is selected; [clearState] puts the fields back.
|
||||
*/
|
||||
fun syncAmountField() {
|
||||
val svc = service ?: return
|
||||
// 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)
|
||||
service?.let(amountField::sync)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,15 +165,10 @@ class FahipayTransferHandler(
|
||||
fragment.focusAmount()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Fahipay transfer types [number] can be paid with, from the carrier lookup. Dhiraagu is
|
||||
* the only carrier that hands back an owner name. Blocking — call from IO.
|
||||
*/
|
||||
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) }
|
||||
}
|
||||
|
||||
/** The Fahipay transfer types a carrier lookup [result] allows. */
|
||||
fun typesFor(result: CarrierLookup.Result): List<TransferType.Fahipay> =
|
||||
servicesFor(result).map { TransferType.Fahipay(it, result.ownerName) }
|
||||
|
||||
// ─── Send ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -252,7 +200,7 @@ class FahipayTransferHandler(
|
||||
toName = toName,
|
||||
toNumber = number,
|
||||
toDetail = svc.destinationLabel,
|
||||
warningTexts = listOfNotNull(gstNote(svc))
|
||||
warningTexts = listOfNotNull(amountField.gstNote(svc))
|
||||
)
|
||||
fragment.showConfirmWithBiometric(
|
||||
title = ctx.getString(R.string.transfer),
|
||||
@@ -314,58 +262,21 @@ class FahipayTransferHandler(
|
||||
|
||||
// ─── Carrier lookup ──────────────────────────────────────────────────────
|
||||
|
||||
private data class CarrierResult(
|
||||
val dhiraagu: DhiraaguClient.Result,
|
||||
val ooredoo: OoredooClient.CustType
|
||||
)
|
||||
|
||||
private fun lookupCarrier(number: String) {
|
||||
fragment.resetTransferTypes()
|
||||
fragment.startLookupLoading()
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val types = withContext(Dispatchers.IO) { lookupServices(number) }
|
||||
val types = withContext(Dispatchers.IO) { typesFor(CarrierLookup.query(number)) }
|
||||
fragment.stopLookupLoading()
|
||||
if (types.isEmpty()) return@launch
|
||||
fragment.offerTransferTypes(number, types)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
private fun servicesFor(result: CarrierLookup.Result): List<FahipayService> = buildList {
|
||||
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.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)
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
* 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
|
||||
* point at every site that needs updating.
|
||||
* Add new kinds as subclasses; the exhaustive `when`s over this type point at every site that
|
||||
* needs updating.
|
||||
*/
|
||||
sealed interface TransferType {
|
||||
val label: String
|
||||
@@ -39,4 +39,17 @@ sealed interface TransferType {
|
||||
override val badgeRes get() = R.drawable.fahipay_logo
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,7 @@
|
||||
<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_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_select_account">Select a BML account to pay from</string>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user