add support for reload via BML veried cards
Auto Tag on Version Change / check-version (push) Successful in 3s
Auto Tag on Version Change / check-version (push) Successful in 3s
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user