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
Auto Tag on Version Change / check-version (push) Successful in 3s
Auto Tag on Version Change / check-version (push) Successful in 3s
This commit is contained in:
@@ -15,7 +15,7 @@ import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
|||||||
|
|
||||||
class ContactPickerAdapter(
|
class ContactPickerAdapter(
|
||||||
private val imageCache: MutableMap<String, Bitmap>,
|
private val imageCache: MutableMap<String, Bitmap>,
|
||||||
private val onItemClick: (accountNumber: String, label: String) -> Unit,
|
private val onItemClick: (accountNumber: String, label: String, categoryId: String?) -> Unit,
|
||||||
private val onSameAsFrom: () -> Unit,
|
private val onSameAsFrom: () -> Unit,
|
||||||
private val onImageNeeded: ((hash: String) -> Unit)? = null,
|
private val onImageNeeded: ((hash: String) -> Unit)? = null,
|
||||||
private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null
|
private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null
|
||||||
@@ -33,7 +33,9 @@ class ContactPickerAdapter(
|
|||||||
val imageHash: String? = null,
|
val imageHash: String? = null,
|
||||||
val inactiveReason: String? = null,
|
val inactiveReason: String? = null,
|
||||||
val balance: 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()
|
) : PickerItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +127,7 @@ class ContactPickerAdapter(
|
|||||||
Toast.makeText(binding.root.context, item.inactiveReason, Toast.LENGTH_SHORT).show()
|
Toast.makeText(binding.root.context, item.inactiveReason, Toast.LENGTH_SHORT).show()
|
||||||
item.isSameAsFrom ->
|
item.isSameAsFrom ->
|
||||||
Toast.makeText(binding.root.context, R.string.transfer_same_account, Toast.LENGTH_SHORT).show()
|
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 ->
|
binding.root.setOnLongClickListener { view ->
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
|
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
|
||||||
ContactPickerAdapter(
|
ContactPickerAdapter(
|
||||||
imageCache = sharedImageCache,
|
imageCache = sharedImageCache,
|
||||||
onItemClick = { accountNumber, label -> handlePickerSelection(accountNumber, label) },
|
onItemClick = { accountNumber, label, categoryId -> handlePickerSelection(accountNumber, label, categoryId) },
|
||||||
onSameAsFrom = {},
|
onSameAsFrom = {},
|
||||||
onImageNeeded = { hash -> fetchImage(hash) },
|
onImageNeeded = { hash -> fetchImage(hash) },
|
||||||
onItemLongClick = { accountNumber, anchor ->
|
onItemLongClick = { accountNumber, anchor ->
|
||||||
@@ -161,12 +161,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}.also { it.attach() }
|
}.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 contacts = viewModel.contacts.value ?: emptyList()
|
||||||
val accounts = viewModel.accounts.value ?: emptyList()
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
|
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
|
||||||
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
||||||
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
||||||
|
categoryId?.let { bundle.putString(KEY_CATEGORY, it) }
|
||||||
when {
|
when {
|
||||||
accountNumber.startsWith("bmlqr:") -> {
|
accountNumber.startsWith("bmlqr:") -> {
|
||||||
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
|
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
|
||||||
@@ -226,7 +227,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
imageHash = r.imageHash,
|
imageHash = r.imageHash,
|
||||||
// A MFAISA-tagged recent is itself a valid M-Faisa recipient — don't grey it out
|
// A MFAISA-tagged recent is itself a valid M-Faisa recipient — don't grey it out
|
||||||
// when the source is M-Faisa.
|
// 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
|
return items
|
||||||
@@ -328,7 +330,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
colorHex = contact.bankColor,
|
colorHex = contact.bankColor,
|
||||||
isSameAsFrom = contact.benefAccount == fromAccountNumber,
|
isSameAsFrom = contact.benefAccount == fromAccountNumber,
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc)
|
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc),
|
||||||
|
categoryId = contact.benefCategoryId
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
@@ -396,6 +399,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
const val KEY_SUBTITLE = "subtitle"
|
const val KEY_SUBTITLE = "subtitle"
|
||||||
const val KEY_COLOR = "color"
|
const val KEY_COLOR = "color"
|
||||||
const val KEY_IMAGE_HASH = "imageHash"
|
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 ARG_FROM_ACCOUNT = "fromAccount"
|
||||||
private const val RECENTS_TAG = "__recents__"
|
private const val RECENTS_TAG = "__recents__"
|
||||||
private const val MY_ACCOUNTS_TAG = "__my_accounts__"
|
private const val MY_ACCOUNTS_TAG = "__my_accounts__"
|
||||||
|
|||||||
@@ -188,7 +188,8 @@ class ContactsFragment : Fragment() {
|
|||||||
displayName = contact.name,
|
displayName = contact.name,
|
||||||
subtitle = contact.transferSubtitle,
|
subtitle = contact.transferSubtitle,
|
||||||
colorHex = contact.bankColor,
|
colorHex = contact.bankColor,
|
||||||
imageHash = contact.imageHash
|
imageHash = contact.imageHash,
|
||||||
|
contactCategory = contact.categoryId
|
||||||
)
|
)
|
||||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import sh.sar.basedbank.databinding.ItemPickerRowBinding
|
|||||||
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||||
import sh.sar.basedbank.databinding.ItemTransferTypeBinding
|
import sh.sar.basedbank.databinding.ItemTransferTypeBinding
|
||||||
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
|
||||||
|
import sh.sar.basedbank.ui.home.transfer.FahipayService
|
||||||
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
|
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_SUBTITLE = "contact_subtitle"
|
||||||
private const val ARG_COLOR = "contact_color"
|
private const val ARG_COLOR = "contact_color"
|
||||||
private const val ARG_IMAGE_HASH = "contact_image_hash"
|
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_FROM_ACCOUNT = "from_account"
|
||||||
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
|
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
|
||||||
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||||
@@ -311,7 +313,9 @@ class TransferFragment : Fragment() {
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
colorHex: 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 {
|
) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putString(ARG_ACCOUNT, accountNumber)
|
putString(ARG_ACCOUNT, accountNumber)
|
||||||
@@ -319,6 +323,7 @@ class TransferFragment : Fragment() {
|
|||||||
putString(ARG_SUBTITLE, subtitle)
|
putString(ARG_SUBTITLE, subtitle)
|
||||||
putString(ARG_COLOR, colorHex)
|
putString(ARG_COLOR, colorHex)
|
||||||
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
|
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
|
return@setFragmentResultListener
|
||||||
}
|
}
|
||||||
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
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 subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
|
||||||
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
||||||
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
||||||
@@ -435,9 +443,11 @@ class TransferFragment : Fragment() {
|
|||||||
private fun applyArguments() {
|
private fun applyArguments() {
|
||||||
// Pre-select contact if navigated from contacts page or QR scan
|
// Pre-select contact if navigated from contacts page or QR scan
|
||||||
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
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(
|
prefillToDirectly(
|
||||||
accountNumber = account,
|
accountNumber = account,
|
||||||
displayName = arguments?.getString(ARG_NAME) ?: account,
|
displayName = name,
|
||||||
subtitle = arguments?.getString(ARG_SUBTITLE) ?: account,
|
subtitle = arguments?.getString(ARG_SUBTITLE) ?: account,
|
||||||
colorHex = arguments?.getString(ARG_COLOR) ?: "#607D8B",
|
colorHex = arguments?.getString(ARG_COLOR) ?: "#607D8B",
|
||||||
imageHash = arguments?.getString(ARG_IMAGE_HASH)
|
imageHash = arguments?.getString(ARG_IMAGE_HASH)
|
||||||
@@ -1090,6 +1100,9 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
binding.etTo.setOnItemClickListener { _, _, position, _ ->
|
binding.etTo.setOnItemClickListener { _, _, position, _ ->
|
||||||
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
|
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
|
||||||
|
if (applyFahipayContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
|
||||||
|
return@setOnItemClickListener
|
||||||
|
}
|
||||||
prefillToDirectly(
|
prefillToDirectly(
|
||||||
accountNumber = contact.benefAccount,
|
accountNumber = contact.benefAccount,
|
||||||
displayName = contact.benefNickName,
|
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. */
|
/** Picks the user's default account as the source when a recipient is chosen before any source. */
|
||||||
private fun selectDefaultSourceIfNone() {
|
private fun selectDefaultSourceIfNone() {
|
||||||
if (selectedAccount != null) return
|
if (selectedAccount != null) return
|
||||||
@@ -1312,7 +1338,9 @@ class TransferFragment : Fragment() {
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
colorHex: 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
|
resolvedAccountNumber = accountNumber
|
||||||
resolvedRecipientName = displayName
|
resolvedRecipientName = displayName
|
||||||
@@ -1363,7 +1391,8 @@ class TransferFragment : Fragment() {
|
|||||||
subtitle = subtitle,
|
subtitle = subtitle,
|
||||||
colorHex = colorHex,
|
colorHex = colorHex,
|
||||||
imageHash = recentImageHash,
|
imageHash = recentImageHash,
|
||||||
isProfileImage = recentIsProfileImage
|
isProfileImage = recentIsProfileImage,
|
||||||
|
contactCategory = contactCategory
|
||||||
))
|
))
|
||||||
if (contact != null && imageHash != null) loadToPhoto(imageHash, isProfile = false)
|
if (contact != null && imageHash != null) loadToPhoto(imageHash, isProfile = false)
|
||||||
}
|
}
|
||||||
@@ -1890,10 +1919,15 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
|
|
||||||
private fun updateTransferButton() {
|
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
|
if (bmlHandler().isOtpFlowActive) return
|
||||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
|
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
|
||||||
val 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 }
|
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||||
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||||
val bankOffline = "NO_INTERNET" in errors ||
|
val bankOffline = "NO_INTERNET" in errors ||
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.ui.home.transfer
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.text.InputType
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.HomeViewModel
|
||||||
import sh.sar.basedbank.ui.home.TransferFragment
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
import sh.sar.basedbank.util.AccountInputParser
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
|
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
|
||||||
@@ -26,11 +29,41 @@ enum class FahipayService(
|
|||||||
val label: String,
|
val label: String,
|
||||||
val destinationLabel: String,
|
val destinationLabel: String,
|
||||||
@param:DrawableRes val iconRes: Int,
|
@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),
|
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
|
||||||
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo),
|
minAmount = 11, maxAmount = null, decimalsAllowed = false,
|
||||||
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo),
|
contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8),
|
||||||
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo),
|
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)
|
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() {
|
fun clearState() {
|
||||||
|
if (service == null) return
|
||||||
service = null
|
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) {
|
fun applyService(type: TransferType.Fahipay, number: String) {
|
||||||
service = type.service
|
service = type.service
|
||||||
|
// None of the payouts take a reference; syncAmountField() disables the box
|
||||||
|
binding.etRemarks.setText("")
|
||||||
val contacts = viewModel.contacts.value ?: emptyList()
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
val displayName = type.ownerName
|
val displayName = type.ownerName
|
||||||
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
||||||
@@ -99,7 +196,8 @@ class FahipayTransferHandler(
|
|||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
subtitle = "${type.label} · $number",
|
subtitle = "${type.label} · $number",
|
||||||
colorHex = "#FF6B00",
|
colorHex = "#FF6B00",
|
||||||
imageHash = null
|
imageHash = null,
|
||||||
|
contactCategory = type.service.contactCategory
|
||||||
)
|
)
|
||||||
fragment.focusAmount()
|
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.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
|
||||||
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
|
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
|
||||||
|
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ data class RecentPick(
|
|||||||
val isProfileImage: Boolean,
|
val isProfileImage: Boolean,
|
||||||
/** Source bank tag for the recent — e.g. "MFAISA". Used by the picker to decide
|
/** 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. */
|
* 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 {
|
object RecentsCache {
|
||||||
@@ -38,6 +41,7 @@ object RecentsCache {
|
|||||||
if (r.imageHash != null) put("imageHash", r.imageHash)
|
if (r.imageHash != null) put("imageHash", r.imageHash)
|
||||||
put("isProfileImage", r.isProfileImage)
|
put("isProfileImage", r.isProfileImage)
|
||||||
if (r.bank != null) put("bank", r.bank)
|
if (r.bank != null) put("bank", r.bank)
|
||||||
|
if (r.contactCategory != null) put("contactCategory", r.contactCategory)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
@@ -56,6 +60,7 @@ object RecentsCache {
|
|||||||
if (r.imageHash != null) put("imageHash", r.imageHash)
|
if (r.imageHash != null) put("imageHash", r.imageHash)
|
||||||
put("isProfileImage", r.isProfileImage)
|
put("isProfileImage", r.isProfileImage)
|
||||||
if (r.bank != null) put("bank", r.bank)
|
if (r.bank != null) put("bank", r.bank)
|
||||||
|
if (r.contactCategory != null) put("contactCategory", r.contactCategory)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
@@ -81,7 +86,8 @@ object RecentsCache {
|
|||||||
colorHex = o.getString("colorHex"),
|
colorHex = o.getString("colorHex"),
|
||||||
imageHash = o.optString("imageHash").takeIf { it.isNotBlank() },
|
imageHash = o.optString("imageHash").takeIf { it.isNotBlank() },
|
||||||
isProfileImage = o.optBoolean("isProfileImage", false),
|
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) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sh.sar.basedbank.util.fahipayapi
|
package sh.sar.basedbank.util.fahipayapi
|
||||||
|
|
||||||
import sh.sar.basedbank.api.models.BankContact
|
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.ContactDisplay
|
||||||
import sh.sar.basedbank.util.TransferNetwork
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
@@ -20,7 +21,8 @@ object FahipayContactParser {
|
|||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
profileId = contact.profileId,
|
profileId = contact.profileId,
|
||||||
transferSubtitle = contact.benefAccount,
|
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,
|
canEdit = false,
|
||||||
canDelete = false
|
canDelete = false
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -262,6 +262,13 @@
|
|||||||
<string name="transfer_label_from">From</string>
|
<string name="transfer_label_from">From</string>
|
||||||
<string name="transfer_label_to">To</string>
|
<string name="transfer_label_to">To</string>
|
||||||
<string name="transfer_type_for">Transfer type for %1$s</string>
|
<string name="transfer_type_for">Transfer type for %1$s</string>
|
||||||
|
<string name="transfer_fahipay_amount_invalid">Enter a valid amount</string>
|
||||||
|
<string name="transfer_fahipay_amount_whole">Whole amounts only, no decimals</string>
|
||||||
|
<string name="transfer_fahipay_amount_decimals">Up to 2 decimal places</string>
|
||||||
|
<string name="transfer_fahipay_amount_min">Minimum is MVR %1$d</string>
|
||||||
|
<string name="transfer_fahipay_amount_max">Maximum is MVR %1$s</string>
|
||||||
|
<string name="transfer_fahipay_gst_hint">%1$d%% GST is deducted from this amount</string>
|
||||||
|
<string name="transfer_fahipay_gst_receive">Recipient receives MVR %1$s after %2$d%% GST</string>
|
||||||
<string name="transfer_fahipay_phone_only">Fahipay transfers require a 7-digit phone number</string>
|
<string name="transfer_fahipay_phone_only">Fahipay transfers require a 7-digit phone number</string>
|
||||||
<string name="transfer_my_accounts">My Accounts</string>
|
<string name="transfer_my_accounts">My Accounts</string>
|
||||||
<string name="transfer_same_as_from">This is the same account as the sender</string>
|
<string name="transfer_same_as_from">This is the same account as the sender</string>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ The transfer screen (`TransferFragment`) handles all outgoing payments across MI
|
|||||||
|
|
||||||
| Factory method | Behaviour |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `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).
|
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
|
### BML source
|
||||||
|
|
||||||
1. If the input type is `MIB_ACCOUNT`, calls `BmlValidateClient.verifyMibAccount()`.
|
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)
|
- A recipient is resolved (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is set)
|
||||||
- Amount is greater than `0`
|
- Amount is greater than `0`
|
||||||
- If transfer types are on offer, one has been picked
|
- 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
|
- No connectivity error for `NO_INTERNET` or for the source bank
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user