From 69b7862e655905f56f566a80411ba424196b4aaa Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Tue, 12 May 2026 23:27:24 +0500 Subject: [PATCH] add support for mib loans view --- .../java/sh/sar/basedbank/BasedBankApp.kt | 4 + .../basedbank/api/mib/MibFinancingClient.kt | 82 +++++ .../sh/sar/basedbank/api/mib/MibLoginFlow.kt | 23 ++ .../sh/sar/basedbank/api/mib/MibModels.kt | 17 + .../sar/basedbank/ui/home/FinancingAdapter.kt | 111 ++++++ .../basedbank/ui/home/FinancingFragment.kt | 47 +++ .../sh/sar/basedbank/ui/home/HomeActivity.kt | 50 ++- .../sh/sar/basedbank/ui/home/HomeViewModel.kt | 2 + .../basedbank/ui/login/CredentialsFragment.kt | 5 +- .../sh/sar/basedbank/util/FinancingCache.kt | 65 ++++ .../main/res/layout/fragment_financing.xml | 41 +++ app/src/main/res/layout/item_finance_deal.xml | 328 ++++++++++++++++++ app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/strings.xml | 15 + docs/mibapi/financing.md | 109 ++++++ 15 files changed, 896 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/api/mib/MibFinancingClient.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/FinancingAdapter.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/FinancingFragment.kt create mode 100644 app/src/main/java/sh/sar/basedbank/util/FinancingCache.kt create mode 100644 app/src/main/res/layout/fragment_financing.xml create mode 100644 app/src/main/res/layout/item_finance_deal.xml create mode 100644 docs/mibapi/financing.md diff --git a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt index ef5e177..f343e77 100644 --- a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt +++ b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt @@ -4,12 +4,16 @@ 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.MibProfile +import sh.sar.basedbank.api.mib.MibSession class BasedBankApp : Application() { // Held in memory after successful login; cleared on logout var accounts: List = emptyList() var fullName: String = "" + var mibSession: MibSession? = null + var mibProfiles: List = emptyList() override fun onCreate() { super.onCreate() diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibFinancingClient.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibFinancingClient.kt new file mode 100644 index 0000000..e6e2103 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibFinancingClient.kt @@ -0,0 +1,82 @@ +package sh.sar.basedbank.api.mib + +import android.util.Log +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit +import kotlin.math.ceil + +class MibFinancingClient { + + private val TAG = "MibFinancingClient" + private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv" + + private val client = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + fun fetchFinancing(session: MibSession): List { + val cookieHeader = "mbmodel=IOS-1.0; " + + "xxid=${session.xxid}; " + + "IBSID=${session.xxid}; " + + "mbnonce=${session.nonceGenerator}; " + + "time-tracker=597" + + val request = Request.Builder() + .url("$BASE_WV_URL/financing?dashurl=1") + .header("Cookie", cookieHeader) + .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", "mv.com.mib.faisamobilex") + .get() + .build() + + val html = client.newCall(request).execute().use { response -> + Log.d(TAG, "fetchFinancing: HTTP ${response.code}") + if (!response.isSuccessful) return emptyList() + response.body?.string() ?: return emptyList() + } + + return parseFinancingHtml(html) + } + + private fun parseFinancingHtml(html: String): List { + val cardPattern = Regex("""finance-card-holder[^>]+>""") + val attrPattern = Regex("""data-(\w+)\s*=\s*"([^"]*)"""") + + return cardPattern.findAll(html).mapNotNull { cardMatch -> + val attrs = attrPattern.findAll(cardMatch.value) + .associate { it.groupValues[1] to it.groupValues[2] } + + val dealNo = attrs["dealNo"] ?: return@mapNotNull null + + MibFinanceDeal( + dealNo = dealNo, + productDesc = attrs["productDesc"] ?: "", + dealStatus = attrs["dealStatus"] ?: "", + statusDesc = attrs["statusDesc"] ?: "", + dealAmount = attrs["dealAmount"]?.toDoubleOrNull() ?: 0.0, + paidAmount = attrs["paidAmount"]?.toDoubleOrNull() ?: 0.0, + outstandingAmount = attrs["outstandingAmount"]?.toDoubleOrNull() ?: 0.0, + dealDate = attrs["dealDate"] ?: "", + overdueAmount = attrs["overdueAmount"]?.toDoubleOrNull() ?: 0.0, + installmentAmount = attrs["installmentAmount"]?.toDoubleOrNull() ?: 0.0, + noOfInstallments = attrs["noOfInstallments"]?.toIntOrNull() ?: 0, + lastPaidDate = attrs["lastPaidDate"] ?: "", + lastPayAmount = attrs["lastPayAmount"]?.toDoubleOrNull() ?: 0.0, + currency = attrs["curCodeDesc"] ?: "MVR" + ) + }.toList().also { Log.d(TAG, "parsed ${it.size} financing deals") } + } + + companion object { + /** Estimate remaining months until financing is fully paid. */ + fun remainingMonths(deal: MibFinanceDeal): Int { + if (deal.installmentAmount <= 0.0) return 0 + return ceil(deal.outstandingAmount / deal.installmentAmount).toInt() + } + } +} 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 19b0625..21e574f 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 @@ -15,6 +15,14 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { private val TAG = "MibLoginFlow" private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/" + /** The active session after a successful login, usable for subsequent WebView requests. */ + var lastSession: MibSession? = null + private set + + /** Profiles returned by the last successful login. */ + var lastProfiles: List = emptyList() + private set + private val client = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) @@ -140,6 +148,8 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { val profiles = parseProfiles(loginResp) Log.d(TAG, "[login] parsed ${profiles.size} profiles") + lastSession = session2 + lastProfiles = profiles Log.d(TAG, "[login] step 7: fetch all profiles") return fetchAllProfiles(session2, profiles) } @@ -213,6 +223,19 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) { put("xxid", session.xxid) } + /** + * Activates [profile] server-side via P47, setting the session role context so that + * subsequent WebView requests run under that profile. + */ + fun switchProfile(session: MibSession, profile: MibProfile) { + Log.d(TAG, "switchProfile: profileId=${profile.profileId} cifType=${profile.cifType}") + val payload = baseData(session, "P47").apply { + put("profileType", profile.profileType) + put("profileId", profile.profileId) + } + doRequest(session, payload, "n") + } + private fun fetchAllProfiles(session: MibSession, profiles: List): List { val allAccounts = mutableListOf() for (profile in profiles) { 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 137474f..26f049e 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 @@ -31,3 +31,20 @@ data class MibAccount( val mvrBalance: String, val statusDesc: String ) + +data class MibFinanceDeal( + val dealNo: String, + val productDesc: String, + val dealStatus: String, + val statusDesc: String, + val dealAmount: Double, + val paidAmount: Double, + val outstandingAmount: Double, + val dealDate: String, + val overdueAmount: Double, + val installmentAmount: Double, + val noOfInstallments: Int, + val lastPaidDate: String, + val lastPayAmount: Double, + val currency: String +) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/FinancingAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/FinancingAdapter.kt new file mode 100644 index 0000000..907dbc7 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/FinancingAdapter.kt @@ -0,0 +1,111 @@ +package sh.sar.basedbank.ui.home + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import sh.sar.basedbank.R +import sh.sar.basedbank.api.mib.MibFinanceDeal +import sh.sar.basedbank.api.mib.MibFinancingClient +import sh.sar.basedbank.databinding.ItemFinanceDealBinding +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +class FinancingAdapter(private var deals: List) : + RecyclerView.Adapter() { + + private val expandedPositions = mutableSetOf() + private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply { + minimumFractionDigits = 2 + maximumFractionDigits = 2 + } + private val inputDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US) + + fun updateDeals(newDeals: List) { + deals = newDeals + expandedPositions.clear() + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemFinanceDealBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(deals[position], position in expandedPositions) + holder.binding.root.setOnClickListener { + val pos = holder.bindingAdapterPosition + if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos) + notifyItemChanged(pos) + } + } + + override fun getItemCount() = deals.size + + inner class ViewHolder(val binding: ItemFinanceDealBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(deal: MibFinanceDeal, expanded: Boolean) { + val ctx = binding.root.context + val currency = deal.currency + + binding.tvProductName.text = deal.productDesc + binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo) + binding.tvStatus.text = deal.statusDesc + binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}" + binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}" + binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}" + + // Progress bar + val progress = if (deal.dealAmount > 0) + ((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100) + else 0 + binding.progressBar.progress = progress + + // Completion estimate + binding.tvCompletion.text = completionText(deal, ctx) + + // Expanded details + val detailsVisible = if (expanded) View.VISIBLE else View.GONE + binding.dividerDetails.visibility = detailsVisible + binding.detailsGroup.visibility = detailsVisible + + if (expanded) { + binding.tvDealDate.text = formatDate(deal.dealDate) + binding.tvInstallment.text = "$currency ${amountFmt.format(deal.installmentAmount)}" + binding.tvNumInstallments.text = deal.noOfInstallments.toString() + binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate) + binding.tvLastPayAmount.text = "$currency ${amountFmt.format(deal.lastPayAmount)}" + + if (deal.overdueAmount > 0) { + binding.rowOverdue.visibility = View.VISIBLE + binding.tvOverdue.text = "$currency ${amountFmt.format(deal.overdueAmount)}" + } else { + binding.rowOverdue.visibility = View.GONE + } + } + } + + private fun completionText(deal: MibFinanceDeal, ctx: android.content.Context): String { + if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done) + val remaining = MibFinancingClient.remainingMonths(deal) + if (remaining <= 0) return ctx.getString(R.string.financing_completion_done) + val cal = Calendar.getInstance() + cal.add(Calendar.MONTH, remaining) + val month = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(cal.time) + return ctx.getString(R.string.financing_completion_fmt, month) + } + + private fun formatDate(raw: String): String { + return try { + outputDateFmt.format(inputDateFmt.parse(raw)!!) + } catch (_: Exception) { + raw.take(10) + } + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/FinancingFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/FinancingFragment.kt new file mode 100644 index 0000000..8d0eb00 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/FinancingFragment.kt @@ -0,0 +1,47 @@ +package sh.sar.basedbank.ui.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import sh.sar.basedbank.R +import sh.sar.basedbank.databinding.FragmentFinancingBinding + +class FinancingFragment : Fragment() { + + private var _binding: FragmentFinancingBinding? = null + private val binding get() = _binding!! + private val viewModel: HomeViewModel by activityViewModels() + private lateinit var adapter: FinancingAdapter + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentFinancingBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + adapter = FinancingAdapter(emptyList()) + binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.recyclerView.adapter = adapter + + viewModel.financing.observe(viewLifecycleOwner) { deals -> + adapter.updateDeals(deals) + binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE + binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE + binding.loadingView.visibility = View.GONE + } + } + + override fun onResume() { + super.onResume() + requireActivity().title = getString(R.string.nav_finances) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} 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 886f937..e93d68e 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 @@ -17,8 +17,13 @@ import sh.sar.basedbank.R import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.databinding.ActivityHomeBinding import sh.sar.basedbank.ui.login.LoginActivity +import sh.sar.basedbank.api.mib.MibFinancingClient +import sh.sar.basedbank.api.mib.MibProfile +import sh.sar.basedbank.api.mib.MibSession +import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.CredentialStore +import sh.sar.basedbank.util.FinancingCache class HomeActivity : AppCompatActivity() { @@ -44,6 +49,7 @@ class HomeActivity : AppCompatActivity() { R.id.nav_dashboard -> show(DashboardFragment()) R.id.nav_add_account -> startActivity(Intent(this, LoginActivity::class.java)) R.id.nav_accounts -> show(AccountsFragment()) + R.id.nav_finances -> show(FinancingFragment()) R.id.nav_settings -> show(SettingsFragment()) else -> Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show() } @@ -53,13 +59,18 @@ class HomeActivity : AppCompatActivity() { // Load data val app = application as BasedBankApp if (app.accounts.isNotEmpty()) { - // Came from fresh manual login + // Came from fresh manual login — accounts ready, financing fetched in background viewModel.accounts.value = app.accounts AccountCache.save(this, app.accounts) + val cached = FinancingCache.load(this) + if (cached.isNotEmpty()) viewModel.financing.value = cached + refreshFinancing(app.mibSession, app.mibProfiles) } else { - // Came from lock screen — show cache immediately, refresh in background + // Came from lock screen — show caches immediately, refresh everything in background val cached = AccountCache.load(this) if (cached.isNotEmpty()) viewModel.accounts.value = cached + val cachedFinancing = FinancingCache.load(this) + if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing val creds = CredentialStore(this).loadMibCredentials() if (creds != null) autoRefresh(creds) } @@ -86,7 +97,10 @@ class HomeActivity : AppCompatActivity() { val accounts = withContext(Dispatchers.IO) { flow.login(creds.username, creds.passwordHash, creds.otpSeed) } - (application as BasedBankApp).accounts = accounts + val app = application as BasedBankApp + app.accounts = accounts + app.mibSession = flow.lastSession + app.mibProfiles = flow.lastProfiles AccountCache.save(this@HomeActivity, accounts) viewModel.accounts.postValue(accounts) } catch (_: Exception) { @@ -94,6 +108,36 @@ class HomeActivity : AppCompatActivity() { } finally { binding.refreshIndicator.visibility = View.GONE } + val app = application as BasedBankApp + refreshFinancing(app.mibSession, app.mibProfiles) + } + } + + private fun refreshFinancing(session: MibSession?, profiles: List) { + if (session == null || profiles.isEmpty()) return + val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE) + val flow = MibLoginFlow(prefs) + val client = MibFinancingClient() + lifecycleScope.launch { + try { + val deals = withContext(Dispatchers.IO) { + val allDeals = mutableListOf() + val seen = mutableSetOf() + for (profile in profiles) { + try { + flow.switchProfile(session, profile) + for (deal in client.fetchFinancing(session)) { + if (seen.add(deal.dealNo)) allDeals.add(deal) + } + } catch (_: Exception) { /* profile has no financing access */ } + } + allDeals + } + if (deals.isNotEmpty()) { + FinancingCache.save(this@HomeActivity, deals) + viewModel.financing.postValue(deals) + } + } catch (_: Exception) { /* keep cached data */ } } } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt index 8867359..80b3cb8 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeViewModel.kt @@ -3,7 +3,9 @@ package sh.sar.basedbank.ui.home import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import sh.sar.basedbank.api.mib.MibAccount +import sh.sar.basedbank.api.mib.MibFinanceDeal class HomeViewModel : ViewModel() { val accounts = MutableLiveData>(emptyList()) + val financing = MutableLiveData>(emptyList()) } diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt index 165a323..7f665d9 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt @@ -113,7 +113,10 @@ class CredentialsFragment : Fragment() { Log.d(TAG, "Login succeeded, got ${accounts.size} accounts") CredentialStore(requireContext()).saveMibCredentials(username, passwordHash, otpSeed) AccountCache.save(requireContext(), accounts) - (requireActivity().application as BasedBankApp).accounts = accounts + val app = requireActivity().application as BasedBankApp + app.accounts = accounts + app.mibSession = flow.lastSession + app.mibProfiles = flow.lastProfiles val intent = Intent(requireContext(), HomeActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK startActivity(intent) diff --git a/app/src/main/java/sh/sar/basedbank/util/FinancingCache.kt b/app/src/main/java/sh/sar/basedbank/util/FinancingCache.kt new file mode 100644 index 0000000..d9920fe --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/FinancingCache.kt @@ -0,0 +1,65 @@ +package sh.sar.basedbank.util + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import sh.sar.basedbank.api.mib.MibFinanceDeal + +object FinancingCache { + + private const val PREFS = "financing_cache" + private const val KEY_MIB = "mib_financing" + + fun save(context: Context, deals: List) { + val arr = JSONArray() + for (d in deals) { + arr.put(JSONObject().apply { + put("dealNo", d.dealNo) + put("productDesc", d.productDesc) + put("dealStatus", d.dealStatus) + put("statusDesc", d.statusDesc) + put("dealAmount", d.dealAmount) + put("paidAmount", d.paidAmount) + put("outstandingAmount", d.outstandingAmount) + put("dealDate", d.dealDate) + put("overdueAmount", d.overdueAmount) + put("installmentAmount", d.installmentAmount) + put("noOfInstallments", d.noOfInstallments) + put("lastPaidDate", d.lastPaidDate) + put("lastPayAmount", d.lastPayAmount) + put("currency", d.currency) + }) + } + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_MIB, arr.toString()).apply() + } + + fun load(context: Context): List { + val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_MIB, null) ?: return emptyList() + return try { + val arr = JSONArray(json) + (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + MibFinanceDeal( + dealNo = o.optString("dealNo"), + productDesc = o.optString("productDesc"), + dealStatus = o.optString("dealStatus"), + statusDesc = o.optString("statusDesc"), + dealAmount = o.optDouble("dealAmount", 0.0), + paidAmount = o.optDouble("paidAmount", 0.0), + outstandingAmount = o.optDouble("outstandingAmount", 0.0), + dealDate = o.optString("dealDate"), + overdueAmount = o.optDouble("overdueAmount", 0.0), + installmentAmount = o.optDouble("installmentAmount", 0.0), + noOfInstallments = o.optInt("noOfInstallments", 0), + lastPaidDate = o.optString("lastPaidDate"), + lastPayAmount = o.optDouble("lastPayAmount", 0.0), + currency = o.optString("currency", "MVR") + ) + } + } catch (e: Exception) { + emptyList() + } + } +} diff --git a/app/src/main/res/layout/fragment_financing.xml b/app/src/main/res/layout/fragment_financing.xml new file mode 100644 index 0000000..4a68b82 --- /dev/null +++ b/app/src/main/res/layout/fragment_financing.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_finance_deal.xml b/app/src/main/res/layout/item_finance_deal.xml new file mode 100644 index 0000000..4e33e4d --- /dev/null +++ b/app/src/main/res/layout/item_finance_deal.xml @@ -0,0 +1,328 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index bccb7d3..3ff2c64 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -3,4 +3,5 @@ #3F65AD #9AD141 + #E85D04 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6289079..2887185 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -92,4 +92,19 @@ Accounts Available Balance + + + No financing deals found + Total + Paid + Unpaid + Deal Date + Monthly Installment + Total Installments + Last Payment Date + Last Payment + Overdue + Fully paid + Deal #%s + Completes %s diff --git a/docs/mibapi/financing.md b/docs/mibapi/financing.md new file mode 100644 index 0000000..ce35ae5 --- /dev/null +++ b/docs/mibapi/financing.md @@ -0,0 +1,109 @@ +# MIB Financing API + +## Overview + +Financing data is fetched from the MIB **WebView** host (`faisamobilex-wv.mib.com.mv`), which is separate from the API host (`faisanet.mib.com.mv`). The response is an HTML page; financing deal data is embedded in `data-*` attributes on card elements. + +--- + +## Endpoint + +``` +GET https://faisamobilex-wv.mib.com.mv/financing?dashurl=1 +``` + +### Authentication + +Session cookies from the login flow must be sent with the request: + +| Cookie | Value | +|----------------|---------------------------------------------| +| `mbmodel` | `IOS-1.0` (literal string) | +| `xxid` | Session ID from login (`MibSession.xxid`) | +| `IBSID` | Same as `xxid` | +| `mbnonce` | `nonceGenerator` string from login response | +| `time-tracker` | `597` (literal string) | + +### Request Headers + +| Header | Value | +|--------------------|------------------------------------| +| `User-Agent` | Standard Android WebView UA string | +| `X-Requested-With` | `mv.com.mib.faisamobilex` | + +--- + +## Response + +**Content-Type:** `text/html; charset=UTF-8` + +The response is a full HTML page. Each financing deal is represented as a `
` with the class `finance-card-holder` and all deal fields embedded as `data-*` attributes: + +```html +
+``` + +### Data Fields + +| Field | Type | Description | +|-----------------------|---------|------------------------------------------------------| +| `productDesc` | String | Product name (e.g. "Ujalaa CG Finance") | +| `dealStatus` | String | Status code: `P` = Active/Pending | +| `statusDesc` | String | Human-readable status (e.g. "Approved") | +| `dealAmount` | Decimal | Total financing amount | +| `dealNo` | Integer | Unique deal/contract number | +| `paidAmount` | Decimal | Amount paid to date | +| `outstandingAmount` | Decimal | Remaining unpaid balance | +| `dealDate` | String | Contract start date (`yyyy-MM-dd HH:mm:ss`) | +| `overdueAmount` | Decimal | Amount currently overdue (0 if none) | +| `installmentAmount` | Decimal | Monthly installment amount | +| `noOfInstallments` | Integer | Total number of installments | +| `lastPaidDate` | String | Date of most recent payment (`yyyy-MM-dd HH:mm:ss`) | +| `lastPayAmount` | Decimal | Amount of most recent payment | +| `financeCurrency` | Integer | Currency code (462 = MVR) | +| `curCodeDesc` | String | Currency abbreviation (e.g. "MVR") | + +### Parsing Strategy + +Use a regex to find all elements with class `finance-card-holder`, then extract all `data-*` attribute key/value pairs from each match: + +```kotlin +val cardPattern = Regex("""finance-card-holder[^>]+>""") +val attrPattern = Regex("""data-(\w+)\s*=\s*"([^"]*)"""") +``` + +--- + +## Completion Date Estimation + +Remaining installments can be estimated from outstanding and installment amounts: + +``` +remainingInstallments = ceil(outstandingAmount / installmentAmount) +completionDate = today + remainingInstallments months +``` + +--- + +## Notes + +- The WebView endpoint uses a different subdomain (`faisamobilex-wv`) from the encrypted API (`faisanet`). +- No encryption is used; the session is maintained purely via cookies. +- The HTML is served gzip/brotli compressed; OkHttp handles decompression automatically. +- The `time-tracker` cookie value appears to be static at `597` — its purpose is unclear, but omitting it may affect behavior. +- Known product names include consumer goods finance and cash financing variants.