From a75d8e420a8dca6d84b4162af14f5d587b6acd45 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Mon, 21 Sep 2026 07:56:46 +0500 Subject: [PATCH] split mfaisa qr pay --- .../sar/basedbank/ui/home/TransferFragment.kt | 254 ++--------------- .../ui/home/transfer/MfaisaTransferHandler.kt | 257 +++++++++++++++++- 2 files changed, 266 insertions(+), 245 deletions(-) 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 f4375b5..9906fb9 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 @@ -89,9 +89,6 @@ class TransferFragment : Fragment() { private var savedToColorHex = "#607D8B" private var savedToImageHash: String? = null - // M-Faisa QR merchant payment mode (set when the scanned QR is an M-Faisa qrCodeId) - private var mfaisaQrInfo: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant? = null - private val dropdownProfileImageCache = mutableMapOf() private var accountDropdownAdapter: AccountDropdownAdapter? = null @@ -108,12 +105,7 @@ class TransferFragment : Fragment() { binding = binding, viewModel = viewModel, currentSource = { selectedAccount }, - selectSource = { account -> - selectedAccount = account - updateAmountPrefix(account) - showFromCard(account) - updateTransferButton() - }, + selectSource = ::selectSourceAccount, onStateChanged = { updateTransferButton() }, onTransferSuccess = { receipt, avatar -> ReceiptStore.save(requireContext(), receipt) @@ -136,6 +128,8 @@ class TransferFragment : Fragment() { fragment = this, binding = binding, viewModel = viewModel, + currentSource = { selectedAccount }, + selectSource = ::selectSourceAccount, onRecipientChanged = { // The handler resolved or cleared a recipient — keep the resolvedAccountNumber // mirror in sync so the shared `updateTransferButton()` / clearForm() logic works. @@ -195,7 +189,7 @@ class TransferFragment : Fragment() { val app = requireActivity().application as BasedBankApp if (trimmedRaw.length in 8..16 && trimmedRaw.all { it.isDigit() } && app.mfaisaSessions.isNotEmpty()) { - lookupMfaisaQrMerchant(trimmedRaw) + mfaisaHandler().lookupQrMerchant(trimmedRaw) return } @@ -321,7 +315,7 @@ class TransferFragment : Fragment() { if (accountNumber.startsWith("mfaisaqr:")) { // M-Faisa QR merchant recent — re-run the lookup so the merchant stays current // (price / status can change) and so the source auto-switch path is taken. - lookupMfaisaQrMerchant(accountNumber.removePrefix("mfaisaqr:")) + mfaisaHandler().lookupQrMerchant(accountNumber.removePrefix("mfaisaqr:")) return@setFragmentResultListener } // MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the @@ -416,93 +410,8 @@ class TransferFragment : Fragment() { } } - /** - * Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an - * M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills - * the amount if the QR is dynamic. - */ - private fun lookupMfaisaQrMerchant(qrCodeId: String) { - val app = requireActivity().application as BasedBankApp - // Prefer the currently-selected MFAISA account's session; otherwise fall back to any session. - val source = (selectedAccount?.takeIf { it.bank == "MFAISA" } - ?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }) - ?: run { - Toast.makeText(requireContext(), "No M-Faisa account available", Toast.LENGTH_SHORT).show() - return - } - val session = app.mfaisaSessionFor(source) ?: run { - Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() - return - } - - // Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually - if (selectedAccount?.bank != "MFAISA") { - selectedAccount = source - updateAmountPrefix(source) - showFromCard(source) - } - - // Lock the "To" input row while loading - binding.tilTo.visibility = View.GONE - binding.btnPickContact.visibility = View.GONE - binding.btnScanQr.visibility = View.GONE - (activity as? HomeActivity)?.setRefreshing(true) - - viewLifecycleOwner.lifecycleScope.launch { - val merchant = withContext(Dispatchers.IO) { - try { - sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient().fetchQrDetails(session, qrCodeId) - } catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) { - val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_")) - ?: return@withContext null - try { sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) } - catch (_: Exception) { null } - } catch (_: Exception) { null } - } - (activity as? HomeActivity)?.setRefreshing(false) - if (merchant == null) { - Toast.makeText(requireContext(), "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show() - resetToFieldVisibility() - return@launch - } - mfaisaQrInfo = merchant - - // Static QRs (no preset amount) make sense to keep in Recents — the merchant is - // reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay. - if (merchant.txnAmount.isNullOrBlank()) { - RecentsCache.save(requireContext(), RecentPick( - accountNumber = "mfaisaqr:${merchant.qrCodeId}", - displayName = merchant.merchantName, - subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}", - colorHex = "#ED1C24", - imageHash = null, - isProfileImage = false, - bank = "MFAISA" - )) - } - - // Show merchant in the "To" card — clear button is the only way to back out - binding.tvToAccountName.text = merchant.merchantName - binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}" - binding.tvToAccountDetails.visibility = View.GONE - binding.tvToBalance.visibility = View.GONE - binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER - binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo) - binding.cardToInfo.visibility = View.VISIBLE - - // Pre-fill + lock amount if the QR is dynamic - val dynamicAmount = merchant.txnAmount?.toDoubleOrNull() - if (dynamicAmount != null && dynamicAmount > 0.0) { - binding.etAmount.setText("%.2f".format(dynamicAmount)) - binding.tilAmount.isEnabled = false - } - - updateTransferButton() - } - } - /** Restores the To-input row to its default state when a QR lookup fails. */ - private fun resetToFieldVisibility() { + internal fun resetToFieldVisibility() { binding.cardToInfo.visibility = View.GONE binding.tilTo.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE @@ -556,7 +465,7 @@ class TransferFragment : Fragment() { return@setOnItemClickListener } } - if (mfaisaQrInfo != null && picked.bank != "MFAISA") { + if (mfaisaHandler().hasQrMerchant && picked.bank != "MFAISA") { Toast.makeText(requireContext(), "Unsupported for M-Faisa QR — select an M-Faisa account", Toast.LENGTH_SHORT).show() binding.actvFrom.setText("", false) return@setOnItemClickListener @@ -789,6 +698,17 @@ class TransferFragment : Fragment() { } } + /** + * Makes [account] the source and repaints everything that depends on it. Handed to the + * QR-capable handlers so a scanned merchant QR can switch the source to the right bank. + */ + private fun selectSourceAccount(account: BankAccount) { + selectedAccount = account + updateAmountPrefix(account) + showFromCard(account) + updateTransferButton() + } + private fun updateAmountPrefix(account: BankAccount) { binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR " } @@ -805,13 +725,7 @@ class TransferFragment : Fragment() { binding.btnClearToInfo.setOnClickListener { bmlHandler().clearQrMerchant() - if (mfaisaQrInfo != null) { - mfaisaQrInfo = null - binding.tilAmount.isEnabled = true - binding.tilRemarks.isEnabled = true - binding.tilRemarks.alpha = 1f - binding.etAmount.setText("") - } + mfaisaHandler().clearQrMerchant() resolvedAccountNumber = "" resolvedRecipientName = "" resolvedDestCurrency = "" @@ -1118,42 +1032,9 @@ class TransferFragment : Fragment() { } private fun initiateTransfer() { - // M-Faisa merchant QR — same confirm popup as other transfers. The /initiateNewBuy + - // /confirmNewBuy pair does NOT require OTP for wallet QR pay (2FARequired=NONE). - mfaisaQrInfo?.let { merchant -> - val src = selectedAccount ?: run { - Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() - return - } - if (src.bank != "MFAISA") { - Toast.makeText(requireContext(), "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show() - return - } - val amountStr = binding.etAmount.text?.toString()?.trim() ?: "" - val amount = amountStr.toDoubleOrNull() - if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return } - binding.tilAmount.error = null - val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty() - - val confirmView = buildTransferConfirmView( - amountCurrency = merchant.currencyCode, - amountValue = "%.2f".format(amount), - fromName = src.accountBriefName, - fromNumber = src.accountNumber, - fromDetail = "M-Faisa", - toName = merchant.merchantName, - toNumber = merchant.merchantMsisdn, - toDetail = "Ooredoo M-Faisa merchant" - ) - showConfirmWithBiometric( - title = getString(R.string.transfer), - customView = confirmView, - biometricSubtitle = "${merchant.currencyCode} ${"%.2f".format(amount)} → ${merchant.merchantName}", - onConfirmed = { dialog, frame -> - showProcessingInDialog(dialog, frame) - executeMfaisaQrPayment(src, merchant, amount, "%.2f".format(amount), remarks, dialog, frame) - } - ) + // M-Faisa merchant QR — same confirm popup as other transfers, no OTP. + if (mfaisaHandler().hasQrMerchant) { + mfaisaHandler().submitQrPayment() return } @@ -1447,93 +1328,6 @@ class TransferFragment : Fragment() { } } - private fun executeMfaisaQrPayment( - src: BankAccount, - merchant: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant, - amount: Double, - amountStr: String, - remarks: String, - dialog: AlertDialog, - frame: android.widget.FrameLayout - ) { - val app = requireActivity().application as BasedBankApp - val loginId = src.loginTag.removePrefix("mfaisa_") - val initialSession = app.mfaisaSessionFor(src) ?: run { - dialog.dismiss() - Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() - return - } - // M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the - // bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber]. - val sourceMdn = "960${initialSession.msisdn}" - - binding.btnTransfer.isEnabled = false - - viewLifecycleOwner.lifecycleScope.launch { - val outcome = withContext(Dispatchers.IO) { - val client = sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient() - try { - val refId = try { - client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks) - } catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) { - val fresh = app.refreshMfaisaSession(loginId) - ?: throw IllegalStateException("Could not refresh M-Faisa session") - client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks) - } - val confirmSession = app.mfaisaSessionFor(src) ?: initialSession - try { - client.confirmPurchase(confirmSession, refId) - } catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) { - val fresh = app.refreshMfaisaSession(loginId) - ?: throw IllegalStateException("Could not refresh M-Faisa session") - client.confirmPurchase(fresh, refId) - } - Result.success(refId) - } catch (e: Exception) { - Result.failure(e) - } - } - if (_binding == null) return@launch - - outcome.fold( - onSuccess = { _ -> - val receipt = TransferReceiptData( - bank = "MFAISA", - amount = amountStr, - currency = merchant.currencyCode, - fromLabel = src.accountBriefName, - fromColorHex = "#ED1C24", - toLabel = merchant.merchantName, - toAccount = merchant.merchantMsisdn, - toBank = "Ooredoo M-Faisa", - remarks = remarks, - mfaisaTransactionType = "Merchant payment", - mfaisaFromName = src.accountBriefName, - mfaisaFromMsisdn = src.accountNumber, - mfaisaToMsisdn = merchant.merchantMsisdn, - mfaisaTimestamp = System.currentTimeMillis() - ) - ReceiptStore.save(requireContext(), receipt) - dialog.dismiss() - clearForm() - val activity = requireActivity() as HomeActivity - activity.triggerRefresh() - activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, null)) - }, - onFailure = { e -> - dialog.dismiss() - binding.btnTransfer.isEnabled = true - val msg = when { - e is java.io.IOException -> getString(R.string.connectivity_no_internet) - !e.message.isNullOrBlank() -> e.message!! - else -> "Payment failed" - } - Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show() - } - ) - } - } - 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 @@ -1678,7 +1472,7 @@ class TransferFragment : Fragment() { private fun updateTransferButton() { if (bmlHandler().isOtpFlowActive) return val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0 - val recipientReady = bmlHandler().hasQrMerchant || mfaisaQrInfo != null || resolvedAccountNumber.isNotBlank() + val recipientReady = bmlHandler().hasQrMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank() val hasAll = selectedAccount != null && recipientReady && amount > 0 if (!hasAll) { binding.btnTransfer.isEnabled = false; return } val errors = viewModel.connectivityErrors.value ?: emptySet() @@ -1690,7 +1484,7 @@ class TransferFragment : Fragment() { internal fun clearForm() { bmlHandler().resetOtpState() mfaisaHandler?.clearState() - mfaisaQrInfo = null + mfaisaHandler?.clearQrMerchant() selectedAccount = null binding.actvFrom.setText("", false) binding.cardFromInfo.visibility = View.GONE diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt index dc8aead..1926223 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt @@ -8,6 +8,7 @@ import android.view.View import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat @@ -22,6 +23,7 @@ import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException +import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient @@ -37,16 +39,25 @@ import sh.sar.basedbank.util.RecentsCache /** * Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup, - * initiate-with-OTP, and confirm-with-OTP. Lives alongside [sh.sar.basedbank.ui.home.TransferFragment] - * which dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source. + * initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay + * (qrCodeId lookup + initiate/confirm purchase). Lives alongside [TransferFragment] which + * dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source, or whenever + * an M-Faisa merchant QR is loaded. + * + * Mirrors [BmlTransferHandler]: the fragment keeps the shared confirm dialog, the recipient card + * and the form state; the handler keeps everything M-Faisa-specific. * * Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for * [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated. */ class MfaisaTransferHandler( - private val fragment: Fragment, + private val fragment: TransferFragment, private val binding: FragmentTransferBinding, private val viewModel: HomeViewModel, + /** Reads the fragment's currently-selected source account. */ + private val currentSource: () -> BankAccount?, + /** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */ + private val selectSource: (BankAccount) -> Unit, /** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */ private val onRecipientChanged: () -> Unit, /** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */ @@ -55,11 +66,16 @@ class MfaisaTransferHandler( private val app get() = fragment.requireActivity().application as BasedBankApp private val ctx get() = fragment.requireContext() + private val host get() = fragment.activity as? HomeActivity /** Set to the resolved recipient after a successful search; null otherwise. */ var recipient: MfaisaTransferClient.Recipient? = null private set + /** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */ + var qrInfo: MfaisaQrPayClient.QrMerchant? = null + private set + private var lookupInFlight = false // ─── Public API the fragment calls ─────────────────────────────────────── @@ -80,7 +96,7 @@ class MfaisaTransferHandler( } binding.tilTo.error = null - val source = currentSource() ?: return + val source = mfaisaSource() ?: return val session = app.mfaisaSessionFor(source) ?: run { Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() return @@ -122,7 +138,7 @@ class MfaisaTransferHandler( /** Triggered when the user taps the Send button (and source bank is MFAISA). */ fun submit() { - val source = currentSource() ?: return + val source = mfaisaSource() ?: return val r = recipient ?: run { Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show() return @@ -153,20 +169,232 @@ class MfaisaTransferHandler( } } + /** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */ + val hasQrMerchant: Boolean get() = qrInfo != null + /** Called when the source account changes away from M-Faisa (or the view tears down). */ fun clearState() { recipient = null lookupInFlight = false } + /** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */ + fun clearQrMerchant() { + if (qrInfo == null) return + qrInfo = null + binding.tilAmount.isEnabled = true + binding.tilRemarks.isEnabled = true + binding.tilRemarks.alpha = 1f + binding.etAmount.setText("") + } + + // ─── Merchant QR ───────────────────────────────────────────────────────── + + /** + * Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an + * M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills + * the amount if the QR is dynamic. + */ + fun lookupQrMerchant(qrCodeId: String) { + val source = mfaisaSource() ?: run { + Toast.makeText(ctx, "No M-Faisa account available", Toast.LENGTH_SHORT).show() + return + } + val session = app.mfaisaSessionFor(source) ?: run { + Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() + return + } + + // Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually + if (currentSource()?.bank != "MFAISA") selectSource(source) + + // Lock the "To" input row while loading + binding.tilTo.visibility = View.GONE + binding.btnPickContact.visibility = View.GONE + binding.btnScanQr.visibility = View.GONE + host?.setRefreshing(true) + + fragment.viewLifecycleOwner.lifecycleScope.launch { + val merchant = withContext(Dispatchers.IO) { + try { + MfaisaQrPayClient().fetchQrDetails(session, qrCodeId) + } catch (_: MfaisaSessionExpiredException) { + val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_")) + ?: return@withContext null + try { MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) } + catch (_: Exception) { null } + } catch (_: Exception) { null } + } + host?.setRefreshing(false) + if (merchant == null) { + Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show() + fragment.resetToFieldVisibility() + return@launch + } + qrInfo = merchant + + // Static QRs (no preset amount) make sense to keep in Recents — the merchant is + // reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay. + if (merchant.txnAmount.isNullOrBlank()) { + RecentsCache.save(ctx, RecentPick( + accountNumber = "mfaisaqr:${merchant.qrCodeId}", + displayName = merchant.merchantName, + subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}", + colorHex = "#ED1C24", + imageHash = null, + isProfileImage = false, + bank = "MFAISA" + )) + } + + // Show merchant in the "To" card — clear button is the only way to back out + binding.tvToAccountName.text = merchant.merchantName + binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}" + binding.tvToAccountDetails.visibility = View.GONE + binding.tvToBalance.visibility = View.GONE + binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER + binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo) + binding.cardToInfo.visibility = View.VISIBLE + + // Pre-fill + lock amount if the QR is dynamic + val dynamicAmount = merchant.txnAmount?.toDoubleOrNull() + if (dynamicAmount != null && dynamicAmount > 0.0) { + binding.etAmount.setText("%.2f".format(dynamicAmount)) + binding.tilAmount.isEnabled = false + } + + onRecipientChanged() + } + } + + /** + * Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog — + * the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay + * (2FARequired=NONE), so unlike [submit] there is no code to enter. + */ + fun submitQrPayment() { + val merchant = qrInfo ?: return + val src = currentSource() ?: run { + Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() + return + } + if (src.bank != "MFAISA") { + Toast.makeText(ctx, "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show() + return + } + val amountStr = binding.etAmount.text?.toString()?.trim() ?: "" + val amount = amountStr.toDoubleOrNull() + if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return } + binding.tilAmount.error = null + val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty() + val amountValue = "%.2f".format(amount) + + val confirmView = fragment.buildTransferConfirmView( + amountCurrency = merchant.currencyCode, + amountValue = amountValue, + fromName = src.accountBriefName, + fromNumber = src.accountNumber, + fromDetail = "M-Faisa", + toName = merchant.merchantName, + toNumber = merchant.merchantMsisdn, + toDetail = "Ooredoo M-Faisa merchant" + ) + fragment.showConfirmWithBiometric( + title = ctx.getString(R.string.transfer), + customView = confirmView, + biometricSubtitle = "${merchant.currencyCode} $amountValue → ${merchant.merchantName}", + onConfirmed = { dialog, frame -> + fragment.showProcessingInDialog(dialog, frame) + executeQrPayment(src, merchant, amountValue, remarks, dialog) + } + ) + } + + private fun executeQrPayment( + src: BankAccount, + merchant: MfaisaQrPayClient.QrMerchant, + amountStr: String, + remarks: String, + dialog: AlertDialog + ) { + val loginId = src.loginTag.removePrefix("mfaisa_") + val initialSession = app.mfaisaSessionFor(src) ?: run { + dialog.dismiss() + Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() + return + } + // M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the + // bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber]. + val sourceMdn = "960${initialSession.msisdn}" + + binding.btnTransfer.isEnabled = false + + fragment.viewLifecycleOwner.lifecycleScope.launch { + val outcome = withContext(Dispatchers.IO) { + val client = MfaisaQrPayClient() + try { + val refId = try { + client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks) + } catch (_: MfaisaSessionExpiredException) { + val fresh = app.refreshMfaisaSession(loginId) + ?: throw IllegalStateException("Could not refresh M-Faisa session") + client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks) + } + val confirmSession = app.mfaisaSessionFor(src) ?: initialSession + try { + client.confirmPurchase(confirmSession, refId) + } catch (_: MfaisaSessionExpiredException) { + val fresh = app.refreshMfaisaSession(loginId) + ?: throw IllegalStateException("Could not refresh M-Faisa session") + client.confirmPurchase(fresh, refId) + } + Result.success(refId) + } catch (e: Exception) { + Result.failure(e) + } + } + if (fragment.view == null) return@launch + + outcome.fold( + onSuccess = { _ -> + val receipt = TransferReceiptData( + bank = "MFAISA", + amount = amountStr, + currency = merchant.currencyCode, + fromLabel = src.accountBriefName, + fromColorHex = "#ED1C24", + toLabel = merchant.merchantName, + toAccount = merchant.merchantMsisdn, + toBank = "Ooredoo M-Faisa", + remarks = remarks, + mfaisaTransactionType = "Merchant payment", + mfaisaFromName = src.accountBriefName, + mfaisaFromMsisdn = src.accountNumber, + mfaisaToMsisdn = merchant.merchantMsisdn, + mfaisaTimestamp = System.currentTimeMillis() + ) + dialog.dismiss() + onTransferSuccess(receipt, null) + }, + onFailure = { e -> + dialog.dismiss() + binding.btnTransfer.isEnabled = true + showError(e, fallback = "Payment failed") + } + ) + } + } + // ─── Internal ──────────────────────────────────────────────────────────── - private fun currentSource(): BankAccount? = - viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" && it.accountNumber == sourceAccountNumberFromCard() } - ?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } // fallback if from-card field isn't easily readable - - private fun sourceAccountNumberFromCard(): String = - binding.tvFromAccountNumber.text?.toString().orEmpty() + /** + * The M-Faisa account to act on: the selected source when it is one, otherwise the first + * M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch + * the source away from another bank. + */ + private fun mfaisaSource(): BankAccount? = + currentSource()?.takeIf { it.bank == "MFAISA" } + ?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) { // Reuse the same recipient card the fragment uses for other banks. The fragment owns the @@ -241,7 +469,6 @@ class MfaisaTransferHandler( refId: String, errorMsg: String? ) { - val tf = fragment as? TransferFragment ?: return val view = fragment.view ?: return val dp = ctx.resources.displayMetrics.density val colorMuted = MaterialColors.getColor( @@ -250,7 +477,7 @@ class MfaisaTransferHandler( view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY) val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr } - val confirmView = tf.buildTransferConfirmView( + val confirmView = fragment.buildTransferConfirmView( amountCurrency = "MVR", amountValue = amountValue, fromName = source.accountBriefName, @@ -415,11 +642,11 @@ class MfaisaTransferHandler( } } - private fun showError(e: Exception) { + private fun showError(e: Throwable, fallback: String = "Transfer failed") { val msg = when { e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet) !e.message.isNullOrBlank() -> e.message!! - else -> "Transfer failed" + else -> fallback } Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show() }