From c0944d38092c38d9086492f44b0b365a2a698f10 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Fri, 2 Oct 2026 19:51:52 +0500 Subject: [PATCH] prep for reload/raastas part 3: add GST notice, max and min values and make contact picker and contacts page behave correctly for fahipay contacts --- .../basedbank/ui/home/ContactPickerAdapter.kt | 8 +- .../ui/home/ContactPickerSheetFragment.kt | 13 +- .../sar/basedbank/ui/home/ContactsFragment.kt | 3 +- .../sar/basedbank/ui/home/TransferFragment.kt | 44 ++++++- .../home/transfer/FahipayTransferHandler.kt | 115 +++++++++++++++++- .../sh/sar/basedbank/util/RecentsCache.kt | 10 +- .../util/fahipayapi/FahipayContactParser.kt | 4 +- app/src/main/res/values/strings.xml | 7 ++ docs/thijooree/20-transfer-flows.md | 48 +++++++- 9 files changed, 229 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerAdapter.kt index 85569b9..cfaae9b 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerAdapter.kt @@ -15,7 +15,7 @@ import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding class ContactPickerAdapter( private val imageCache: MutableMap, - private val onItemClick: (accountNumber: String, label: String) -> Unit, + private val onItemClick: (accountNumber: String, label: String, categoryId: String?) -> Unit, private val onSameAsFrom: () -> Unit, private val onImageNeeded: ((hash: String) -> Unit)? = null, private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null @@ -33,7 +33,9 @@ class ContactPickerAdapter( val imageHash: String? = null, val inactiveReason: String? = null, val balance: String? = null, - val bankLogoRes: Int? = null + val bankLogoRes: Int? = null, + /** The saved contact's category, for rows that are contacts; null otherwise. */ + val categoryId: String? = null ) : PickerItem() } @@ -125,7 +127,7 @@ class ContactPickerAdapter( Toast.makeText(binding.root.context, item.inactiveReason, Toast.LENGTH_SHORT).show() item.isSameAsFrom -> Toast.makeText(binding.root.context, R.string.transfer_same_account, Toast.LENGTH_SHORT).show() - else -> onItemClick(item.accountNumber, item.displayName) + else -> onItemClick(item.accountNumber, item.displayName, item.categoryId) } } binding.root.setOnLongClickListener { view -> diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt index 3c271ba..80f9bda 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt @@ -54,7 +54,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { val pageAdapters: List = pages.mapIndexed { i, page -> ContactPickerAdapter( imageCache = sharedImageCache, - onItemClick = { accountNumber, label -> handlePickerSelection(accountNumber, label) }, + onItemClick = { accountNumber, label, categoryId -> handlePickerSelection(accountNumber, label, categoryId) }, onSameAsFrom = {}, onImageNeeded = { hash -> fetchImage(hash) }, onItemLongClick = { accountNumber, anchor -> @@ -161,12 +161,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { }.also { it.attach() } } - private fun handlePickerSelection(accountNumber: String, label: String) { + private fun handlePickerSelection(accountNumber: String, label: String, categoryId: String?) { val contacts = viewModel.contacts.value ?: emptyList() val accounts = viewModel.accounts.value ?: emptyList() val contact = contacts.firstOrNull { it.benefAccount == accountNumber } val account = accounts.firstOrNull { it.accountNumber == accountNumber } val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label) + categoryId?.let { bundle.putString(KEY_CATEGORY, it) } when { accountNumber.startsWith("bmlqr:") -> { bundle.putString(KEY_SUBTITLE, "BML QR Merchant") @@ -226,7 +227,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { imageHash = r.imageHash, // A MFAISA-tagged recent is itself a valid M-Faisa recipient — don't grey it out // when the source is M-Faisa. - inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive + inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive, + categoryId = r.contactCategory )) } return items @@ -328,7 +330,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { colorHex = contact.bankColor, isSameAsFrom = contact.benefAccount == fromAccountNumber, imageHash = contact.customerImgHash, - inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc) + inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc), + categoryId = contact.benefCategoryId )) } return items @@ -396,6 +399,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() { const val KEY_SUBTITLE = "subtitle" const val KEY_COLOR = "color" const val KEY_IMAGE_HASH = "imageHash" + /** The picked contact's category (`BankContact.benefCategoryId`); absent for non-contacts. */ + const val KEY_CATEGORY = "category" private const val ARG_FROM_ACCOUNT = "fromAccount" private const val RECENTS_TAG = "__recents__" private const val MY_ACCOUNTS_TAG = "__my_accounts__" diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt index f1cb4a0..4dc9fa3 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactsFragment.kt @@ -188,7 +188,8 @@ class ContactsFragment : Fragment() { displayName = contact.name, subtitle = contact.transferSubtitle, colorHex = contact.bankColor, - imageHash = contact.imageHash + imageHash = contact.imageHash, + contactCategory = contact.categoryId ) (requireActivity() as HomeActivity).showWithBackStack(fragment) } 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 5a0a517..09c564f 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,7 @@ 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.FahipayService import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler import sh.sar.basedbank.ui.home.transfer.MibTransferHandler @@ -277,6 +278,7 @@ class TransferFragment : Fragment() { private const val ARG_SUBTITLE = "contact_subtitle" private const val ARG_COLOR = "contact_color" private const val ARG_IMAGE_HASH = "contact_image_hash" + private const val ARG_CONTACT_CATEGORY = "contact_category" private const val ARG_FROM_ACCOUNT = "from_account" private const val ARG_AMOUNT_PREFILL = "amount_prefill" private const val ARG_REMARKS_PREFILL = "remarks_prefill" @@ -311,7 +313,9 @@ class TransferFragment : Fragment() { displayName: String, subtitle: String, colorHex: String, - imageHash: String? + imageHash: String?, + /** The contact's category; a Fahipay favourites list opens as that payout service. */ + contactCategory: String? = null ) = TransferFragment().apply { arguments = Bundle().apply { putString(ARG_ACCOUNT, accountNumber) @@ -319,6 +323,7 @@ class TransferFragment : Fragment() { putString(ARG_SUBTITLE, subtitle) putString(ARG_COLOR, colorHex) if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash) + if (contactCategory != null) putString(ARG_CONTACT_CATEGORY, contactCategory) } } @@ -393,6 +398,9 @@ class TransferFragment : Fragment() { return@setFragmentResultListener } val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: "" + if (applyFahipayContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) { + return@setFragmentResultListener + } val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B" val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH) @@ -435,9 +443,11 @@ class TransferFragment : Fragment() { private fun applyArguments() { // Pre-select contact if navigated from contacts page or QR scan arguments?.getString(ARG_ACCOUNT)?.let { account -> + val name = arguments?.getString(ARG_NAME) ?: account + if (applyFahipayContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let prefillToDirectly( accountNumber = account, - displayName = arguments?.getString(ARG_NAME) ?: account, + displayName = name, subtitle = arguments?.getString(ARG_SUBTITLE) ?: account, colorHex = arguments?.getString(ARG_COLOR) ?: "#607D8B", imageHash = arguments?.getString(ARG_IMAGE_HASH) @@ -1090,6 +1100,9 @@ class TransferFragment : Fragment() { binding.etTo.setOnItemClickListener { _, _, position, _ -> val contact = adapter.getContact(position) ?: return@setOnItemClickListener + if (applyFahipayContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) { + return@setOnItemClickListener + } prefillToDirectly( accountNumber = contact.benefAccount, displayName = contact.benefNickName, @@ -1102,6 +1115,19 @@ class TransferFragment : Fragment() { } } + /** + * A saved Fahipay favourite: its list ([categoryId]) already says which service pays it, so + * that service is applied as the only transfer type, with no carrier lookup. That switches + * the source to the Fahipay wallet and brings in the service's amount rules, the same as a + * searched number. Returns false, doing nothing, when [categoryId] isn't a Fahipay list. + */ + private fun applyFahipayContact(number: String, categoryId: String?, name: String): Boolean { + val service = FahipayService.fromContactCategory(categoryId) ?: return false + clearRecipient() + offerTransferTypes(number, listOf(TransferType.Fahipay(service, name.takeIf { it.isNotBlank() && it != number }))) + return true + } + /** Picks the user's default account as the source when a recipient is chosen before any source. */ private fun selectDefaultSourceIfNone() { if (selectedAccount != null) return @@ -1312,7 +1338,9 @@ class TransferFragment : Fragment() { displayName: String, subtitle: String, colorHex: String, - imageHash: String? + imageHash: String?, + /** Saved with the recent, so picking it again pays the same way (Fahipay services). */ + contactCategory: String? = null ) { resolvedAccountNumber = accountNumber resolvedRecipientName = displayName @@ -1363,7 +1391,8 @@ class TransferFragment : Fragment() { subtitle = subtitle, colorHex = colorHex, imageHash = recentImageHash, - isProfileImage = recentIsProfileImage + isProfileImage = recentIsProfileImage, + contactCategory = contactCategory )) if (contact != null && imageHash != null) loadToPhoto(imageHash, isProfile = false) } @@ -1890,10 +1919,15 @@ class TransferFragment : Fragment() { private fun updateTransferButton() { + // The handler is dropped with the view, but a picked Fahipay service lives on in the + // 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() 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 hasAll = selectedAccount != null && recipientReady && amount > 0 && !transferTypePending + val amountOk = amount > 0 && fahipay?.amountProblem == null + val hasAll = selectedAccount != null && recipientReady && amountOk && !transferTypePending if (!hasAll) { binding.btnTransfer.isEnabled = false; return } val errors = viewModel.connectivityErrors.value ?: emptySet() val bankOffline = "NO_INTERNET" in errors || 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 e66a866..f5bdc4d 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,5 +1,6 @@ package sh.sar.basedbank.ui.home.transfer +import android.text.InputType import androidx.annotation.DrawableRes import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers @@ -12,6 +13,8 @@ import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.ui.home.HomeViewModel import sh.sar.basedbank.ui.home.TransferFragment import sh.sar.basedbank.util.AccountInputParser +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 @@ -26,11 +29,41 @@ enum class FahipayService( val label: String, val destinationLabel: String, @param: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, + /** + * 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, ) { - RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo), - OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo), - DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo), - DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo), + RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo, + minAmount = 11, maxAmount = null, decimalsAllowed = false, + contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8), + OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo, + minAmount = 10, maxAmount = 50000, decimalsAllowed = true, + contactCategory = "FAHIPAY_OOREDOO_BILL"), + DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo, + minAmount = 8, maxAmount = 1000, decimalsAllowed = false, + contactCategory = "FAHIPAY_RELOAD"), + DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo, + minAmount = 10, maxAmount = 5000, decimalsAllowed = false, + contactCategory = "FAHIPAY_DHIRAAGU_BILL"); + + companion object { + /** + * The service a saved Fahipay favourite or recent pays with, from its [contactCategory], + * or null when [categoryId] isn't one of them. + */ + fun fromContactCategory(categoryId: String?): FahipayService? = + entries.firstOrNull { it.contactCategory == categoryId } + } } /** @@ -79,9 +112,71 @@ class FahipayTransferHandler( lookupCarrier(rawInput) } - /** Forgets the selected service. The picker itself is the fragment's to reset. */ + /** + * Forgets the selected service and gives back the amount and reference fields it took over. + * The picker itself is the fragment's to reset. + */ 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 + } + + /** + * 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. + */ + 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 + } + } + + /** + * 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. + */ + 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) } /** @@ -90,6 +185,8 @@ class FahipayTransferHandler( */ fun applyService(type: TransferType.Fahipay, 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 @@ -99,7 +196,8 @@ class FahipayTransferHandler( displayName = displayName, subtitle = "${type.label} · $number", colorHex = "#FF6B00", - imageHash = null + imageHash = null, + contactCategory = type.service.contactCategory ) fragment.focusAmount() } @@ -165,4 +263,9 @@ class FahipayTransferHandler( 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/util/RecentsCache.kt b/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt index 490054f..6341e30 100644 --- a/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/RecentsCache.kt @@ -13,7 +13,10 @@ data class RecentPick( val isProfileImage: Boolean, /** Source bank tag for the recent — e.g. "MFAISA". Used by the picker to decide * per-bank selectability. Null for legacy entries; treated as unspecified. */ - val bank: String? = null + val bank: String? = null, + /** The contact category the pick was paid as — set for Fahipay payouts (e.g. + * "FAHIPAY_RAASTAS") so picking the recent again applies the same service. */ + val contactCategory: String? = null ) object RecentsCache { @@ -38,6 +41,7 @@ object RecentsCache { if (r.imageHash != null) put("imageHash", r.imageHash) put("isProfileImage", r.isProfileImage) if (r.bank != null) put("bank", r.bank) + if (r.contactCategory != null) put("contactCategory", r.contactCategory) }) } context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -56,6 +60,7 @@ object RecentsCache { if (r.imageHash != null) put("imageHash", r.imageHash) put("isProfileImage", r.isProfileImage) if (r.bank != null) put("bank", r.bank) + if (r.contactCategory != null) put("contactCategory", r.contactCategory) }) } context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -81,7 +86,8 @@ object RecentsCache { colorHex = o.getString("colorHex"), imageHash = o.optString("imageHash").takeIf { it.isNotBlank() }, isProfileImage = o.optBoolean("isProfileImage", false), - bank = o.optString("bank").takeIf { it.isNotBlank() } + bank = o.optString("bank").takeIf { it.isNotBlank() }, + contactCategory = o.optString("contactCategory").takeIf { it.isNotBlank() } ) } } catch (_: Exception) { diff --git a/app/src/main/java/sh/sar/basedbank/util/fahipayapi/FahipayContactParser.kt b/app/src/main/java/sh/sar/basedbank/util/fahipayapi/FahipayContactParser.kt index ba9ee2e..f8edb7b 100644 --- a/app/src/main/java/sh/sar/basedbank/util/fahipayapi/FahipayContactParser.kt +++ b/app/src/main/java/sh/sar/basedbank/util/fahipayapi/FahipayContactParser.kt @@ -1,6 +1,7 @@ package sh.sar.basedbank.util.fahipayapi import sh.sar.basedbank.api.models.BankContact +import sh.sar.basedbank.ui.home.transfer.FahipayService import sh.sar.basedbank.util.ContactDisplay import sh.sar.basedbank.util.TransferNetwork @@ -20,7 +21,8 @@ object FahipayContactParser { imageHash = contact.customerImgHash, profileId = contact.profileId, transferSubtitle = contact.benefAccount, - canTransfer = false, + // Each favourites list is one payout service; the Transfer screen picks it from categoryId + canTransfer = FahipayService.fromContactCategory(contact.benefCategoryId) != null, canEdit = false, canDelete = false ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c2f13b..648c896 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -262,6 +262,13 @@ From To Transfer type for %1$s + Enter a valid amount + Whole amounts only, no decimals + Up to 2 decimal places + Minimum is MVR %1$d + Maximum is MVR %1$s + %1$d%% GST is deducted from this amount + Recipient receives MVR %1$s after %2$d%% GST Fahipay transfers require a 7-digit phone number My Accounts This is the same account as the sender diff --git a/docs/thijooree/20-transfer-flows.md b/docs/thijooree/20-transfer-flows.md index 7417fd4..61339f7 100644 --- a/docs/thijooree/20-transfer-flows.md +++ b/docs/thijooree/20-transfer-flows.md @@ -10,7 +10,7 @@ The transfer screen (`TransferFragment`) handles all outgoing payments across MI | Factory method | Behaviour | |---|---| -| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate | +| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash, contactCategory?)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate. A Fahipay favourite's `contactCategory` opens it as that payout service instead (see [Saved Fahipay favourites](#saved-fahipay-favourites)) | | `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown | | `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan | | `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway/POS QR merchant payment mode — locks recipient, may pre-fill amount | @@ -90,6 +90,51 @@ The result maps to one or more Fahipay services: The matching services are offered in the Transfer Type picker (see above). +#### Saved Fahipay favourites + +Each Fahipay favourites list is one payout service (`FahipayService.contactCategory` / `fromContactCategory`): + +| Contact category | Service | +|---|---| +| `FAHIPAY_RAASTAS` | Raastas | +| `FAHIPAY_RELOAD` | Dhiraagu Reload | +| `FAHIPAY_OOREDOO_BILL` | Ooredoo Bill Pay | +| `FAHIPAY_DHIRAAGU_BILL` | Dhiraagu Bill Pay | + +So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyFahipayContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked: + +- the contact picker sheet (the row's category goes back as `ContactPickerSheetFragment.KEY_CATEGORY`) +- the "To" field's search-as-you-type dropdown +- the Contacts page: the row's transfer button and the contact details sheet's Transfer action. Fahipay favourites have `canTransfer` set when their category maps to a service. + +Recents work the same way. Paying a number as a Fahipay service saves the recent with that service's category (`RecentPick.contactCategory`). The picker passes it back like a favourite's, so picking the recent pays with the same service. Recents saved before this was added have no category and are still filled in directly. + +#### Amount rules + +Each service has its own limits on the amount (`FahipayService.minAmount` / `maxAmount` / `decimalsAllowed`): + +| Service | Min (MVR) | Max (MVR) | Decimals | +|---|---|---|---| +| Raastas | 11 | no limit | no | +| Ooredoo Bill Pay | 10 | 50,000 | yes, up to 2 places | +| Dhiraagu Reload | 8 | 1,000 | no | +| Dhiraagu Bill Pay | 10 | 5,000 | no | + +The amount is checked as the user types (`FahipayTransferHandler.amountProblem`). An amount that breaks a rule shows an error on the amount field ("Minimum is MVR 11", "Maximum is MVR 5,000", "Whole amounts only, no decimals", "Up to 2 decimal places"), and Send stays disabled. A trailing `.00` counts as a whole number. Services that don't take decimals switch the amount field to a number-only keypad. + +#### GST (Raastas) + +Raastas charges 8% GST out of the amount paid (`FahipayService.gstPercent`), so the number credited is less than the amount deducted from the wallet. The amount is GST-inclusive, so the credit is `amount / 1.08`, rounded down to 2 decimal places. The amount field's helper text says so: + +- Empty field: "8% GST is deducted from this amount" +- With an amount: "Recipient receives MVR 92.59 after 8% GST" (for MVR 100) + +When the amount breaks a rule, the error replaces the helper text. + +#### Reference + +None of the Fahipay services take a reference. Picking one clears the Reference field and disables it, the same way BML merchant QR payments do. Clearing the service turns the field back on. + ### BML source 1. If the input type is `MIB_ACCOUNT`, calls `BmlValidateClient.verifyMibAccount()`. @@ -281,6 +326,7 @@ The transfer button is only enabled when all of the following are true: - A recipient is resolved (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is set) - Amount is greater than `0` - If transfer types are on offer, one has been picked +- For a Fahipay service, the amount meets that service's rules (see [Amount rules](#amount-rules)) - No connectivity error for `NO_INTERNET` or for the source bank ---