diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt index 441b025..1478327 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantCardPayClient.kt @@ -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 ?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 `?wait=1`, + * which 302s to the merchant's `redirectUrl` (`…?transactionId=&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 } } diff --git a/app/src/main/java/sh/sar/basedbank/api/dhiraagu/DhiraaguReloadClient.kt b/app/src/main/java/sh/sar/basedbank/api/dhiraagu/DhiraaguReloadClient.kt new file mode 100644 index 0000000..a48ec99 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/dhiraagu/DhiraaguReloadClient.kt @@ -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) + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 9c1a245..130e8f3 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -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) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt index 41bc842..fd5f6bd 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlTransferHandler.kt @@ -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 { - 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 = + 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) } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlVerifiedCards.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlVerifiedCards.kt new file mode 100644 index 0000000..e0b1b4f --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/BmlVerifiedCards.kt @@ -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): List { + 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 +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/CardPayoutTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/CardPayoutTransferHandler.kt new file mode 100644 index 0000000..8219309 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/CardPayoutTransferHandler.kt @@ -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 = + 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): List { + 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() + } + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/CarrierLookup.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/CarrierLookup.kt new file mode 100644 index 0000000..f3b3920 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/CarrierLookup.kt @@ -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 } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt index 7d61038..f798f22 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/FahipayTransferHandler.kt @@ -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 { - 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 = + 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 = buildList { + private fun servicesFor(result: CarrierLookup.Result): List = 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 - } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/PayoutService.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/PayoutService.kt new file mode 100644 index 0000000..5eddd55 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/PayoutService.kt @@ -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 + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt index cf7f784..38d607d 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferDraft.kt @@ -57,4 +57,7 @@ class TransferDraft { // Fahipay var fahipayService: FahipayService? = null + + // Carrier service paid by verified BML card + var cardPayoutService: CardPayoutService? = null } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferType.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferType.kt index 57363da..9dba0e3 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferType.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/TransferType.kt @@ -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) : 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 + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 648c896..cf15e47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -306,6 +306,7 @@ Could not load BML payment for this transaction ID No verified card available. Verify a BML card first in Manage Card. This payment has already been completed. + Paid, but %1$s couldn\'t be notified. If it isn\'t credited, contact them with BML transaction %2$s. Payment Successful Select a BML account to pay from