add transfers

This commit is contained in:
2026-05-14 06:15:39 +05:00
parent 4c91a1aa0e
commit eda8797552
10 changed files with 533 additions and 63 deletions
@@ -311,6 +311,98 @@ class BmlLoginFlow {
return parseContacts(json) return parseContacts(json)
} }
/**
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
*/
fun initiateTransfer(
session: BmlSession,
debitAccount: String,
creditAccount: String,
amount: Double,
transferType: String,
currency: String,
bank: String? = null
): Boolean {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
put("creditAccount", creditAccount)
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", "token")
if (bank != null) put("bank", bank)
}
val body = jo.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/mobile/transfer")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("accept", "application/json")
.build()
return apiClient.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try {
val json = JSONObject(bodyStr)
json.optBoolean("success") && json.optInt("code") == 22
} catch (_: Exception) { false }
}
}
/**
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
*/
fun confirmTransfer(
session: BmlSession,
debitAccount: String,
creditAccount: String,
amount: Double,
transferType: String,
currency: String,
otp: String,
remarks: String = "",
bank: String? = null
): BmlTransferResult {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
put("creditAccount", creditAccount)
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", "token")
put("otp", otp)
if (remarks.isNotBlank()) put("remarks", remarks)
if (bank != null) put("bank", bank)
}
val body = jo.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/mobile/transfer")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("accept", "application/json")
.build()
return apiClient.newCall(request).execute().use { response ->
val bodyStr = response.body?.string()
?: return@use BmlTransferResult(false, errorMessage = "No response")
try {
val json = JSONObject(bodyStr)
if (!json.optBoolean("success")) {
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
} else {
val payload = json.optJSONObject("payload")
BmlTransferResult(
success = true,
reference = payload?.optString("reference") ?: "",
timestamp = payload?.optString("timestamp") ?: ""
)
}
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
}
}
fun deleteContact(session: BmlSession, contactId: String): Boolean { fun deleteContact(session: BmlSession, contactId: String): Boolean {
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType()) val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder() val request = Request.Builder()
@@ -350,6 +442,8 @@ class BmlLoginFlow {
val accountNumber = item.optString("account") val accountNumber = item.optString("account")
val status = item.optString("account_status", "Active") val status = item.optString("account_status", "Active")
val internalId = item.optString("id", "")
if (accountType == "CASA") { if (accountType == "CASA") {
val available = item.optDouble("availableBalance", 0.0) val available = item.optDouble("availableBalance", 0.0)
casaAccounts.add(MibAccount( casaAccounts.add(MibAccount(
@@ -365,7 +459,8 @@ class BmlLoginFlow {
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00", mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status, statusDesc = status,
profileImageHash = null, profileImageHash = null,
loginTag = loginTag loginTag = loginTag,
internalId = internalId
)) ))
} else if (accountType == "Card") { } else if (accountType == "Card") {
val isPrepaid = item.optBoolean("prepaid_card", false) val isPrepaid = item.optBoolean("prepaid_card", false)
@@ -385,7 +480,8 @@ class BmlLoginFlow {
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00", mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status, statusDesc = status,
profileImageHash = null, profileImageHash = null,
loginTag = loginTag loginTag = loginTag,
internalId = internalId
)) ))
} else { } else {
// Linked debit cards have no independent balance or account link — skip // Linked debit cards have no independent balance or account link — skip
@@ -16,6 +16,13 @@ data class BmlAccountValidation(
val agnt: String? = null // BIC for DOT (MIB account on BML) val agnt: String? = null // BIC for DOT (MIB account on BML)
) )
data class BmlTransferResult(
val success: Boolean,
val reference: String = "",
val timestamp: String = "",
val errorMessage: String = ""
)
data class BmlForeignLimit( data class BmlForeignLimit(
val type: String, val type: String,
val used: Double, val used: Double,
@@ -204,7 +204,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
doRequest(session, payload, "n") doRequest(session, payload, "n")
} }
private fun fetchAllProfiles(session: MibSession, profiles: List<MibProfile>, loginTag: String): List<MibAccount> { fun fetchAllProfiles(session: MibSession, profiles: List<MibProfile>, loginTag: String): List<MibAccount> {
val allAccounts = mutableListOf<MibAccount>() val allAccounts = mutableListOf<MibAccount>()
for (profile in profiles) { for (profile in profiles) {
val payload = baseData(session, "P47").apply { val payload = baseData(session, "P47").apply {
@@ -33,7 +33,15 @@ data class MibAccount(
val statusDesc: String, val statusDesc: String,
val profileImageHash: String?, val profileImageHash: String?,
val loginTag: String = "", val loginTag: String = "",
val profileId: String = "" // MIB profile ID; empty for BML accounts val profileId: String = "", // MIB profile ID; empty for BML accounts
val internalId: String = "" // BML internal UUID; empty for MIB accounts
)
data class MibTransferResult(
val success: Boolean,
val trxId: String = "",
val date: String = "",
val errorMessage: String = ""
) )
data class MibBeneficiaryCategory( data class MibBeneficiaryCategory(
@@ -33,6 +33,59 @@ class MibTransferClient {
.header("Origin", BASE_WV_URL) .header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/transfer/quick") .header("Referer", "$BASE_WV_URL/transfer/quick")
/**
* Execute a transfer. bankNo=2 → MIB internal (/transferInternal), bankNo=3 → local/BML (/transferLocal).
* currencyCode: "462"=MVR, "840"=USD. purpose uses "-" if blank.
*/
fun transfer(
session: MibSession,
fromAccount: String,
toAccount: String,
amount: String,
currencyCode: String,
benefName: String,
bankNo: Int,
purpose: String,
otp: String
): MibTransferResult {
val endpoint = if (bankNo == 2) "transferInternal" else "transferLocal"
val body = FormBody.Builder()
.add("benefName", benefName)
.add("benefNo", "0")
.add("fromAccountNo", fromAccount)
.add("benefAccountNo", toAccount)
.add("transferCy", currencyCode)
.add("benefCurrencyCode", currencyCode)
.add("amount", amount)
.add("bankNo", bankNo.toString())
.add("purpose", purpose.ifBlank { "-" })
.add("otp", otp)
.add("otpType", "3")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxTransfer/$endpoint")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (json == null || !json.optBoolean("success")) {
MibTransferResult(
success = false,
errorMessage = json?.optString("reasonText")?.ifBlank { null } ?: "Transfer failed"
)
} else {
val data = json.optJSONArray("data")?.optJSONObject(0)
MibTransferResult(
success = true,
trxId = data?.optString("trxId") ?: "",
date = data?.optString("date") ?: ""
)
}
}
}
/** /**
* Routes the lookup to the correct endpoint based on input format: * Routes the lookup to the correct endpoint based on input format:
* *
@@ -49,13 +49,22 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
adapter = ContactPickerAdapter( adapter = ContactPickerAdapter(
onItemClick = { accountNumber, label -> onItemClick = { accountNumber, label ->
val contacts = viewModel.contacts.value ?: emptyList() val contacts = viewModel.contacts.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 bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label) val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
if (contact != null && !contact.transferCyDesc.equals("MVR", ignoreCase = true)) { when {
bundle.putBoolean(KEY_SKIP_LOOKUP, true) account?.profileType == "BML_PREPAID" -> {
bundle.putString(KEY_SUBTITLE, "${contact.benefBankName} · ${contact.benefAccount}") bundle.putBoolean(KEY_SKIP_LOOKUP, true)
bundle.putString(KEY_COLOR, contact.bankColor) bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
contact.customerImgHash?.let { bundle.putString(KEY_IMAGE_HASH, it) } bundle.putString(KEY_COLOR, "#FE860E")
}
contact != null && !contact.transferCyDesc.equals("MVR", ignoreCase = true) -> {
bundle.putBoolean(KEY_SKIP_LOOKUP, true)
bundle.putString(KEY_SUBTITLE, "${contact.benefBankName} · ${contact.benefAccount}")
bundle.putString(KEY_COLOR, contact.bankColor)
contact.customerImgHash?.let { bundle.putString(KEY_IMAGE_HASH, it) }
}
} }
setFragmentResult(REQUEST_KEY, bundle) setFragmentResult(REQUEST_KEY, bundle)
dismiss() dismiss()
@@ -289,6 +289,39 @@ class HomeActivity : AppCompatActivity() {
} }
} }
fun refreshBalances(src: MibAccount) {
val app = application as BasedBankApp
lifecycleScope.launch {
val current = viewModel.accounts.value ?: emptyList()
if (src.profileType.startsWith("BML")) {
val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSession ?: return@withContext null
try {
val accounts = BmlLoginFlow().fetchAccounts(sess)
AccountCache.saveBml(this@HomeActivity, accounts)
app.bmlAccounts = accounts
accounts
} catch (_: Exception) { null }
} ?: return@launch
val mibOnly = current.filter { !it.profileType.startsWith("BML") }
viewModel.accounts.postValue(mibOnly + fresh)
} else {
val fresh = withContext(Dispatchers.IO) {
val sess = app.mibSession ?: return@withContext null
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
try { MibLoginFlow(prefs).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
catch (_: Exception) { null }
} ?: return@launch
// Replace accounts from this profile only, keep everything else
val others = current.filter { it.profileId != src.profileId || it.profileType.startsWith("BML") }
val merged = others + fresh
AccountCache.save(this@HomeActivity, merged.filter { !it.profileType.startsWith("BML") })
viewModel.accounts.postValue(merged)
}
}
}
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) { private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
@@ -11,8 +11,11 @@ import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ArrayAdapter import android.widget.BaseAdapter
import android.widget.Filter
import android.widget.Filterable
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
@@ -26,16 +29,22 @@ 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.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlTransferResult
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.mib.MibLookupException import sh.sar.basedbank.api.mib.MibLookupException
import sh.sar.basedbank.api.mib.MibTransferClient import sh.sar.basedbank.api.mib.MibTransferClient
import sh.sar.basedbank.api.mib.MibTransferResult
import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.PaymvQrParser import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.Totp
class TransferFragment : Fragment() { class TransferFragment : Fragment() {
@@ -47,6 +56,23 @@ class TransferFragment : Fragment() {
private val session get() = (requireActivity().application as BasedBankApp).mibSession private val session get() = (requireActivity().application as BasedBankApp).mibSession
private val bmlSession get() = (requireActivity().application as BasedBankApp).bmlSession private val bmlSession get() = (requireActivity().application as BasedBankApp).bmlSession
// Resolved recipient info — set after successful lookup or prefill
private var resolvedAccountNumber = ""
private var resolvedRecipientName = ""
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val qr = PaymvQrParser.parse(raw)
if (qr == null || qr.accountNumber == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult
}
if (qr.amount != null) binding.etAmount.setText(qr.amount)
if (qr.purpose != null) binding.etRemarks.setText(qr.purpose)
prefillToFromContact(qr.accountNumber, "")
}
companion object { companion object {
private const val ARG_ACCOUNT = "contact_account" private const val ARG_ACCOUNT = "contact_account"
private const val ARG_NAME = "contact_name" private const val ARG_NAME = "contact_name"
@@ -71,19 +97,6 @@ class TransferFragment : Fragment() {
} }
} }
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val qr = PaymvQrParser.parse(raw)
if (qr == null || qr.accountNumber == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult
}
if (qr.amount != null) binding.etAmount.setText(qr.amount)
if (qr.purpose != null) binding.etRemarks.setText(qr.purpose)
prefillToFromContact(qr.accountNumber, "")
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentTransferBinding.inflate(inflater, container, false) _binding = FragmentTransferBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -115,9 +128,7 @@ class TransferFragment : Fragment() {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
} }
binding.btnTransfer.setOnClickListener { binding.btnTransfer.setOnClickListener { initiateTransfer() }
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
// Pre-select contact if navigated from contacts page // Pre-select contact if navigated from contacts page
arguments?.getString(ARG_ACCOUNT)?.let { account -> arguments?.getString(ARG_ACCOUNT)?.let { account ->
@@ -136,22 +147,27 @@ class TransferFragment : Fragment() {
val adapter = AccountDropdownAdapter(requireContext(), accounts) val adapter = AccountDropdownAdapter(requireContext(), accounts)
binding.actvFrom.setAdapter(adapter) binding.actvFrom.setAdapter(adapter)
if (accounts.isNotEmpty() && selectedAccount == null) { // No default selection — user must explicitly pick a source account
selectedAccount = accounts[0]
binding.actvFrom.setText(accounts[0].toDisplayString(), false)
}
binding.actvFrom.setOnItemClickListener { _, _, position, _ -> binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
selectedAccount = accounts[position] val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
binding.actvFrom.setText(accounts[position].toDisplayString(), false) selectedAccount = picked
binding.actvFrom.setText(picked.toDisplayString(), false)
updateAmountPrefix(picked)
} }
} }
} }
private fun updateAmountPrefix(account: MibAccount) {
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
}
private fun setupAccountLookup() { private fun setupAccountLookup() {
binding.tilTo.setEndIconOnClickListener { lookupAccount() } binding.tilTo.setEndIconOnClickListener { lookupAccount() }
binding.btnClearToInfo.setOnClickListener { binding.btnClearToInfo.setOnClickListener {
resolvedAccountNumber = ""
resolvedRecipientName = ""
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
@@ -162,6 +178,8 @@ class TransferFragment : Fragment() {
binding.etTo.addTextChangedListener { binding.etTo.addTextChangedListener {
binding.tilTo.error = null binding.tilTo.error = null
if (binding.cardToInfo.visibility == View.VISIBLE) { if (binding.cardToInfo.visibility == View.VISIBLE) {
resolvedAccountNumber = ""
resolvedRecipientName = ""
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
@@ -206,7 +224,6 @@ class TransferFragment : Fragment() {
var errorMsg: String? = null var errorMsg: String? = null
val info = withContext(Dispatchers.IO) { val info = withContext(Dispatchers.IO) {
if (isBmlSource && bmlSess != null) { if (isBmlSource && bmlSess != null) {
// BML source: prefer BML validate, fall back to MIB IPS
val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess, accountNumber) } catch (_: Exception) { null } val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess, accountNumber) } catch (_: Exception) { null }
if (bmlResult != null) { if (bmlResult != null) {
val bankId = when (bmlResult.trnType) { val bankId = when (bmlResult.trnType) {
@@ -222,13 +239,11 @@ class TransferFragment : Fragment() {
errorMsg = getString(R.string.transfer_account_not_found); null errorMsg = getString(R.string.transfer_account_not_found); null
} }
} else { } else {
// MIB source (or no preference): prefer MIB, fall back to BML validate
if (mibSess != null) { if (mibSess != null) {
try { MibTransferClient().lookup(mibSess, accountNumber) } try { MibTransferClient().lookup(mibSess, accountNumber) }
catch (e: MibLookupException) { errorMsg = e.message; null } catch (e: MibLookupException) { errorMsg = e.message; null }
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null } catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
} else { } else {
// MIB not available, try BML
val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null } val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
if (bmlResult != null) { if (bmlResult != null) {
val bankId = when (bmlResult.trnType) { val bankId = when (bmlResult.trnType) {
@@ -245,14 +260,14 @@ class TransferFragment : Fragment() {
binding.tilTo.isEnabled = true binding.tilTo.isEnabled = true
if (info != null) { if (info != null) {
val accounts = viewModel.accounts.value ?: emptyList() val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber } val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
val matchedContact = contacts.firstOrNull { it.benefAccount == info.accountNumber } val matchedCont = contacts.firstOrNull { it.benefAccount == info.accountNumber }
val displayName = matchedAcc?.accountBriefName val displayName = matchedAcc?.accountBriefName ?: matchedCont?.benefNickName ?: info.accountName
?: matchedContact?.benefNickName val colorHex = if (matchedAcc != null) "#FE860E" else matchedCont?.bankColor ?: "#607D8B"
?: info.accountName
val colorHex = if (matchedAcc != null) "#FE860E" else matchedContact?.bankColor ?: "#607D8B" resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName
binding.tvToAccountName.text = displayName binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}" binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
@@ -266,8 +281,8 @@ class TransferFragment : Fragment() {
when { when {
matchedAcc?.profileImageHash != null -> matchedAcc?.profileImageHash != null ->
loadToPhoto(matchedAcc.profileImageHash, isProfile = true) loadToPhoto(matchedAcc.profileImageHash, isProfile = true)
matchedContact?.customerImgHash != null -> matchedCont?.customerImgHash != null ->
loadToPhoto(matchedContact.customerImgHash, isProfile = false) loadToPhoto(matchedCont.customerImgHash, isProfile = false)
} }
} else { } else {
Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show()
@@ -282,6 +297,9 @@ class TransferFragment : Fragment() {
colorHex: String, colorHex: String,
imageHash: String? imageHash: String?
) { ) {
resolvedAccountNumber = accountNumber
resolvedRecipientName = displayName
binding.tvToAccountName.text = displayName binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = subtitle binding.tvToBankBic.text = subtitle
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex)) binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
@@ -306,6 +324,8 @@ class TransferFragment : Fragment() {
} }
private fun prefillToFromContact(accountNumber: String, label: String) { private fun prefillToFromContact(accountNumber: String, label: String) {
resolvedAccountNumber = ""
resolvedRecipientName = ""
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
@@ -315,6 +335,206 @@ class TransferFragment : Fragment() {
lookupAccount() lookupAccount()
} }
// ── Transfer ──────────────────────────────────────────────────────────────
private fun initiateTransfer() {
val src = selectedAccount ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
if (resolvedAccountNumber.isBlank()) {
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, 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() ?: ""
val isSrcBml = src.profileType.startsWith("BML")
val isSrcCard = src.profileType == "BML_PREPAID"
val isDestMib = resolvedAccountNumber.matches(Regex("^9\\d{16}$"))
val currency = src.currencyName.ifBlank { "MVR" }
val allAccounts = viewModel.accounts.value ?: emptyList()
val allContacts = viewModel.contacts.value ?: emptyList()
// BML USD → MIB requires a saved BML contact
if (isSrcBml && isDestMib && currency == "USD") {
val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber }
if (!hasBmlContact) {
AlertDialog.Builder(requireContext())
.setTitle(R.string.transfer_bml_contact_required_title)
.setMessage(R.string.transfer_bml_contact_required_msg)
.setPositiveButton("OK", null)
.show()
return
}
}
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
AlertDialog.Builder(requireContext())
.setTitle(R.string.transfer)
.setMessage("Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}")
.setPositiveButton(R.string.transfer_confirm) { _, _ ->
binding.btnTransfer.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
val (ok, msg) = withContext(Dispatchers.IO) {
if (!isSrcBml) {
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, amountStr, remarks)
} else {
doBmlTransfer(src, resolvedAccountNumber, amount, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
}
}
binding.btnTransfer.isEnabled = true
if (ok) {
clearForm()
(requireActivity() as HomeActivity).refreshBalances(src)
AlertDialog.Builder(requireContext())
.setTitle(R.string.transfer_success)
.setMessage(msg)
.setPositiveButton("OK", null)
.show()
} else {
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
}
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun doMibTransfer(
src: MibAccount,
destAccount: String,
destName: String,
amount: String,
remarks: String
): Pair<Boolean, String> {
val sess = session ?: return Pair(false, getString(R.string.transfer_session_unavailable))
val app = requireActivity().application as BasedBankApp
// Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) {
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
}
val otp = CredentialStore(requireContext()).loadMibCredentials()?.otpSeed
?.let { Totp.generate(it) }
?: return Pair(false, "OTP unavailable")
val currencyCode = if (src.currencyName == "USD") "840" else "462"
val isDestMib = destAccount.matches(Regex("^9\\d{16}$"))
val bankNo = if (isDestMib) 2 else 3
return try {
val result = MibTransferClient().transfer(
session = sess,
fromAccount = src.accountNumber,
toAccount = destAccount,
amount = amount,
currencyCode = currencyCode,
benefName = destName.ifBlank { "Recipient" },
bankNo = bankNo,
purpose = remarks,
otp = otp
)
if (result.success) {
Pair(true, "Transaction ID: ${result.trxId}\n${result.date}")
} else {
Pair(false, result.errorMessage.ifBlank { "Transfer failed" })
}
} catch (e: Exception) {
Pair(false, e.message ?: "Transfer failed")
}
}
private fun doBmlTransfer(
src: MibAccount,
destAccount: String,
amount: Double,
remarks: String,
isSrcCard: Boolean,
isDestMib: Boolean,
currency: String,
allAccounts: List<MibAccount>,
allContacts: List<MibBeneficiary>
): Pair<Boolean, String> {
val sess = bmlSession ?: return Pair(false, getString(R.string.transfer_session_unavailable))
val otp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed
?.let { Totp.generate(it) }
?: return Pair(false, "OTP unavailable")
val debitAccount = src.internalId.ifBlank {
return Pair(false, getString(R.string.transfer_missing_internal_id))
}
// Determine type + credit account
val isDestMyCard = allAccounts.any { it.profileType == "BML_PREPAID" && it.accountNumber == destAccount }
val (transferType, creditAccount, bank) = when {
isSrcCard -> {
// CAD: card → own BML account
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null as String?)
}
isDestMyCard -> {
// CPA: BML CASA → own card top-up
val card = allAccounts.first { it.profileType == "BML_PREPAID" && it.accountNumber == destAccount }
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
}
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
isDestMib -> {
// USD DOT: requires BML contact numeric ID
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
?: return Pair(false, "BML contact not found for this account")
Triple("DOT", contact.benefNo.removePrefix("bml_"), null as String?)
}
else -> Triple("IAT", destAccount, null as String?)
}
val bmlFlow = BmlLoginFlow()
// Step 1: initiate
val initiated = try {
bmlFlow.initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
} catch (e: Exception) { return Pair(false, e.message ?: "Initiation failed") }
if (!initiated) return Pair(false, "Failed to initiate transfer — check your session")
// Step 2: confirm with fresh OTP
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed
?.let { Totp.generate(it) } ?: otp
return try {
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
if (result.success) {
val time = result.timestamp.take(19).replace("T", " ")
Pair(true, "Reference: ${result.reference}\n$time")
} else {
Pair(false, result.errorMessage.ifBlank { "Transfer failed" })
}
} catch (e: Exception) {
Pair(false, e.message ?: "Transfer failed")
}
}
private fun clearForm() {
selectedAccount = null
binding.actvFrom.setText("", false)
binding.tilAmount.prefixText = null
binding.etAmount.setText("")
binding.etRemarks.setText("")
resolvedAccountNumber = ""
resolvedRecipientName = ""
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
binding.etTo.setText("")
binding.tilTo.error = null
binding.tilAmount.error = null
}
// ── Helpers ───────────────────────────────────────────────────────────────
private fun loadToPhoto(hash: String, isProfile: Boolean) { private fun loadToPhoto(hash: String, isProfile: Boolean) {
val sess = session ?: return val sess = session ?: return
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
@@ -381,7 +601,6 @@ class TransferFragment : Fragment() {
return return
} }
// Manual entry not in contacts/accounts — save with resolved info
RecentsCache.save(requireContext(), RecentPick( RecentsCache.save(requireContext(), RecentPick(
accountNumber = info.accountNumber, accountNumber = info.accountNumber,
displayName = info.accountName, displayName = info.accountName,
@@ -404,29 +623,67 @@ class TransferFragment : Fragment() {
private fun MibAccount.toDisplayString() = "$accountBriefName · $accountNumber" private fun MibAccount.toDisplayString() = "$accountBriefName · $accountNumber"
// items: String = section header, MibAccount = selectable row
private inner class AccountDropdownAdapter( private inner class AccountDropdownAdapter(
context: Context, private val context: Context,
private val accounts: List<MibAccount> accounts: List<MibAccount>
) : ArrayAdapter<String>(context, android.R.layout.simple_list_item_1, accounts.map { it.toDisplayString() }) { ) : BaseAdapter(), Filterable {
private fun bindDropdown(convertView: View?, parent: ViewGroup, position: Int): View { private val items: List<Any> = buildList {
val itemBinding = if (convertView?.tag is ItemAccountDropdownBinding) { val regular = accounts.filter { it.profileType != "BML_PREPAID" }
convertView.tag as ItemAccountDropdownBinding val cards = accounts.filter { it.profileType == "BML_PREPAID" }
} else { addAll(regular)
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false) if (cards.isNotEmpty()) {
.also { it.root.tag = it } add(getString(R.string.cards))
addAll(cards)
} }
val account = accounts[position]
itemBinding.tvDropdownAccountName.text = account.accountBriefName
itemBinding.tvDropdownAccountNumber.text = account.accountNumber
itemBinding.tvDropdownBalance.text = "${account.currencyName} ${account.availableBalance}"
return itemBinding.root
} }
override fun getView(position: Int, convertView: View?, parent: ViewGroup) = fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount)
bindDropdown(convertView, parent, position) ?.takeUnless { it.profileType == "BML_PREPAID" && !it.statusDesc.equals("Active", ignoreCase = true) }
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup) = override fun getCount() = items.size
bindDropdown(convertView, parent, position) override fun getItem(position: Int) = items[position]
override fun getItemId(position: Int) = position.toLong()
override fun getViewTypeCount() = 2
override fun getItemViewType(position: Int) = if (items[position] is String) 0 else 1
override fun isEnabled(position: Int) = getAccount(position) != null
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
getDropDownView(position, convertView, parent)
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val item = items[position]
return if (item is String) {
val b = if (convertView?.tag is ItemPickerSectionHeaderBinding) {
convertView.tag as ItemPickerSectionHeaderBinding
} else {
ItemPickerSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false)
.also { it.root.tag = it }
}
b.tvHeader.text = item
b.root
} else {
val acc = item as MibAccount
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
convertView.tag as ItemAccountDropdownBinding
} else {
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
.also { it.root.tag = it }
}
val inactive = acc.profileType == "BML_PREPAID" && !acc.statusDesc.equals("Active", ignoreCase = true)
b.tvDropdownAccountName.text = acc.accountBriefName
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
b.root.alpha = if (inactive) 0.4f else 1f
b.root
}
}
override fun getFilter() = object : Filter() {
override fun performFiltering(c: CharSequence?) = FilterResults().apply { values = items; count = items.size }
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
override fun convertResultToString(r: Any?) = ""
}
} }
} }
@@ -51,6 +51,7 @@ object AccountCache {
put("mvrBalance", acc.mvrBalance) put("mvrBalance", acc.mvrBalance)
put("statusDesc", acc.statusDesc) put("statusDesc", acc.statusDesc)
put("loginTag", acc.loginTag) put("loginTag", acc.loginTag)
put("internalId", acc.internalId)
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -77,7 +78,8 @@ object AccountCache {
mvrBalance = o.optString("mvrBalance"), mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = null, profileImageHash = null,
loginTag = o.optString("loginTag") loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "")
) )
} }
} catch (e: Exception) { emptyList() } } catch (e: Exception) { emptyList() }
+5
View File
@@ -115,6 +115,11 @@
<string name="transfer_session_unavailable">Session unavailable — please re-login</string> <string name="transfer_session_unavailable">Session unavailable — please re-login</string>
<string name="transfer_amount">Amount</string> <string name="transfer_amount">Amount</string>
<string name="transfer_remarks">Remarks</string> <string name="transfer_remarks">Remarks</string>
<string name="transfer_confirm">Confirm</string>
<string name="transfer_success">Transfer Successful</string>
<string name="transfer_bml_contact_required_title">Contact Required</string>
<string name="transfer_bml_contact_required_msg">To send USD to a MIB account from BML, the recipient must be saved as a BML contact first. This is required by BML\'s API.\n\nPlease add this account as a BML contact, then try again.</string>
<string name="transfer_missing_internal_id">Account data is incomplete — please re-login to refresh.</string>
<!-- Contacts --> <!-- Contacts -->
<string name="contacts_empty">No contacts found</string> <string name="contacts_empty">No contacts found</string>