split mfaisa qr pay
Auto Tag on Version Change / check-version (push) Successful in 4s

This commit is contained in:
2026-09-21 07:56:46 +05:00
parent ca6ecc4283
commit a75d8e420a
2 changed files with 266 additions and 245 deletions
@@ -89,9 +89,6 @@ class TransferFragment : Fragment() {
private var savedToColorHex = "#607D8B" private var savedToColorHex = "#607D8B"
private var savedToImageHash: String? = null 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<String, Bitmap>() private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
private var accountDropdownAdapter: AccountDropdownAdapter? = null private var accountDropdownAdapter: AccountDropdownAdapter? = null
@@ -108,12 +105,7 @@ class TransferFragment : Fragment() {
binding = binding, binding = binding,
viewModel = viewModel, viewModel = viewModel,
currentSource = { selectedAccount }, currentSource = { selectedAccount },
selectSource = { account -> selectSource = ::selectSourceAccount,
selectedAccount = account
updateAmountPrefix(account)
showFromCard(account)
updateTransferButton()
},
onStateChanged = { updateTransferButton() }, onStateChanged = { updateTransferButton() },
onTransferSuccess = { receipt, avatar -> onTransferSuccess = { receipt, avatar ->
ReceiptStore.save(requireContext(), receipt) ReceiptStore.save(requireContext(), receipt)
@@ -136,6 +128,8 @@ class TransferFragment : Fragment() {
fragment = this, fragment = this,
binding = binding, binding = binding,
viewModel = viewModel, viewModel = viewModel,
currentSource = { selectedAccount },
selectSource = ::selectSourceAccount,
onRecipientChanged = { onRecipientChanged = {
// The handler resolved or cleared a recipient — keep the resolvedAccountNumber // The handler resolved or cleared a recipient — keep the resolvedAccountNumber
// mirror in sync so the shared `updateTransferButton()` / clearForm() logic works. // mirror in sync so the shared `updateTransferButton()` / clearForm() logic works.
@@ -195,7 +189,7 @@ class TransferFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
if (trimmedRaw.length in 8..16 && trimmedRaw.all { it.isDigit() } && if (trimmedRaw.length in 8..16 && trimmedRaw.all { it.isDigit() } &&
app.mfaisaSessions.isNotEmpty()) { app.mfaisaSessions.isNotEmpty()) {
lookupMfaisaQrMerchant(trimmedRaw) mfaisaHandler().lookupQrMerchant(trimmedRaw)
return return
} }
@@ -321,7 +315,7 @@ class TransferFragment : Fragment() {
if (accountNumber.startsWith("mfaisaqr:")) { if (accountNumber.startsWith("mfaisaqr:")) {
// M-Faisa QR merchant recent — re-run the lookup so the merchant stays current // 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. // (price / status can change) and so the source auto-switch path is taken.
lookupMfaisaQrMerchant(accountNumber.removePrefix("mfaisaqr:")) mfaisaHandler().lookupQrMerchant(accountNumber.removePrefix("mfaisaqr:"))
return@setFragmentResultListener return@setFragmentResultListener
} }
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the // 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. */ /** 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.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE
@@ -556,7 +465,7 @@ class TransferFragment : Fragment() {
return@setOnItemClickListener 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() Toast.makeText(requireContext(), "Unsupported for M-Faisa QR — select an M-Faisa account", Toast.LENGTH_SHORT).show()
binding.actvFrom.setText("", false) binding.actvFrom.setText("", false)
return@setOnItemClickListener 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) { private fun updateAmountPrefix(account: BankAccount) {
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR " binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
} }
@@ -805,13 +725,7 @@ class TransferFragment : Fragment() {
binding.btnClearToInfo.setOnClickListener { binding.btnClearToInfo.setOnClickListener {
bmlHandler().clearQrMerchant() bmlHandler().clearQrMerchant()
if (mfaisaQrInfo != null) { mfaisaHandler().clearQrMerchant()
mfaisaQrInfo = null
binding.tilAmount.isEnabled = true
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
}
resolvedAccountNumber = "" resolvedAccountNumber = ""
resolvedRecipientName = "" resolvedRecipientName = ""
resolvedDestCurrency = "" resolvedDestCurrency = ""
@@ -1118,42 +1032,9 @@ class TransferFragment : Fragment() {
} }
private fun initiateTransfer() { private fun initiateTransfer() {
// M-Faisa merchant QR — same confirm popup as other transfers. The /initiateNewBuy + // M-Faisa merchant QR — same confirm popup as other transfers, no OTP.
// /confirmNewBuy pair does NOT require OTP for wallet QR pay (2FARequired=NONE). if (mfaisaHandler().hasQrMerchant) {
mfaisaQrInfo?.let { merchant -> mfaisaHandler().submitQrPayment()
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)
}
)
return 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<String>(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) { 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
@@ -1678,7 +1472,7 @@ class TransferFragment : Fragment() {
private fun updateTransferButton() { private fun updateTransferButton() {
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 || mfaisaQrInfo != null || resolvedAccountNumber.isNotBlank() val recipientReady = bmlHandler().hasQrMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
val hasAll = selectedAccount != null && recipientReady && amount > 0 val hasAll = selectedAccount != null && recipientReady && amount > 0
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()
@@ -1690,7 +1484,7 @@ class TransferFragment : Fragment() {
internal fun clearForm() { internal fun clearForm() {
bmlHandler().resetOtpState() bmlHandler().resetOtpState()
mfaisaHandler?.clearState() mfaisaHandler?.clearState()
mfaisaQrInfo = null mfaisaHandler?.clearQrMerchant()
selectedAccount = null selectedAccount = null
binding.actvFrom.setText("", false) binding.actvFrom.setText("", false)
binding.cardFromInfo.visibility = View.GONE binding.cardFromInfo.visibility = View.GONE
@@ -8,6 +8,7 @@ import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -22,6 +23,7 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException 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.MfaisaRecipientNotFoundException
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient 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, * 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] * initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay
* which dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source. * (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 * 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. * [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
*/ */
class MfaisaTransferHandler( class MfaisaTransferHandler(
private val fragment: Fragment, private val fragment: TransferFragment,
private val binding: FragmentTransferBinding, private val binding: FragmentTransferBinding,
private val viewModel: HomeViewModel, 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. */ /** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
private val onRecipientChanged: () -> Unit, private val onRecipientChanged: () -> Unit,
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */ /** 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 app get() = fragment.requireActivity().application as BasedBankApp
private val ctx get() = fragment.requireContext() 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. */ /** Set to the resolved recipient after a successful search; null otherwise. */
var recipient: MfaisaTransferClient.Recipient? = null var recipient: MfaisaTransferClient.Recipient? = null
private set 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 private var lookupInFlight = false
// ─── Public API the fragment calls ─────────────────────────────────────── // ─── Public API the fragment calls ───────────────────────────────────────
@@ -80,7 +96,7 @@ class MfaisaTransferHandler(
} }
binding.tilTo.error = null binding.tilTo.error = null
val source = currentSource() ?: return val source = mfaisaSource() ?: return
val session = app.mfaisaSessionFor(source) ?: run { val session = app.mfaisaSessionFor(source) ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return return
@@ -122,7 +138,7 @@ class MfaisaTransferHandler(
/** Triggered when the user taps the Send button (and source bank is MFAISA). */ /** Triggered when the user taps the Send button (and source bank is MFAISA). */
fun submit() { fun submit() {
val source = currentSource() ?: return val source = mfaisaSource() ?: return
val r = recipient ?: run { val r = recipient ?: run {
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show() Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
return 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). */ /** Called when the source account changes away from M-Faisa (or the view tears down). */
fun clearState() { fun clearState() {
recipient = null recipient = null
lookupInFlight = false 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<String>(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 ──────────────────────────────────────────────────────────── // ─── Internal ────────────────────────────────────────────────────────────
private fun currentSource(): BankAccount? = /**
viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" && it.accountNumber == sourceAccountNumberFromCard() } * The M-Faisa account to act on: the selected source when it is one, otherwise the first
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } // fallback if from-card field isn't easily readable * M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch
* the source away from another bank.
private fun sourceAccountNumberFromCard(): String = */
binding.tvFromAccountNumber.text?.toString().orEmpty() private fun mfaisaSource(): BankAccount? =
currentSource()?.takeIf { it.bank == "MFAISA" }
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) { private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the // Reuse the same recipient card the fragment uses for other banks. The fragment owns the
@@ -241,7 +469,6 @@ class MfaisaTransferHandler(
refId: String, refId: String,
errorMsg: String? errorMsg: String?
) { ) {
val tf = fragment as? TransferFragment ?: return
val view = fragment.view ?: return val view = fragment.view ?: return
val dp = ctx.resources.displayMetrics.density val dp = ctx.resources.displayMetrics.density
val colorMuted = MaterialColors.getColor( val colorMuted = MaterialColors.getColor(
@@ -250,7 +477,7 @@ class MfaisaTransferHandler(
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY) view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr } val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
val confirmView = tf.buildTransferConfirmView( val confirmView = fragment.buildTransferConfirmView(
amountCurrency = "MVR", amountCurrency = "MVR",
amountValue = amountValue, amountValue = amountValue,
fromName = source.accountBriefName, 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 { val msg = when {
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet) e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
!e.message.isNullOrBlank() -> e.message!! !e.message.isNullOrBlank() -> e.message!!
else -> "Transfer failed" else -> fallback
} }
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show() Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
} }