add transactions and account history

This commit is contained in:
2026-05-15 09:21:09 +05:00
parent deedd16ba2
commit 6779b6f3b7
21 changed files with 1629 additions and 5 deletions
@@ -13,10 +13,13 @@ import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.util.Totp
import java.security.MessageDigest
import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.util.Base64
import java.util.Locale
import java.util.concurrent.TimeUnit
class AuthExpiredException : Exception("Session expired")
@@ -419,6 +422,156 @@ class BmlLoginFlow {
}
}
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
private fun parsePurchaseNarrative1(narrative1: String): String? {
return try {
val parts = narrative1.split(" ")
if (parts.size < 2) null
else {
val timePart = parts[1].take(4)
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
}
} catch (_: Exception) { null }
}
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
private fun parseTransferNarrative1(narrative1: String): String? {
return try {
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
} catch (_: Exception) { null }
}
/**
* Fetches paginated transaction history for a BML CASA account.
* @return Pair of (transactions, totalPages)
*/
fun fetchAccountHistory(
session: BmlSession,
accountId: String,
accountDisplayName: String,
accountNumber: String,
page: Int
): Pair<List<Transaction>, Int> {
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/account/$accountId/history/$page")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
val totalPages = payload.optInt("totalPages", 0)
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
val transactions = (0 until history.length()).map { i ->
val item = history.getJSONObject(i)
val desc = item.optString("description").trim()
val narrative1 = item.optString("narrative1")
val date = when (desc) {
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
else -> item.optString("bookingDate")
}
Transaction(
id = item.optString("id"),
date = date,
description = desc,
amount = item.optDouble("amount", 0.0),
currency = item.optString("currency"),
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
reference = item.optString("reference").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML"
)
}
Pair(transactions, totalPages)
} catch (_: Exception) { Pair(emptyList(), 0) }
}
/**
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM").
* Returns combined outstanding authorizations + settled statement entries.
*/
fun fetchCardHistory(
session: BmlSession,
cardId: String,
accountDisplayName: String,
accountNumber: String,
month: String
): List<Transaction> {
val body = """{"card":"$cardId","month":"$month"}"""
.toRequestBody("application/json".toMediaType())
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/card/statement").post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return emptyList()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload = root.optJSONObject("payload") ?: return emptyList()
val result = mutableListOf<Transaction>()
// Outstanding authorizations
val authDetails = payload.optJSONObject("outstanding")
?.optJSONArray("CardOutStdAuthDetails")
if (authDetails != null) {
for (i in 0 until authDetails.length()) {
val item = authDetails.getJSONObject(i)
result.add(Transaction(
id = "auth_${item.optString("TranApprCode")}_$i",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
// Settled statement entries
val statement = payload.optJSONArray("cardstatement")
if (statement != null) {
for (i in 0 until statement.length()) {
val item = statement.getJSONObject(i)
result.add(Transaction(
id = "stmt_${item.optString("TranRef", i.toString())}",
date = item.optString("TransDate", item.optString("TranDate", "")),
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
amount = -item.optDouble("TranAmount", 0.0),
currency = item.optString("TranCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
result
} catch (_: Exception) { emptyList() }
}
private fun apiRequest(session: BmlSession, url: String) =
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
@@ -0,0 +1,87 @@
package sh.sar.basedbank.api.mib
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibHistoryClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
/**
* Fetches transaction history for a single MIB account.
* @param start 1-based index; use 1 for first page, 21 for second, etc. (pageSize=20)
* @return Pair of (transactions, totalCount)
*/
fun fetchHistory(
session: MibSession,
accountNo: String,
accountDisplayName: String,
start: Int = 1,
pageSize: Int = 20
): Pair<List<Transaction>, Int> {
val body = FormBody.Builder()
.add("accountNo", accountNo)
.add("trxNo", "")
.add("trxType", "0")
.add("sortTrx", "date")
.add("sortDir", "desc")
.add("fromDate", "")
.add("toDate", "")
.add("start", start.toString())
.add("end", (start + pageSize - 1).toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxAccounts/trxHistory")
.post(body)
.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//accountDetails?trxh=1&dashurl=1&accountNo=$accountNo")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
val total = json.optString("total_count", "0").toIntOrNull() ?: 0
val data = json.optJSONArray("data") ?: return Pair(emptyList(), total)
val transactions = (0 until data.length()).map { i ->
val item = data.getJSONObject(i)
Transaction(
id = item.optString("trxNumber"),
date = item.optString("trxDate"),
description = item.optString("descr1").trim(),
amount = item.optString("baseAmount", "0").toDoubleOrNull() ?: 0.0,
currency = item.optString("curCodeDesc"),
counterpartyName = item.optString("benefName").takeIf {
it.isNotBlank() && it != "null"
},
reference = item.optString("trxNumber2").takeIf { it.isNotBlank() },
accountNumber = accountNo,
accountDisplayName = accountDisplayName,
source = "MIB"
)
}
Pair(transactions, total)
}
}
}
@@ -72,6 +72,19 @@ data class MibIpsAccountInfo(
val bankId: String
)
data class Transaction(
val id: String,
val date: String, // "YYYY-MM-DD HH:mm:ss" for MIB, ISO8601 for BML
val description: String,
val amount: Double, // negative = debit, positive = credit
val currency: String,
val counterpartyName: String?,
val reference: String?,
val accountNumber: String,
val accountDisplayName: String,
val source: String // "MIB", "BML", "BML_CARD"
)
data class MibFinanceDeal(
val dealNo: String,
val productDesc: String,
@@ -0,0 +1,47 @@
package sh.sar.basedbank.api.mib
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
object TransactionCache {
fun save(context: Context, key: String, transactions: List<Transaction>) {
try {
val arr = JSONArray()
for (t in transactions) arr.put(JSONObject().apply {
put("id", t.id)
put("date", t.date)
put("description", t.description)
put("amount", t.amount)
put("currency", t.currency)
put("counterpartyName", t.counterpartyName ?: "")
put("reference", t.reference ?: "")
put("accountNumber", t.accountNumber)
put("accountDisplayName", t.accountDisplayName)
put("source", t.source)
})
File(context.cacheDir, "tx_$key.json").writeText(arr.toString())
} catch (_: Exception) {}
}
fun load(context: Context, key: String): List<Transaction> = try {
val arr = JSONArray(File(context.cacheDir, "tx_$key.json").readText())
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
Transaction(
id = o.optString("id"),
date = o.optString("date"),
description = o.optString("description"),
amount = o.optDouble("amount", 0.0),
currency = o.optString("currency"),
counterpartyName = o.optString("counterpartyName").takeIf { it.isNotBlank() },
reference = o.optString("reference").takeIf { it.isNotBlank() },
accountNumber = o.optString("accountNumber"),
accountDisplayName = o.optString("accountDisplayName"),
source = o.optString("source")
)
}
} catch (_: Exception) { emptyList() }
}