diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt index 1ac6ba7..f6fdde2 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlLoginFlow.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt index 706d7b3..a8d18ce 100644 --- a/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlModels.kt @@ -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, diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt index 9fe4c04..96dc130 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt @@ -204,7 +204,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { doRequest(session, payload, "n") } - private fun fetchAllProfiles(session: MibSession, profiles: List, loginTag: String): List { + fun fetchAllProfiles(session: MibSession, profiles: List, loginTag: String): List { val allAccounts = mutableListOf() for (profile in profiles) { val payload = baseData(session, "P47").apply { diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt index d835bd1..ea3b195 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt @@ -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( diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt index 590675d..6609efe 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt @@ -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: * diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt index f3bd19d..b8a0d78 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt @@ -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() diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 52b2dda..f60651a 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -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) { if (session == null || profiles.isEmpty()) return val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 8b05f2f..69d089b 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -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 { + 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, + allContacts: List + ): Pair { + 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 - ) : ArrayAdapter(context, android.R.layout.simple_list_item_1, accounts.map { it.toDisplayString() }) { + private val context: Context, + accounts: List + ) : 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 = 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?) = "" + } } } diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt index 7fda1f6..fdbd050 100644 --- a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt @@ -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() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c31dfdd..6a4146a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -115,6 +115,11 @@ Session unavailable — please re-login Amount Remarks + Confirm + Transfer Successful + Contact Required + 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. + Account data is incomplete — please re-login to refresh. No contacts found