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

View File

@@ -311,6 +311,98 @@ class BmlLoginFlow {
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 {
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
@@ -350,6 +442,8 @@ class BmlLoginFlow {
val accountNumber = item.optString("account")
val status = item.optString("account_status", "Active")
val internalId = item.optString("id", "")
if (accountType == "CASA") {
val available = item.optDouble("availableBalance", 0.0)
casaAccounts.add(MibAccount(
@@ -365,7 +459,8 @@ class BmlLoginFlow {
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag
loginTag = loginTag,
internalId = internalId
))
} else if (accountType == "Card") {
val isPrepaid = item.optBoolean("prepaid_card", false)
@@ -385,7 +480,8 @@ class BmlLoginFlow {
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag
loginTag = loginTag,
internalId = internalId
))
} else {
// Linked debit cards have no independent balance or account link — skip

View File

@@ -16,6 +16,13 @@ data class BmlAccountValidation(
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(
val type: String,
val used: Double,

View File

@@ -204,7 +204,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
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>()
for (profile in profiles) {
val payload = baseData(session, "P47").apply {

View File

@@ -33,7 +33,15 @@ data class MibAccount(
val statusDesc: String,
val profileImageHash: 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(

View File

@@ -33,6 +33,59 @@ class MibTransferClient {
.header("Origin", BASE_WV_URL)
.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:
*

View File

@@ -49,13 +49,22 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
adapter = ContactPickerAdapter(
onItemClick = { accountNumber, label ->
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)
if (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) }
when {
account?.profileType == "BML_PREPAID" -> {
bundle.putBoolean(KEY_SKIP_LOOKUP, true)
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
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)
dismiss()

View File

@@ -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>) {
if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)

View File

@@ -11,8 +11,11 @@ import android.util.Base64
import android.view.LayoutInflater
import android.view.View
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 androidx.appcompat.app.AlertDialog
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -26,16 +29,22 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
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.MibBeneficiary
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.mib.MibLookupException
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.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.Totp
class TransferFragment : Fragment() {
@@ -47,6 +56,23 @@ class TransferFragment : Fragment() {
private val session get() = (requireActivity().application as BasedBankApp).mibSession
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 {
private const val ARG_ACCOUNT = "contact_account"
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 {
_binding = FragmentTransferBinding.inflate(inflater, container, false)
return binding.root
@@ -115,9 +128,7 @@ class TransferFragment : Fragment() {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
binding.btnTransfer.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnTransfer.setOnClickListener { initiateTransfer() }
// Pre-select contact if navigated from contacts page
arguments?.getString(ARG_ACCOUNT)?.let { account ->
@@ -136,22 +147,27 @@ class TransferFragment : Fragment() {
val adapter = AccountDropdownAdapter(requireContext(), accounts)
binding.actvFrom.setAdapter(adapter)
if (accounts.isNotEmpty() && selectedAccount == null) {
selectedAccount = accounts[0]
binding.actvFrom.setText(accounts[0].toDisplayString(), false)
}
// No default selection — user must explicitly pick a source account
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
selectedAccount = accounts[position]
binding.actvFrom.setText(accounts[position].toDisplayString(), false)
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
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() {
binding.tilTo.setEndIconOnClickListener { lookupAccount() }
binding.btnClearToInfo.setOnClickListener {
resolvedAccountNumber = ""
resolvedRecipientName = ""
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
@@ -162,6 +178,8 @@ class TransferFragment : Fragment() {
binding.etTo.addTextChangedListener {
binding.tilTo.error = null
if (binding.cardToInfo.visibility == View.VISIBLE) {
resolvedAccountNumber = ""
resolvedRecipientName = ""
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
@@ -206,7 +224,6 @@ class TransferFragment : Fragment() {
var errorMsg: String? = null
val info = withContext(Dispatchers.IO) {
if (isBmlSource && bmlSess != null) {
// BML source: prefer BML validate, fall back to MIB IPS
val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess, accountNumber) } catch (_: Exception) { null }
if (bmlResult != null) {
val bankId = when (bmlResult.trnType) {
@@ -222,13 +239,11 @@ class TransferFragment : Fragment() {
errorMsg = getString(R.string.transfer_account_not_found); null
}
} else {
// MIB source (or no preference): prefer MIB, fall back to BML validate
if (mibSess != null) {
try { MibTransferClient().lookup(mibSess, accountNumber) }
catch (e: MibLookupException) { errorMsg = e.message; null }
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
} else {
// MIB not available, try BML
val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
if (bmlResult != null) {
val bankId = when (bmlResult.trnType) {
@@ -245,14 +260,14 @@ class TransferFragment : Fragment() {
binding.tilTo.isEnabled = true
if (info != null) {
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
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
?: matchedContact?.benefNickName
?: info.accountName
val colorHex = if (matchedAcc != null) "#FE860E" else matchedContact?.bankColor ?: "#607D8B"
val displayName = matchedAcc?.accountBriefName ?: matchedCont?.benefNickName ?: info.accountName
val colorHex = if (matchedAcc != null) "#FE860E" else matchedCont?.bankColor ?: "#607D8B"
resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
@@ -266,8 +281,8 @@ class TransferFragment : Fragment() {
when {
matchedAcc?.profileImageHash != null ->
loadToPhoto(matchedAcc.profileImageHash, isProfile = true)
matchedContact?.customerImgHash != null ->
loadToPhoto(matchedContact.customerImgHash, isProfile = false)
matchedCont?.customerImgHash != null ->
loadToPhoto(matchedCont.customerImgHash, isProfile = false)
}
} else {
Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show()
@@ -282,6 +297,9 @@ class TransferFragment : Fragment() {
colorHex: String,
imageHash: String?
) {
resolvedAccountNumber = accountNumber
resolvedRecipientName = displayName
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = subtitle
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
@@ -306,6 +324,8 @@ class TransferFragment : Fragment() {
}
private fun prefillToFromContact(accountNumber: String, label: String) {
resolvedAccountNumber = ""
resolvedRecipientName = ""
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
@@ -315,6 +335,206 @@ class TransferFragment : Fragment() {
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) {
val sess = session ?: return
val app = requireActivity().application as BasedBankApp
@@ -381,7 +601,6 @@ class TransferFragment : Fragment() {
return
}
// Manual entry not in contacts/accounts — save with resolved info
RecentsCache.save(requireContext(), RecentPick(
accountNumber = info.accountNumber,
displayName = info.accountName,
@@ -404,29 +623,67 @@ class TransferFragment : Fragment() {
private fun MibAccount.toDisplayString() = "$accountBriefName · $accountNumber"
// items: String = section header, MibAccount = selectable row
private inner class AccountDropdownAdapter(
context: Context,
private val accounts: List<MibAccount>
) : ArrayAdapter<String>(context, android.R.layout.simple_list_item_1, accounts.map { it.toDisplayString() }) {
private val context: Context,
accounts: List<MibAccount>
) : BaseAdapter(), Filterable {
private fun bindDropdown(convertView: View?, parent: ViewGroup, position: Int): View {
val itemBinding = if (convertView?.tag is ItemAccountDropdownBinding) {
convertView.tag as ItemAccountDropdownBinding
} else {
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
.also { it.root.tag = it }
private val items: List<Any> = buildList {
val regular = accounts.filter { it.profileType != "BML_PREPAID" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" }
addAll(regular)
if (cards.isNotEmpty()) {
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) =
bindDropdown(convertView, parent, position)
fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount)
?.takeUnless { it.profileType == "BML_PREPAID" && !it.statusDesc.equals("Active", ignoreCase = true) }
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup) =
bindDropdown(convertView, parent, position)
override fun getCount() = items.size
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?) = ""
}
}
}

View File

@@ -51,6 +51,7 @@ object AccountCache {
put("mvrBalance", acc.mvrBalance)
put("statusDesc", acc.statusDesc)
put("loginTag", acc.loginTag)
put("internalId", acc.internalId)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -77,7 +78,8 @@ object AccountCache {
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
profileImageHash = null,
loginTag = o.optString("loginTag")
loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "")
)
}
} catch (e: Exception) { emptyList() }

View File

@@ -115,6 +115,11 @@
<string name="transfer_session_unavailable">Session unavailable — please re-login</string>
<string name="transfer_amount">Amount</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 -->
<string name="contacts_empty">No contacts found</string>