add transfers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
*
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?) = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user