contacts, contact picker and trnasfer account look up ui

This commit is contained in:
2026-05-13 02:15:53 +05:00
parent c49cce0cf2
commit b452940ed0
19 changed files with 1284 additions and 35 deletions

View File

@@ -4,6 +4,7 @@ import android.app.Application
import androidx.appcompat.app.AppCompatDelegate
import com.google.android.material.color.DynamicColors
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession
@@ -15,6 +16,10 @@ class BasedBankApp : Application() {
var mibSession: MibSession? = null
var mibProfiles: List<MibProfile> = emptyList()
val mibLoginFlow by lazy {
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE))
}
override fun onCreate() {
super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this)

View File

@@ -263,7 +263,8 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc")
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage
)
)
}
@@ -283,11 +284,22 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
name = p.optString("name"),
cifType = p.optString("cifType"),
profileType = p.optString("profileType"),
color = p.optString("color")
color = p.optString("color"),
customerImage = p.optString("customerImage").takeIf { it.isNotBlank() }
)
}
}
/** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */
fun fetchProfileImage(session: MibSession, imageHash: String): String? {
val payload = baseData(session, "P41").apply {
put("imageHash", imageHash)
}
val resp = doRequest(session, payload, "n")
if (!resp.optBoolean("success", false)) return null
return resp.optString("profileImage").takeIf { it.isNotBlank() }
}
private fun post(body: FormBody): String {
val request = Request.Builder()
.url(BASE_URL)

View File

@@ -15,7 +15,8 @@ data class MibProfile(
val name: String,
val cifType: String,
val profileType: String,
val color: String
val color: String,
val customerImage: String?
)
data class MibAccount(
@@ -29,7 +30,8 @@ data class MibAccount(
val currentBalance: String,
val blockedAmount: String,
val mvrBalance: String,
val statusDesc: String
val statusDesc: String,
val profileImageHash: String?
)
data class MibBeneficiaryCategory(
@@ -53,6 +55,12 @@ data class MibBeneficiary(
val benefCategoryId: String // "0" = uncategorized
)
data class MibIpsAccountInfo(
val accountName: String,
val accountNumber: String,
val bankId: String
)
data class MibFinanceDeal(
val dealNo: String,
val productDesc: String,

View File

@@ -0,0 +1,143 @@
package sh.sar.basedbank.api.mib
import android.util.Log
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibLookupException(message: String) : Exception(message)
class MibTransferClient {
private val TAG = "MibTransferClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
private fun Request.Builder.withWvHeaders(session: MibSession): Request.Builder = this
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/transfer/quick")
/**
* Routes the lookup to the correct endpoint based on input format:
*
* - 15 digits starting with 7 → IPS account lookup (BML / local bank)
* - 17 digits starting with 9 → MIB internal account name lookup
* - 7 digits starting with 7 or 9,
* A + 6 digits,
* or email address → Favara alias lookup
*/
fun lookup(session: MibSession, input: String): MibIpsAccountInfo {
val trimmed = input.trim()
return when {
trimmed.matches(Regex("^7\\d{12}$")) -> lookupIpsAccount(session, trimmed)
trimmed.matches(Regex("^9\\d{16}$")) -> lookupAccountName(session, trimmed)
trimmed.matches(Regex("^[79]\\d{6}$")) -> lookupAlias(session, trimmed)
trimmed.matches(Regex("^[Aa]\\d{6}$")) -> lookupAlias(session, trimmed)
trimmed.contains("@") -> lookupAlias(session, trimmed)
else -> throw MibLookupException("Unrecognized account number format")
}
}
/** BML / local bank: 15-digit account starting with 7. */
private fun lookupIpsAccount(session: MibSession, accountNumber: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("benefAccount", accountNumber).build()
val request = Request.Builder()
.url("$BASE_WV_URL/AjaxAlias/getIPSAccount")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
Log.d(TAG, "lookupIpsAccount: HTTP ${response.code}")
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
MibIpsAccountInfo(
accountName = json.optString("accountName").trim(),
accountNumber = accountNumber,
bankId = json.optString("bankBic")
)
}
}
/** MIB internal: 17-digit account starting with 9. */
private fun lookupAccountName(session: MibSession, accountNumber: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("accountNo", accountNumber).build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/getAccountName")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
Log.d(TAG, "lookupAccountName: HTTP ${response.code}")
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
// accountName may be at root or inside a "data" object
val name = json.optString("accountName").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("accountName") ?: ""
MibIpsAccountInfo(
accountName = name.trim(),
accountNumber = accountNumber,
bankId = "MADVMVMV" // MIB
)
}
}
/** Favara alias: 7-digit shortcode (7/9 prefix), A-format ID, or email. */
private fun lookupAlias(session: MibSession, aliasName: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("aliasName", aliasName).build()
val request = Request.Builder()
.url("$BASE_WV_URL/AjaxAlias/getAlias")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
Log.d(TAG, "lookupAlias: HTTP ${response.code}")
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
val data = json.getJSONObject("data")
val cdtrAcct = data.getJSONObject("CdtrAcct")
MibIpsAccountInfo(
accountName = data.optString("BfyNm").trim(),
accountNumber = cdtrAcct.optString("Acct"),
bankId = cdtrAcct.optString("FinInstnId")
)
}
}
}

View File

@@ -0,0 +1,125 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.databinding.ItemPickerRowBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
class ContactPickerAdapter(
private val onItemClick: (accountNumber: String, label: String) -> Unit,
private val onSameAsFrom: () -> Unit,
private val onImageNeeded: ((hash: String) -> Unit)? = null,
private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
sealed class PickerItem {
data class Header(val title: String) : PickerItem()
data class Row(
val accountNumber: String,
val displayName: String,
val subtitle: String,
val colorHex: String,
val isSameAsFrom: Boolean = false,
val isManualEntry: Boolean = false,
val imageHash: String? = null
) : PickerItem()
}
private var items: List<PickerItem> = emptyList()
private val imageCache = mutableMapOf<String, Bitmap>()
fun updateImage(hash: String, bitmap: Bitmap) {
imageCache[hash] = bitmap
val idx = items.indexOfFirst { it is PickerItem.Row && it.imageHash == hash }
if (idx >= 0) notifyItemChanged(idx)
}
fun submitList(newItems: List<PickerItem>) {
items = newItems
notifyDataSetChanged()
}
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_ROW = 1
}
override fun getItemViewType(position: Int) = when (items[position]) {
is PickerItem.Header -> TYPE_HEADER
is PickerItem.Row -> TYPE_ROW
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_HEADER -> HeaderVH(ItemPickerSectionHeaderBinding.inflate(inflater, parent, false))
else -> RowVH(ItemPickerRowBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) {
is PickerItem.Header -> (holder as HeaderVH).bind(item)
is PickerItem.Row -> (holder as RowVH).bind(item)
}
}
override fun getItemCount() = items.size
inner class HeaderVH(private val binding: ItemPickerSectionHeaderBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: PickerItem.Header) {
binding.tvHeader.text = item.title
}
}
inner class RowVH(private val binding: ItemPickerRowBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: PickerItem.Row) {
binding.tvPrimary.text = item.displayName
binding.tvSecondary.text = item.subtitle
val cached = item.imageHash?.let { imageCache[it] }
if (cached != null) {
binding.ivIcon.setImageBitmap(cached)
} else {
val iconChar = if (item.isManualEntry) "" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
}
binding.root.alpha = if (item.isSameAsFrom) 0.4f else 1.0f
binding.root.setOnClickListener {
if (item.isSameAsFrom) onSameAsFrom()
else onItemClick(item.accountNumber, item.displayName)
}
binding.root.setOnLongClickListener { view ->
onItemLongClick?.invoke(item.accountNumber, view) ?: false
}
}
private fun makeInitialsBitmap(letter: String, colorHex: String, context: Context): Bitmap {
val sizePx = (context.resources.displayMetrics.density * 44).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = bgColor
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
paint.color = Color.WHITE
paint.textSize = sizePx * 0.42f
paint.textAlign = Paint.Align.CENTER
val metrics = paint.fontMetrics
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm
}
}
}

View File

@@ -0,0 +1,222 @@
package sh.sar.basedbank.ui.home
import android.graphics.BitmapFactory
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.tabs.TabLayout
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient
import android.widget.PopupMenu
import sh.sar.basedbank.databinding.SheetContactPickerBinding
import sh.sar.basedbank.util.RecentsCache
class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private var _binding: SheetContactPickerBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: ContactPickerAdapter
// null = All, RECENTS_TAG = Recents, else = category id
private var activeCategoryId: String? = RECENTS_TAG
private val pendingHashes = mutableSetOf<String>()
private val profileImageHashes = mutableSetOf<String>()
private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SheetContactPickerBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val selectedAccountNumber = arguments?.getString(ARG_FROM_ACCOUNT) ?: ""
adapter = ContactPickerAdapter(
onItemClick = { accountNumber, label ->
setFragmentResult(REQUEST_KEY, bundleOf(
KEY_ACCOUNT_NUMBER to accountNumber,
KEY_LABEL to label
))
dismiss()
},
onSameAsFrom = {},
onImageNeeded = { hash -> fetchImage(hash) },
onItemLongClick = { accountNumber, anchor ->
if (activeCategoryId == RECENTS_TAG) {
val menu = PopupMenu(requireContext(), anchor)
menu.menu.add(getString(R.string.recents_remove))
menu.setOnMenuItemClickListener {
RecentsCache.remove(requireContext(), accountNumber)
rebuildList(selectedAccountNumber)
true
}
menu.show()
true
} else false
}
)
binding.sheetRecyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.sheetRecyclerView.adapter = adapter
binding.etSheetSearch.addTextChangedListener { rebuildList(selectedAccountNumber) }
// Tabs: Recents | All | <categories...>
binding.sheetCategoryTabs.addTab(
binding.sheetCategoryTabs.newTab().setText(R.string.contacts_tab_recents).apply { tag = RECENTS_TAG }
)
binding.sheetCategoryTabs.addTab(
binding.sheetCategoryTabs.newTab().setText(R.string.contacts_tab_all).apply { tag = null }
)
binding.sheetCategoryTabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
activeCategoryId = tab.tag as? String
rebuildList(selectedAccountNumber)
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {}
})
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
// Remove all tabs after "All" (index 1) and re-add categories
while (binding.sheetCategoryTabs.tabCount > 2) binding.sheetCategoryTabs.removeTabAt(2)
for (cat in cats) {
binding.sheetCategoryTabs.addTab(
binding.sheetCategoryTabs.newTab().setText(cat.categoryName).apply { tag = cat.id }
)
}
rebuildList(selectedAccountNumber)
}
viewModel.contacts.observe(viewLifecycleOwner) { rebuildList(selectedAccountNumber) }
viewModel.accounts.observe(viewLifecycleOwner) { rebuildList(selectedAccountNumber) }
}
private fun rebuildList(fromNumber: String) {
val search = binding.etSheetSearch.text?.toString()?.trim() ?: ""
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
if (activeCategoryId == RECENTS_TAG) {
val recents = RecentsCache.load(requireContext())
val filtered = if (search.isBlank()) recents else recents.filter {
it.displayName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
}
for (r in filtered) {
if (r.isProfileImage && r.imageHash != null) profileImageHashes.add(r.imageHash)
items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = r.accountNumber,
displayName = r.displayName,
subtitle = r.subtitle,
colorHex = r.colorHex,
isSameAsFrom = r.accountNumber == fromNumber,
imageHash = r.imageHash
))
}
adapter.submitList(items)
return
}
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
if (activeCategoryId == null) {
val filtered = if (search.isBlank()) accounts else accounts.filter {
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
}
if (filtered.isNotEmpty()) {
items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.transfer_my_accounts)))
for (acc in filtered) {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber,
displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
colorHex = "#FE860E",
isSameAsFrom = acc.accountNumber == fromNumber,
imageHash = acc.profileImageHash
))
}
}
}
val filteredContacts = contacts.filter { contact ->
val matchesCat = activeCategoryId == null || contact.benefCategoryId == activeCategoryId
val matchesSearch = search.isBlank() ||
contact.benefNickName.contains(search, ignoreCase = true) ||
contact.benefName.contains(search, ignoreCase = true) ||
contact.benefAccount.contains(search)
matchesCat && matchesSearch
}
if (filteredContacts.isNotEmpty()) {
if (activeCategoryId == null) {
items.add(ContactPickerAdapter.PickerItem.Header(getString(R.string.nav_contacts)))
}
for (contact in filteredContacts) {
items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
colorHex = contact.bankColor,
isSameAsFrom = contact.benefAccount == fromNumber,
imageHash = contact.customerImgHash
))
}
}
adapter.submitList(items)
}
private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return
val sess = session ?: return
lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = if (hash in profileImageHashes) {
app.mibLoginFlow.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) {
adapter.updateImage(hash, bitmap)
}
} catch (_: Exception) {
pendingHashes.remove(hash)
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
const val REQUEST_KEY = "contact_picker"
const val KEY_ACCOUNT_NUMBER = "accountNumber"
const val KEY_LABEL = "label"
private const val ARG_FROM_ACCOUNT = "fromAccount"
private const val RECENTS_TAG = "__recents__"
fun newInstance(fromAccountNumber: String) = ContactPickerSheetFragment().apply {
arguments = bundleOf(ARG_FROM_ACCOUNT to fromAccountNumber)
}
}
}

View File

@@ -1,18 +1,36 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
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.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
class TransferFragment : Fragment() {
@@ -21,6 +39,7 @@ class TransferFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: MibAccount? = null
private val session get() = (requireActivity().application as BasedBankApp).mibSession
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentTransferBinding.inflate(inflater, container, false)
@@ -28,8 +47,18 @@ class TransferFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
setupAccountDropdown(accounts)
setupFromDropdown()
setupAccountLookup()
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
prefillToFromContact(accountNumber, label)
}
binding.btnPickContact.setOnClickListener {
val sheet = ContactPickerSheetFragment.newInstance(selectedAccount?.accountNumber ?: "")
sheet.show(childFragmentManager, "contact_picker")
}
binding.btnTransfer.setOnClickListener {
@@ -37,21 +66,188 @@ class TransferFragment : Fragment() {
}
}
private fun setupAccountDropdown(accounts: List<MibAccount>) {
val adapter = AccountDropdownAdapter(requireContext(), accounts)
binding.actvFrom.setAdapter(adapter)
private fun setupFromDropdown() {
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val adapter = AccountDropdownAdapter(requireContext(), accounts)
binding.actvFrom.setAdapter(adapter)
if (accounts.isNotEmpty() && selectedAccount == null) {
selectedAccount = accounts[0]
binding.actvFrom.setText(accounts[0].toDisplayString(), false)
if (accounts.isNotEmpty() && selectedAccount == null) {
selectedAccount = accounts[0]
binding.actvFrom.setText(accounts[0].toDisplayString(), false)
}
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
selectedAccount = accounts[position]
binding.actvFrom.setText(accounts[position].toDisplayString(), false)
}
}
}
private fun setupAccountLookup() {
binding.tilTo.setEndIconOnClickListener { lookupAccount() }
binding.btnClearToInfo.setOnClickListener {
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.tilTo.error = null
}
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
selectedAccount = accounts[position]
binding.actvFrom.setText(accounts[position].toDisplayString(), false)
binding.etTo.addTextChangedListener {
binding.tilTo.error = null
if (binding.cardToInfo.visibility == View.VISIBLE) {
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
}
}
}
private fun lookupAccount() {
val accountNumber = binding.etTo.text?.toString()?.trim() ?: ""
if (accountNumber.isBlank()) {
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show()
return
}
val sess = session
if (sess == null) {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
binding.tilTo.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
var errorMsg: String? = null
val info = withContext(Dispatchers.IO) {
try {
MibTransferClient().lookup(sess, accountNumber)
} catch (e: MibLookupException) {
errorMsg = e.message
null
} catch (_: Exception) {
errorMsg = getString(R.string.transfer_account_not_found)
null
}
}
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 displayName = matchedAcc?.accountBriefName
?: matchedContact?.benefNickName
?: info.accountName
val colorHex = if (matchedAcc != null) "#FE860E" else matchedContact?.bankColor ?: "#607D8B"
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
saveToRecents(info)
when {
matchedAcc?.profileImageHash != null ->
loadToPhoto(matchedAcc.profileImageHash, isProfile = true)
matchedContact?.customerImgHash != null ->
loadToPhoto(matchedContact.customerImgHash, isProfile = false)
}
} else {
Toast.makeText(requireContext(), errorMsg, Toast.LENGTH_SHORT).show()
}
}
}
private fun prefillToFromContact(accountNumber: String, label: String) {
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.tilTo.error = null
binding.etTo.setText(accountNumber)
lookupAccount()
}
private fun loadToPhoto(hash: String, isProfile: Boolean) {
val sess = session ?: return
val app = requireActivity().application as BasedBankApp
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = if (isProfile) {
app.mibLoginFlow.fetchProfileImage(sess, hash)
} else {
MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) {
if (_binding != null) binding.ivToPhoto.setImageBitmap(bitmap)
}
} catch (_: Exception) { }
}
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = (resources.displayMetrics.density * 40).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = bgColor
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
paint.color = Color.WHITE
paint.textSize = sizePx * 0.42f
paint.textAlign = Paint.Align.CENTER
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val metrics = paint.fontMetrics
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm
}
private fun saveToRecents(info: MibIpsAccountInfo) {
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
val acc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
if (acc != null) {
RecentsCache.save(requireContext(), RecentPick(
accountNumber = acc.accountNumber,
displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
colorHex = "#FE860E",
imageHash = acc.profileImageHash,
isProfileImage = true
))
return
}
val contact = contacts.firstOrNull { it.benefAccount == info.accountNumber }
if (contact != null) {
RecentsCache.save(requireContext(), RecentPick(
accountNumber = contact.benefAccount,
displayName = contact.benefNickName,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
colorHex = contact.bankColor,
imageHash = contact.customerImgHash,
isProfileImage = false
))
return
}
// Manual entry not in contacts/accounts — save with resolved info
RecentsCache.save(requireContext(), RecentPick(
accountNumber = info.accountNumber,
displayName = info.accountName,
subtitle = "${info.accountNumber} · ${info.bankId}",
colorHex = "#607D8B",
imageHash = null,
isProfileImage = false
))
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.transfer)

View File

@@ -25,6 +25,7 @@ object AccountCache {
put("blockedAmount", acc.blockedAmount)
put("mvrBalance", acc.mvrBalance)
put("statusDesc", acc.statusDesc)
if (acc.profileImageHash != null) put("profileImageHash", acc.profileImageHash)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -49,7 +50,8 @@ object AccountCache {
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc")
statusDesc = o.optString("statusDesc"),
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() }
)
}
} catch (e: Exception) {

View File

@@ -0,0 +1,80 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
data class RecentPick(
val accountNumber: String,
val displayName: String,
val subtitle: String,
val colorHex: String,
val imageHash: String?,
val isProfileImage: Boolean
)
object RecentsCache {
private const val PREFS = "recents_cache"
private const val KEY = "contact_recents"
private const val MAX = 10
fun save(context: Context, pick: RecentPick) {
val existing = load(context).toMutableList()
existing.removeAll { it.accountNumber == pick.accountNumber }
existing.add(0, pick)
if (existing.size > MAX) existing.subList(MAX, existing.size).clear()
val arr = JSONArray()
for (r in existing) {
arr.put(JSONObject().apply {
put("accountNumber", r.accountNumber)
put("displayName", r.displayName)
put("subtitle", r.subtitle)
put("colorHex", r.colorHex)
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
}
fun remove(context: Context, accountNumber: String) {
val updated = load(context).filter { it.accountNumber != accountNumber }
val arr = JSONArray()
for (r in updated) {
arr.put(JSONObject().apply {
put("accountNumber", r.accountNumber)
put("displayName", r.displayName)
put("subtitle", r.subtitle)
put("colorHex", r.colorHex)
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
}
fun load(context: Context): List<RecentPick> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY, null) ?: return emptyList()
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
RecentPick(
accountNumber = o.getString("accountNumber"),
displayName = o.getString("displayName"),
subtitle = o.getString("subtitle"),
colorHex = o.getString("colorHex"),
imageHash = o.optString("imageHash").takeIf { it.isNotBlank() },
isProfileImage = o.optBoolean("isProfileImage", false)
)
}
} catch (_: Exception) {
emptyList()
}
}
}

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z"/>
</vector>

View File

@@ -6,13 +6,6 @@
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="scrollable"
app:tabGravity="start" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -37,6 +30,13 @@
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="scrollable"
app:tabGravity="start" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"

View File

@@ -86,6 +86,39 @@
</LinearLayout>
<!-- Pending Finances card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_pending_finances"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvPendingFinances"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Card support WIP -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"

View File

@@ -45,23 +45,111 @@
</com.google.android.material.textfield.TextInputLayout>
<!-- To account number -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilTo"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
<!-- To field row: input + pick contact button -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/transfer_to"
android:layout_marginBottom="16dp">
android:orientation="horizontal"
android:gravity="center_vertical">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etTo"
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilTo"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:hint="@string/transfer_to"
app:endIconMode="custom"
app:endIconDrawable="@android:drawable/ic_menu_search"
app:endIconContentDescription="@string/transfer_lookup_account">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etTo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textNoSuggestions"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPickContact"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:icon="@drawable/ic_contacts"
android:contentDescription="@string/transfer_pick_contact" />
</LinearLayout>
<!-- Confirmed account info card (shown after successful lookup) -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardToInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:cardCornerRadius="4dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorPrimary">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLines="1" />
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="4dp"
android:paddingVertical="12dp"
android:gravity="center_vertical">
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivToPhoto"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="12dp"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvToAccountName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvToBankBic"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<ImageButton
android:id="@+id/btnClearToInfo"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/transfer_clear_recipient" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<View
android:id="@+id/spacerTo"
android:layout_width="match_parent"
android:layout_height="16dp" />
<!-- Amount -->
<com.google.android.material.textfield.TextInputLayout

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp"
android:minHeight="64dp"
android:gravity="center_vertical"
android:background="?attr/selectableItemBackground">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivIcon"
android:layout_width="44dp"
android:layout_height="44dp"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/tvPrimary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvSecondary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/tvHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="16dp"
android:paddingTop="16dp"
android:paddingBottom="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:letterSpacing="0.08" />

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="4dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
app:startIconDrawable="@android:drawable/ic_menu_search"
app:boxCornerRadiusTopStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusBottomEnd="24dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSheetSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/contacts_search_hint"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionSearch" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.tabs.TabLayout
android:id="@+id/sheetCategoryTabs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="scrollable"
app:tabGravity="start" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/sheetRecyclerView"
android:layout_width="match_parent"
android:layout_height="400dp"
android:clipToPadding="false"
android:paddingBottom="24dp" />
</LinearLayout>

View File

@@ -74,6 +74,7 @@
<string name="work_in_progress">Work in progress</string>
<!-- Dashboard -->
<string name="dashboard_pending_finances">Pending Finances</string>
<string name="balance_mvr">MVR Total</string>
<string name="balance_usd">USD Total</string>
<string name="card_support_wip">Card Support</string>
@@ -94,14 +95,26 @@
<string name="available_balance">Available Balance</string>
<!-- Transfer -->
<string name="transfer_tab_quick">Quick Transfer</string>
<string name="transfer_tab_contacts">Contacts</string>
<string name="transfer_from">From account</string>
<string name="transfer_to">To account number</string>
<string name="transfer_to">Account Number or Favara ID</string>
<string name="transfer_my_accounts">My Accounts</string>
<string name="transfer_same_as_from">This is the same account as the sender</string>
<string name="transfer_lookup_account">Look up account</string>
<string name="transfer_clear_recipient">Clear recipient</string>
<string name="transfer_pick_contact">Pick contact</string>
<string name="transfer_enter_account_first">Enter an account number first</string>
<string name="transfer_account_not_found">Account not found</string>
<string name="transfer_session_unavailable">Session unavailable — please re-login</string>
<string name="transfer_amount">Amount</string>
<string name="transfer_remarks">Remarks</string>
<!-- Contacts -->
<string name="contacts_empty">No contacts found</string>
<string name="contacts_search_hint">Search contacts</string>
<string name="contacts_tab_recents">Recents</string>
<string name="recents_remove">Remove from recents</string>
<string name="contacts_tab_all">All</string>
<!-- Financing -->

View File

@@ -0,0 +1,123 @@
# MIB Account Lookup Routing
Before initiating a transfer, the recipient input must be resolved to a verified account name and
account number. Three different endpoints are used depending on the format of the input.
## Input Format Routing
| Input format | Endpoint | Body field |
|-------------------------------------------|---------------------------------------|-----------------|
| Starts with `7`, exactly 13 digits | `AjaxAlias/getIPSAccount` | `benefAccount` |
| Starts with `9`, exactly 17 digits | `ajaxBeneficiary/getAccountName` | `accountNo` |
| Starts with `7` or `9`, exactly 7 digits | `AjaxAlias/getAlias` | `aliasName` |
| Starts with `A` followed by 6 digits | `AjaxAlias/getAlias` | `aliasName` |
| Email address (contains `@`) | `AjaxAlias/getAlias` | `aliasName` |
All endpoints share the same WebView session auth (see `contacts.md` for cookie format) and use
`Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick`.
---
## Endpoint Details
### 1. IPS Account Lookup — Local / BML accounts (13 digits, starts with 7)
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getIPSAccount`
Body: `benefAccount=7700000000000` (13 digits)
**Success response:**
```json
{
"success": true,
"responseCode": "2",
"reasonCode": "201",
"reasonText": "Request Successful. Account Found",
"accountName": "ACCOUNT HOLDER NAME",
"bankBic": "MALBMVMV"
}
```
Fields used:
- `accountName` — account holder name
- `bankBic` — bank SWIFT/BIC code
The account number is already known from the input; it is not returned in the response.
---
### 2. MIB Internal Account Name Lookup — MIB accounts (17 digits, starts with 9)
**POST** `https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getAccountName`
Body: `accountNo=90100000000000000` (17 digits)
**Success response** (exact structure to be confirmed):
```json
{
"success": true,
"responseCode": "1",
"reasonText": "Account found",
"accountName": "ACCOUNT HOLDER NAME"
}
```
Fields used:
- `accountName` — account holder name (check at root level or inside `data` object)
The account number is already known from the input; bank is always MIB (`MADVMVMV`).
---
### 3. Favara Alias Lookup — Shortcodes, A-IDs, emails
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias`
Body: `aliasName=<alias>`
Accepted alias formats:
- `7` or `9` followed by 6 digits → e.g. `7012345`, `9198026`
- `A` followed by 6 digits → e.g. `A123456`
- Email address → e.g. `user@example.com`
**Success response:**
```json
{
"success": true,
"responseCode": "2",
"reasonCode": "203",
"reasonText": " Favara ID found",
"data": {
"TxId": "BANK00001",
"CdtrAcct": {
"Acct": "90100000000000000",
"FinInstnId": "MADVMVMV"
},
"BfyNm": "Account Holder Name",
"RegDtTm": "2023-01-01T00:00:00"
}
}
```
Fields used from `data`:
- `BfyNm` — beneficiary name (trim whitespace)
- `CdtrAcct.Acct` — resolved account number to use for the transfer
- `CdtrAcct.FinInstnId` — bank institution ID
---
## Error Handling
All three endpoints return `"success": false` on failure with a human-readable `reasonText`:
```json
{
"success": false,
"responseCode": "0",
"reasonText": "Account not found"
}
```
- Always show `reasonText` directly to the user as the error message.
- For non-200 HTTP responses, also attempt to parse `reasonText` from the body before falling back to a generic error.
- If the input does not match any known format, reject it client-side before making any request.

81
docs/mibapi/transfer.md Normal file
View File

@@ -0,0 +1,81 @@
# MIB Transfer API
Transfer endpoints are served from the MIB WebView subdomain, using the same session-cookie auth as
financing and contacts.
## Authentication
```
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
```
All AJAX POST requests also require:
```
X-Requested-With: XMLHttpRequest
Origin: https://faisamobilex-wv.mib.com.mv
Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
```
---
## Endpoints
### 1. Look Up Recipient by Favara Alias
Resolves a Favara ID (alias) to the account holder name and account number before initiating a transfer.
**POST** `https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias`
**Request body (form-urlencoded):**
| Field | Description |
|-------------|------------------------------------------|
| `aliasName` | The recipient's Favara ID / alias number |
**Success response (`responseCode: "2"`):**
```json
{
"success": true,
"responseCode": "2",
"reasonCode": "203",
"reasonText": " Favara ID found",
"data": {
"TxId": "BANK00001",
"CreDtTm": "...",
"Resp": {
"Rslt": true,
"RsltDtls": null
},
"CdtrAcct": {
"Acct": "90100000000000000",
"FinInstnId": "MADVMVMV"
},
"BfyNm": "Account Holder Name",
"RegDtTm": "2023-01-01T00:00:00"
}
}
```
**Not found / error response:**
```json
{
"success": false,
"responseCode": "0",
"reasonCode": "400",
"reasonText": "Alias not found"
}
```
Key fields from `data`:
- `BfyNm` — beneficiary full name (trim whitespace)
- `CdtrAcct.Acct` — resolved account number to use for the transfer
- `CdtrAcct.FinInstnId` — bank institution ID (e.g. `MADVMVMV`, `MALBMVMV`)
**Notes:**
- Use `success` (not `responseCode`) to determine if the lookup succeeded.
- Show `BfyNm` + `CdtrAcct.Acct` to the user as confirmation before proceeding.
- The `reasonText` from error responses should be shown directly to the user.