18 Commits
v1.0.7 ... main

Author SHA1 Message Date
ea227bf3b9 impprove ci performance
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-24 00:29:40 +05:00
6b3131069e update docs
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-24 00:27:59 +05:00
8037ce3f02 Merge pull request 'add product group mapping for cards' (#4) from fix/bmlapi-card-parser-add-missing-product-groups into main
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
Reviewed-on: #4
2026-05-24 00:04:47 +05:00
cecf0bedfc add product group mapping for cards 2026-05-23 23:56:15 +05:00
256f216da4 update docs
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 23:46:00 +05:00
0a27de4a34 update bml api docs
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-23 23:33:31 +05:00
a3f8852163 some android studio bs
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-23 23:13:07 +05:00
8e345746ed pending finaces on dashboard is now a button that takes you to finaces page 2026-05-23 23:12:50 +05:00
473e051282 release v1.0.8
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
Build and Release APK / build (push) Successful in 4m6s
2026-05-23 22:52:27 +05:00
f9c182fe9a fix weird error on failed pin
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 22:47:47 +05:00
339dae8a37 hide money value in transfer drop down with privacy mode on
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 22:42:48 +05:00
a6a1f28144 disable transfer button when there is issue with source bank or connectivity
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 22:31:27 +05:00
523d1248bd add connetivity banners
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 22:23:54 +05:00
ee9f98b720 fix caching reading issue when refreshed without internet
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-23 21:49:27 +05:00
219ca9bf00 add more card support, include credit cards in accounts
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 21:21:01 +05:00
e9f0cec698 compress mib cards and add prep support for bml cards
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 21:03:25 +05:00
268f3dada0 fix useragents to give out actual device model os version and etc
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-22 20:50:50 +05:00
e0a554c769 fix useragents to give out actual device model os version and etc
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-22 06:53:07 +05:00
107 changed files with 4377 additions and 1834 deletions

View File

@@ -1,11 +1,9 @@
services: services:
release: release:
# image: git.shihaam.dev/dockerfiles/android-builder
image: git.shihaam.dev/dockerfiles/runners/gradle image: git.shihaam.dev/dockerfiles/runners/gradle
hostname: isodroid
network_mode: host network_mode: host
env_file: .env env_file: .env
volumes: volumes:
- ./release:/release - ./release:/release
- ../../:/source - ../../:/source
# - /root/.cache/cache-runners/gradle:/root/.gradle - /root/.cache/cache-runners/gradle:/root/.gradle

8
.idea/markdown.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

View File

@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 6 versionCode = 7
versionName = "1.0.7" versionName = "1.0.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -203,15 +203,15 @@ class LockActivity : AppCompatActivity() {
if (remaining <= 0) return false if (remaining <= 0) return false
val secs = ((remaining + 999L) / 1000L).toInt() val secs = ((remaining + 999L) / 1000L).toInt()
val msg = getString(R.string.unlock_locked_out, secs) val msg = getString(R.string.unlock_locked_out, secs)
binding.tvLockPinDots.text = msg binding.tvPinHint.text = msg
binding.root.postDelayed({ updateDots() }, remaining) binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, remaining)
return true return true
} }
private fun showFailure() { private fun showFailure() {
val msg = failureMessage() val msg = failureMessage()
binding.tvLockPinDots.text = msg binding.tvPinHint.text = msg
binding.root.postDelayed({ updateDots() }, 1200) binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, 1200)
} }
private fun failureMessage(): String { private fun failureMessage(): String {

View File

@@ -2,6 +2,7 @@ package sh.sar.basedbank.api.bml
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankServerException
data class BmlUserInfo( data class BmlUserInfo(
val fullName: String, val fullName: String,
@@ -27,6 +28,7 @@ class BmlAccountClient {
val json = resp.body?.string() val json = resp.body?.string()
resp.close() resp.close()
if (code == 401 || code == 419) throw AuthExpiredException() if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId) return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
} }
@@ -36,6 +38,7 @@ class BmlAccountClient {
val code = resp.code val code = resp.code
resp.close() resp.close()
if (code == 401 || code == 419) throw AuthExpiredException() if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
} }
fun fetchUserInfo(session: BmlSession): BmlUserInfo? { fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
@@ -166,16 +169,22 @@ class BmlAccountClient {
internalId = internalId internalId = internalId
)) ))
} else if (accountType == "Card") { } else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue
val isPrepaid = item.optBoolean("prepaid_card", false) val isPrepaid = item.optBoolean("prepaid_card", false)
val productCode = item.optString("product_code", "")
val cardBalance = item.optJSONObject("cardBalance") val cardBalance = item.optJSONObject("cardBalance")
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0 val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0 val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
val isVisible = item.optBoolean("account_visible", false)
val cardProfileType = when {
isPrepaid -> "BML_PREPAID"
isVisible -> "BML_CREDIT" // non-prepaid, visible = credit card
else -> "BML_DEBIT" // non-prepaid, not visible = debit card
}
prepaidCards.add(BankAccount( prepaidCards.add(BankAccount(
bank = "BML", bank = "BML",
profileName = profileName, profileName = profileName,
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT", profileType = cardProfileType,
productCode = productCode,
accountNumber = accountNumber, accountNumber = accountNumber,
accountBriefName = item.optString("alias").ifBlank { product }, accountBriefName = item.optString("alias").ifBlank { product },
currencyName = currency, currencyName = currency,

View File

@@ -1,11 +1,12 @@
package sh.sar.basedbank.api.bml package sh.sar.basedbank.api.bml
import android.os.Build
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
internal const val BML_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)" internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
internal const val BML_APP_VERSION = "2.1.44.348" internal const val BML_APP_VERSION = "2.1.44.348"
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder() internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()

View File

@@ -25,9 +25,9 @@ class BmlLoginFlow {
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7" private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback" private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private val APP_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)" private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
private val APP_VERSION = "2.1.44.348" private val APP_VERSION = "2.1.44.348"
private val WEB_USER_AGENT = "Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0" private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>() private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar { private val cookieJar = object : CookieJar {

View File

@@ -4,6 +4,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class FahipayAccountClient { class FahipayAccountClient {
@@ -27,8 +28,10 @@ class FahipayAccountClient {
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en") Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
.auth(session).build() .auth(session).build()
).execute() ).execute()
val code = resp.code
val json = resp.body?.string() ?: throw Exception("Empty profile response") val json = resp.body?.string() ?: throw Exception("Empty profile response")
resp.close() resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
val obj = JSONObject(json) val obj = JSONObject(json)
val props = obj.optJSONObject("props") ?: JSONObject() val props = obj.optJSONObject("props") ?: JSONObject()
return FahipayUserProfile( return FahipayUserProfile(
@@ -47,8 +50,10 @@ class FahipayAccountClient {
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en") Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
.auth(session).build() .auth(session).build()
).execute() ).execute()
val code = resp.code
val json = resp.body?.string() ?: return 0.0 val json = resp.body?.string() ?: return 0.0
resp.close() resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
return try { return try {
val obj = JSONObject(json) val obj = JSONObject(json)
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0) if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)

View File

@@ -16,7 +16,7 @@ import java.util.concurrent.TimeUnit
class FahipayLoginFlow { class FahipayLoginFlow {
private val BASE_URL = "https://fahipay.mv" private val BASE_URL = "https://fahipay.mv"
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>() private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar { private val cookieJar = object : CookieJar {

View File

@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -31,7 +32,7 @@ class MibCardsClient {
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos") .url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
.post(body) .post(body)
.header("Cookie", cookieHeader(session)) .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("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; 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("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")
.header("Origin", BASE_WV_URL) .header("Origin", BASE_WV_URL)

View File

@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -24,7 +25,7 @@ class MibContactsClient {
.header("Cookie", cookieHeader(session)) .header("Cookie", cookieHeader(session))
.header( .header(
"User-Agent", "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" "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; 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("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")

View File

@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -27,7 +28,7 @@ class MibFinancingClient {
.header("Cookie", cookieHeader) .header("Cookie", cookieHeader)
.header( .header(
"User-Agent", "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" "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; 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") .header("X-Requested-With", "mv.com.mib.faisamobilex")
.get() .get()

View File

@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -50,7 +51,7 @@ class MibHistoryClient {
.header("Cookie", cookieHeader(session)) .header("Cookie", cookieHeader(session))
.header( .header(
"User-Agent", "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" "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; 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("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")

View File

@@ -161,7 +161,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
bank = "MIB", bank = "MIB",
profileName = profile.name, profileName = profile.name,
profileType = profile.profileType, profileType = profile.profileType,
cifType = profile.cifType, productCode = profile.cifType,
accountNumber = a.optString("accountNumber"), accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"), accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"), currencyName = a.optString("currencyName"),
@@ -318,7 +318,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
bank = "MIB", bank = "MIB",
profileName = profile.name, profileName = profile.name,
profileType = profile.profileType, profileType = profile.profileType,
cifType = profile.cifType, productCode = profile.cifType,
accountNumber = a.optString("accountNumber"), accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"), accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"), currencyName = a.optString("currencyName"),
@@ -373,6 +373,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
.build() .build()
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
if (response.code == 419) throw SessionExpiredException() if (response.code == 419) throw SessionExpiredException()
if (response.code in 500..599) throw sh.sar.basedbank.api.models.BankServerException("MIB")
return response.body?.string() ?: throw IllegalStateException("Empty response body") return response.body?.string() ?: throw IllegalStateException("Empty response body")
} }

View File

@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -26,7 +27,7 @@ class MibTransferClient {
.header("Cookie", cookieHeader(session)) .header("Cookie", cookieHeader(session))
.header( .header(
"User-Agent", "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" "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; 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("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")

View File

@@ -1,5 +1,8 @@
package sh.sar.basedbank.api.models package sh.sar.basedbank.api.models
/** Thrown by a bank API client when the server returns an HTTP 5xx response. */
class BankServerException(val bankName: String) : Exception("Server error from $bankName")
/** /**
* Unified account model used across all banks (MIB, BML, Fahipay, ...). * Unified account model used across all banks (MIB, BML, Fahipay, ...).
* The [bank] field identifies which bank owns this account. * The [bank] field identifies which bank owns this account.
@@ -8,7 +11,7 @@ data class BankAccount(
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
val profileName: String, val profileName: String,
val profileType: String, val profileType: String,
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks val productCode: String = "", // bank-specific product/subtype code: MIB: CIF type label ("Individual", "Sole Propr"); BML: card product code ("C8201", "C1007")
val accountNumber: String, val accountNumber: String,
val accountBriefName: String, val accountBriefName: String,
val currencyName: String, val currencyName: String,

View File

@@ -72,7 +72,7 @@ class AccountsAdapter(
else -> account.bank else -> account.bank
} }
val profileLabel = when (account.bank) { val profileLabel = when (account.bank) {
"MIB" -> account.cifType.ifBlank { account.profileName } "MIB" -> account.productCode.ifBlank { account.profileName }
else -> account.profileName else -> account.profileName
} }
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName

View File

@@ -15,8 +15,8 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentCardSettingsBinding import sh.sar.basedbank.databinding.FragmentCardSettingsBinding
import sh.sar.basedbank.util.bmlapi.BmlCardParser
class CardSettingsFragment : Fragment() { class CardSettingsFragment : Fragment() {
@@ -44,17 +44,23 @@ class CardSettingsFragment : Fragment() {
} }
binding.swipeRefresh.setOnRefreshListener { binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefreshCards() (activity as? HomeActivity)?.triggerRefresh()
} }
viewModel.mibCards.observe(viewLifecycleOwner) { cards -> val updateCardList = {
if (cards == null) return@observe val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
adapter.update(cards) val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
.map { CardItem.Bml(it) }
val all = mibItems + bmlItems
adapter.update(all)
binding.loadingView.visibility = View.GONE binding.loadingView.visibility = View.GONE
binding.swipeRefresh.isRefreshing = false binding.swipeRefresh.isRefreshing = false
binding.emptyView.visibility = if (cards.isEmpty()) View.VISIBLE else View.GONE binding.emptyView.visibility = if (all.isEmpty()) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (cards.isEmpty()) View.GONE else View.VISIBLE binding.recyclerView.visibility = if (all.isEmpty()) View.GONE else View.VISIBLE
} }
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
if (viewModel.mibCards.value == null) { if (viewModel.mibCards.value == null) {
binding.loadingView.visibility = View.VISIBLE binding.loadingView.visibility = View.VISIBLE
@@ -73,11 +79,11 @@ class CardSettingsFragment : Fragment() {
} }
private inner class CardSettingsAdapter( private inner class CardSettingsAdapter(
private var cards: List<MibCard>, private var cards: List<CardItem>,
private val context: Context private val context: Context
) : RecyclerView.Adapter<CardSettingsAdapter.VH>() { ) : RecyclerView.Adapter<CardSettingsAdapter.VH>() {
fun update(newCards: List<MibCard>) { fun update(newCards: List<CardItem>) {
cards = newCards cards = newCards
notifyDataSetChanged() notifyDataSetChanged()
} }
@@ -93,17 +99,34 @@ class CardSettingsFragment : Fragment() {
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner) private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber) private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardType: TextView = view.findViewById(R.id.tvCardType) private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
private val btnChangePin: View = view.findViewById(R.id.btnChangePin) private val btnChangePin: View = view.findViewById(R.id.btnChangePin)
private val btnFreeze: View = view.findViewById(R.id.btnFreeze) private val btnFreeze: View = view.findViewById(R.id.btnFreeze)
private val btnBlock: View = view.findViewById(R.id.btnBlock) private val btnBlock: View = view.findViewById(R.id.btnBlock)
fun bind(card: MibCard) { fun bind(item: CardItem) {
tvCardOwner.text = card.cardHolderName when (item) {
tvCardNumber.text = PayWithCardFragment.formatMasked(card.maskedCardNumber) is CardItem.Mib -> {
tvCardType.text = card.cardTypeDesc tvCardOwner.text = item.card.cardHolderName
val assetPath = PayWithCardFragment.cardImageAsset(card) tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath) tvCardType.text = item.card.cardTypeDesc
else ivCardImage.setImageDrawable(null) val assetPath = PayWithCardFragment.cardImageAsset(item.card)
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
itemView.alpha = 1f
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
tvCardType.text = item.account.accountTypeName
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
val bmlStatus = item.account.statusDesc.takeUnless { isActive }
PayWithCardFragment.bindCardStatus(tvCardStatus, bmlStatus)
itemView.alpha = if (isActive) 1f else 0.45f
}
}
val wip = View.OnClickListener { val wip = View.OnClickListener {
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show() Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
} }

View File

@@ -145,6 +145,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() } viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() } viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
(activity as? HomeActivity)?.loadAllContacts() (activity as? HomeActivity)?.loadAllContacts()
} }
@@ -183,6 +184,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> { private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> {
val search = binding.etSheetSearch.text?.toString()?.trim() ?: "" val search = binding.etSheetSearch.text?.toString()?.trim() ?: ""
val hide = viewModel.hideAmounts.value ?: false
val items = mutableListOf<ContactPickerAdapter.PickerItem>() val items = mutableListOf<ContactPickerAdapter.PickerItem>()
if (tabTag == RECENTS_TAG) { if (tabTag == RECENTS_TAG) {
@@ -209,11 +211,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber } val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
val fromCurrency = fromAccount?.currencyName ?: "" val fromCurrency = fromAccount?.currencyName ?: ""
val fromLoginTag = fromAccount?.loginTag ?: "" val fromLoginTag = fromAccount?.loginTag ?: ""
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
if (tabTag == MY_ACCOUNTS_TAG) { if (tabTag == MY_ACCOUNTS_TAG) {
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" } val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" } val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter { val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search) it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
@@ -223,10 +225,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
for (acc in filteredRegular) { for (acc in filteredRegular) {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash) if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
val isSame = acc.accountNumber == fromAccountNumber val isSame = acc.accountNumber == fromAccountNumber
val accBal = if (hide) "••••••" else acc.availableBalance
items.add(ContactPickerAdapter.PickerItem.Row( items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber, accountNumber = acc.accountNumber,
displayName = acc.accountBriefName, displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}", subtitle = "${acc.accountNumber} · ${acc.currencyName} $accBal",
colorHex = "#FE860E", colorHex = "#FE860E",
isSameAsFrom = isSame, isSameAsFrom = isSame,
imageHash = acc.profileImageHash, imageHash = acc.profileImageHash,
@@ -246,10 +249,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash) if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
val isSame = acc.accountNumber == fromAccountNumber val isSame = acc.accountNumber == fromAccountNumber
val isActive = acc.statusDesc.equals("Active", ignoreCase = true) val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
val cardBal = if (hide) "••••••" else acc.availableBalance
items.add(ContactPickerAdapter.PickerItem.Row( items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber, accountNumber = acc.accountNumber,
displayName = acc.accountBriefName, displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}", subtitle = "${acc.accountNumber} · ${acc.currencyName} $cardBal",
colorHex = "#FE860E", colorHex = "#FE860E",
isSameAsFrom = isSame, isSameAsFrom = isSame,
imageHash = acc.profileImageHash, imageHash = acc.profileImageHash,
@@ -306,6 +310,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
} }
} }
private fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? = private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null

View File

@@ -21,6 +21,7 @@ import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import kotlin.math.abs import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -52,16 +53,26 @@ class DashboardFragment : Fragment() {
binding.swipeRefresh.isRefreshing = false binding.swipeRefresh.isRefreshing = false
} }
binding.cardPendingFinances.setOnClickListener {
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
}
val cardAdapter = DashboardCardAdapter() val cardAdapter = DashboardCardAdapter()
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = cardAdapter binding.rvCards.adapter = cardAdapter
LinearSnapHelper().attachToRecyclerView(binding.rvCards) LinearSnapHelper().attachToRecyclerView(binding.rvCards)
viewModel.mibCards.observe(viewLifecycleOwner) { cards -> val updateCardList = {
if (cards.isNullOrEmpty()) return@observe val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
cardAdapter.update(cards) val bmlItems = (viewModel.accounts.value ?: emptyList())
binding.sectionCards.visibility = View.VISIBLE .filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
.map { CardItem.Bml(it) }
val all = mibItems + bmlItems
cardAdapter.update(all)
binding.sectionCards.visibility = if (all.isNotEmpty()) View.VISIBLE else View.GONE
} }
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt() val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets -> ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
@@ -229,9 +240,9 @@ class DashboardFragment : Fragment() {
} }
private inner class DashboardCardAdapter : RecyclerView.Adapter<DashboardCardAdapter.VH>() { private inner class DashboardCardAdapter : RecyclerView.Adapter<DashboardCardAdapter.VH>() {
private var cards: List<MibCard> = emptyList() private var cards: List<CardItem> = emptyList()
fun update(newCards: List<MibCard>) { fun update(newCards: List<CardItem>) {
cards = newCards cards = newCards
notifyDataSetChanged() notifyDataSetChanged()
} }
@@ -249,15 +260,27 @@ class DashboardFragment : Fragment() {
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage) private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner) private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber) private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
private val btnPayQr: View = view.findViewById(R.id.btnPayQr) private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc) private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
fun bind(card: MibCard) { fun bind(item: CardItem) {
tvCardOwner.text = card.cardHolderName when (item) {
tvCardNumber.text = PayWithCardFragment.formatMasked(card.maskedCardNumber) is CardItem.Mib -> {
val assetPath = PayWithCardFragment.cardImageAsset(card) tvCardOwner.text = item.card.cardHolderName
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath) tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
else ivCardImage.setImageDrawable(null) val assetPath = PayWithCardFragment.cardImageAsset(item.card)
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
PayWithCardFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
}
}
btnPayQr.setOnClickListener { btnPayQr.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
} }

View File

@@ -38,6 +38,8 @@ import okhttp3.RequestBody.Companion.toRequestBody
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.AuthExpiredException import sh.sar.basedbank.api.bml.AuthExpiredException
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.ConcurrentLinkedQueue
import sh.sar.basedbank.api.bml.BmlAccountClient import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlContactsClient import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
@@ -547,14 +549,25 @@ fun applyNavLabelVisibility() {
autoRefresh(store) autoRefresh(store)
} }
private fun showConnectivityBanner(message: String) {
binding.connectivityBanner.text = message
binding.connectivityBanner.visibility = View.VISIBLE
}
private fun hideConnectivityBanner() {
binding.connectivityBanner.visibility = View.GONE
}
private fun autoRefresh(store: CredentialStore) { private fun autoRefresh(store: CredentialStore) {
val mibLoginIds = store.getMibLoginIds() val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds() val bmlLoginIds = store.getBmlLoginIds()
val fahipayLoginIds = store.getFahipayLoginIds() val fahipayLoginIds = store.getFahipayLoginIds()
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
binding.refreshIndicator.visibility = View.VISIBLE binding.refreshIndicator.visibility = View.VISIBLE
hideConnectivityBanner()
lifecycleScope.launch { lifecycleScope.launch {
val refreshErrors = ConcurrentLinkedQueue<String>()
// One async job per MIB login, all run in parallel // One async job per MIB login, all run in parallel
val mibJobs = mibLoginIds.mapNotNull { loginId -> val mibJobs = mibLoginIds.mapNotNull { loginId ->
val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null
@@ -568,7 +581,15 @@ fun applyNavLabelVisibility() {
app.mibLoginFlows[loginId] = flow app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles) store.saveMibProfiles(loginId, flow.lastProfiles)
accounts accounts
} catch (_: Exception) { AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" } } } catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
} catch (e: BankServerException) {
refreshErrors.add("SERVER:${e.bankName}")
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
} catch (_: Exception) {
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
}
} }
} }
@@ -623,6 +644,14 @@ fun applyNavLabelVisibility() {
tryRefresh() tryRefresh()
} }
} }
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
} catch (e: BankServerException) {
refreshErrors.add("SERVER:${e.bankName}")
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
} catch (_: Exception) { } catch (_: Exception) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId) allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId } .filter { it.profileId == profile.profileId }
@@ -638,6 +667,12 @@ fun applyNavLabelVisibility() {
val accounts = BmlAccountClient().fetchAccounts(session, loginTag) val accounts = BmlAccountClient().fetchAccounts(session, loginTag)
app.bmlSessions[loginId] = session app.bmlSessions[loginId] = session
allAccounts += accounts allAccounts += accounts
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
} catch (e: BankServerException) {
refreshErrors.add("SERVER:${e.bankName}")
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
} catch (_: Exception) { } catch (_: Exception) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId) allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
} }
@@ -689,6 +724,12 @@ fun applyNavLabelVisibility() {
app.fahipaySessions[loginId] = session app.fahipaySessions[loginId] = session
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts) AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
accounts accounts
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
AccountCache.loadFahipay(this@HomeActivity, loginId)
} catch (e: BankServerException) {
refreshErrors.add("SERVER:${e.bankName}")
AccountCache.loadFahipay(this@HomeActivity, loginId)
} catch (_: Exception) { } catch (_: Exception) {
AccountCache.loadFahipay(this@HomeActivity, loginId) AccountCache.loadFahipay(this@HomeActivity, loginId)
} }
@@ -708,6 +749,22 @@ fun applyNavLabelVisibility() {
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts()) viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
binding.refreshIndicator.visibility = View.GONE binding.refreshIndicator.visibility = View.GONE
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
val serverErrors = refreshErrors.filter { it.startsWith("SERVER:") }
.map { it.removePrefix("SERVER:") }.distinct()
when {
noInternet -> showConnectivityBanner(getString(R.string.connectivity_no_internet))
serverErrors.isNotEmpty() -> showConnectivityBanner(
getString(R.string.connectivity_server_error, serverErrors.joinToString(", "))
)
else -> hideConnectivityBanner()
}
val errors = mutableSetOf<String>()
if (noInternet) errors.add("NO_INTERNET")
serverErrors.forEach { errors.add(it.uppercase()) }
viewModel.connectivityErrors.postValue(errors)
for ((_, session) in app.bmlSessions) refreshBmlLimits(session) for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
for ((loginId, session) in app.mibSessions) { for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList() val profiles = app.mibProfilesMap[loginId] ?: emptyList()

View File

@@ -10,6 +10,11 @@ import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
sealed class CardItem {
data class Mib(val card: MibCard) : CardItem()
data class Bml(val account: BankAccount) : CardItem()
}
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<BankAccount>>(emptyList()) val accounts = MutableLiveData<List<BankAccount>>(emptyList())
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList()) val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
@@ -24,4 +29,11 @@ class HomeViewModel : ViewModel() {
val mibCards = MutableLiveData<List<MibCard>?>(null) val mibCards = MutableLiveData<List<MibCard>?>(null)
val hideAmounts = MutableLiveData<Boolean>(false) val hideAmounts = MutableLiveData<Boolean>(false)
/**
* Set of connectivity error keys from the last refresh.
* Contains "NO_INTERNET" for no network, or uppercase bank names ("MIB", "BML", "FAHIPAY")
* for HTTP 5xx server errors from specific banks.
*/
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
} }

View File

@@ -97,7 +97,7 @@ class PayMvQrFragment : Fragment() {
private fun setupDropdown() { private fun setupDropdown() {
viewModel.accounts.observe(viewLifecycleOwner) { accounts -> viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val eligible = accounts.filter { val eligible = accounts.filter {
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN" it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN"
} }
val adapter = QrAccountAdapter(requireContext(), eligible) val adapter = QrAccountAdapter(requireContext(), eligible)
binding.actvAccount.setAdapter(adapter) binding.actvAccount.setAdapter(adapter)

View File

@@ -2,6 +2,7 @@ package sh.sar.basedbank.ui.home
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.drawable.GradientDrawable
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -16,9 +17,11 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentPayWithCardBinding import sh.sar.basedbank.databinding.FragmentPayWithCardBinding
import sh.sar.basedbank.util.CardsCache import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.bmlapi.BmlCardParser
class PayWithCardFragment : Fragment() { class PayWithCardFragment : Fragment() {
@@ -46,17 +49,23 @@ class PayWithCardFragment : Fragment() {
} }
binding.swipeRefresh.setOnRefreshListener { binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefreshCards() (activity as? HomeActivity)?.triggerRefresh()
} }
viewModel.mibCards.observe(viewLifecycleOwner) { cards -> val updateCardList = {
if (cards == null) return@observe val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
adapter.update(cards) val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
.map { CardItem.Bml(it) }
val all = mibItems + bmlItems
adapter.update(all)
binding.loadingView.visibility = View.GONE binding.loadingView.visibility = View.GONE
binding.swipeRefresh.isRefreshing = false binding.swipeRefresh.isRefreshing = false
binding.emptyView.visibility = if (cards.isEmpty()) View.VISIBLE else View.GONE binding.emptyView.visibility = if (all.isEmpty()) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (cards.isEmpty()) View.GONE else View.VISIBLE binding.recyclerView.visibility = if (all.isEmpty()) View.GONE else View.VISIBLE
} }
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
val cached = CardsCache.load(requireContext()) val cached = CardsCache.load(requireContext())
if (cached.isNotEmpty()) { if (cached.isNotEmpty()) {
@@ -78,11 +87,11 @@ class PayWithCardFragment : Fragment() {
} }
private inner class CardWalletAdapter( private inner class CardWalletAdapter(
private var cards: List<MibCard>, private var cards: List<CardItem>,
private val context: Context private val context: Context
) : RecyclerView.Adapter<CardWalletAdapter.VH>() { ) : RecyclerView.Adapter<CardWalletAdapter.VH>() {
fun update(newCards: List<MibCard>) { fun update(newCards: List<CardItem>) {
cards = newCards cards = newCards
notifyDataSetChanged() notifyDataSetChanged()
} }
@@ -98,16 +107,30 @@ class PayWithCardFragment : Fragment() {
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner) private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber) private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardType: TextView = view.findViewById(R.id.tvCardType) private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
private val btnPayQr: View = view.findViewById(R.id.btnPayQr) private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc) private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
fun bind(card: MibCard) { fun bind(item: CardItem) {
tvCardOwner.text = card.cardHolderName when (item) {
tvCardNumber.text = formatMasked(card.maskedCardNumber) is CardItem.Mib -> {
tvCardType.text = card.cardTypeDesc tvCardOwner.text = item.card.cardHolderName
val assetPath = cardImageAsset(card) tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
if (assetPath != null) loadCardImage(ivCardImage, assetPath) tvCardType.text = item.card.cardTypeDesc
else ivCardImage.setImageDrawable(null) val assetPath = cardImageAsset(item.card)
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
tvCardNumber.text = formatMasked(item.account.accountNumber)
tvCardType.text = item.account.accountTypeName
loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
val bmlStatus = item.account.statusDesc.takeUnless { it.equals("Active", ignoreCase = true) }
bindCardStatus(tvCardStatus, bmlStatus)
}
}
btnPayQr.setOnClickListener { btnPayQr.setOnClickListener {
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show() Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
} }
@@ -127,9 +150,9 @@ class PayWithCardFragment : Fragment() {
companion object { companion object {
fun cardImageAsset(card: MibCard): String? = when (card.cardType) { fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
"53" -> "cards/mib/visa_black_platinum.jpg" "53" -> "cards/mib/visa_black_platinum.png"
"57" -> "cards/mib/visa_blue_everyday.jpg" "57" -> "cards/mib/visa_blue_everyday.png"
"70" -> "cards/mib/visa_business.jpg" "70" -> "cards/mib/visa_business.png"
else -> null else -> null
} }
@@ -144,6 +167,23 @@ class PayWithCardFragment : Fragment() {
} }
} }
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
"CHST0" -> null // Active — no badge
else -> cardStatus
}
fun bindCardStatus(tv: TextView, statusLabel: String?) {
if (statusLabel == null) { tv.visibility = View.GONE; return }
tv.visibility = View.VISIBLE
tv.text = statusLabel
val dp = tv.context.resources.displayMetrics.density
tv.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 12 * dp
setColor(0xCC212121.toInt())
}
}
fun formatMasked(masked: String): String { fun formatMasked(masked: String): String {
if (masked.length < 4) return masked if (masked.length < 4) return masked
return "\u2022\u2022\u2022\u2022 ${masked.takeLast(4)}" return "\u2022\u2022\u2022\u2022 ${masked.takeLast(4)}"

View File

@@ -108,6 +108,7 @@ class TransferFragment : Fragment() {
val toAvatar: Bitmap? val toAvatar: Bitmap?
) )
private var pendingBmlTransfer: PendingBmlTransfer? = null private var pendingBmlTransfer: PendingBmlTransfer? = null
private var accountDropdownAdapter: AccountDropdownAdapter? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
@@ -178,6 +179,11 @@ class TransferFragment : Fragment() {
setupFromDropdown() setupFromDropdown()
setupAccountLookup() setupAccountLookup()
viewModel.hideAmounts.observe(viewLifecycleOwner) {
accountDropdownAdapter?.notifyDataSetChanged()
selectedAccount?.let { showFromCard(it) }
}
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle -> childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: "" val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
@@ -196,6 +202,8 @@ class TransferFragment : Fragment() {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
} }
viewModel.connectivityErrors.observe(viewLifecycleOwner) { updateTransferButton() }
binding.btnTransfer.isEnabled = false binding.btnTransfer.isEnabled = false
binding.btnTransfer.setOnClickListener { binding.btnTransfer.setOnClickListener {
if (bmlOtpState == BmlOtpState.AWAITING_OTP) verifyBmlOtp() if (bmlOtpState == BmlOtpState.AWAITING_OTP) verifyBmlOtp()
@@ -234,6 +242,13 @@ class TransferFragment : Fragment() {
binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search) binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
} }
// ── Sensitive masking helpers ─────────────────────────────────────────────
private fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
private fun setupFromDropdown() { private fun setupFromDropdown() {
binding.btnClearFromInfo.setOnClickListener { binding.btnClearFromInfo.setOnClickListener {
selectedAccount = null selectedAccount = null
@@ -245,11 +260,11 @@ class TransferFragment : Fragment() {
} }
viewModel.accounts.observe(viewLifecycleOwner) { accounts -> viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val adapter = AccountDropdownAdapter(requireContext(), accounts) accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
binding.actvFrom.setAdapter(adapter) binding.actvFrom.setAdapter(accountDropdownAdapter)
binding.actvFrom.setOnItemClickListener { _, _, position, _ -> binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
selectedAccount = picked selectedAccount = picked
updateAmountPrefix(picked) updateAmountPrefix(picked)
showFromCard(picked) showFromCard(picked)
@@ -283,13 +298,16 @@ class TransferFragment : Fragment() {
val typeLabel = when { val typeLabel = when {
account.profileType == "BML_PREPAID" -> "Prepaid Card" account.profileType == "BML_PREPAID" -> "Prepaid Card"
account.profileType == "BML_CREDIT" -> "Credit Card" account.profileType == "BML_CREDIT" -> "Credit Card"
account.profileType == "BML_DEBIT" -> "Debit Card"
account.accountTypeName.isNotBlank() -> account.accountTypeName account.accountTypeName.isNotBlank() -> account.accountTypeName
else -> account.profileType else -> account.profileType
} }
val hide = viewModel.hideAmounts.value ?: false
val balancePart = "${account.currencyName} ${account.availableBalance}"
binding.tvFromAccountName.text = account.accountBriefName binding.tvFromAccountName.text = account.accountBriefName
binding.tvFromAccountNumber.text = account.accountNumber binding.tvFromAccountNumber.text = account.accountNumber
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, "${account.currencyName} ${account.availableBalance}").joinToString(" · ") binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, if (hide) maskAmount(balancePart) else balancePart).joinToString(" · ")
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex)) binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex))
binding.tilFrom.visibility = View.GONE binding.tilFrom.visibility = View.GONE
binding.cardFromInfo.visibility = View.VISIBLE binding.cardFromInfo.visibility = View.VISIBLE
@@ -632,7 +650,7 @@ class TransferFragment : Fragment() {
val isSrcBml = src.bank == "BML" val isSrcBml = src.bank == "BML"
val isBmlBusiness = isSrcBml && isBusinessProfile(src) // to test on personal accounts: use `isSrcBml` val isBmlBusiness = isSrcBml && isBusinessProfile(src) // to test on personal accounts: use `isSrcBml`
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT" val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT" || src.profileType == "BML_DEBIT"
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val currency = src.currencyName.ifBlank { "MVR" } val currency = src.currencyName.ifBlank { "MVR" }
val allAccounts = viewModel.accounts.value ?: emptyList() val allAccounts = viewModel.accounts.value ?: emptyList()
@@ -780,12 +798,6 @@ class TransferFragment : Fragment() {
val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null) val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val loginId = src.loginTag.removePrefix("mib_") val loginId = src.loginTag.removePrefix("mib_")
// Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
val otp = CredentialStore(requireContext()).loadMibCredentials(loginId)?.otpSeed val otp = CredentialStore(requireContext()).loadMibCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null) ?: return Triple(false, "OTP unavailable", null)
@@ -802,6 +814,12 @@ class TransferFragment : Fragment() {
} }
} }
return try { return try {
// Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
val result = MibTransferClient().transfer( val result = MibTransferClient().transfer(
session = sess, session = sess,
fromAccount = src.accountNumber, fromAccount = src.accountNumber,
@@ -860,7 +878,7 @@ class TransferFragment : Fragment() {
} }
// Determine type + credit account // Determine type + credit account
val isDestMyCard = allAccounts.any { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount } val isDestMyCard = allAccounts.any { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount }
val (transferType, creditAccount, bank) = when { val (transferType, creditAccount, bank) = when {
isSrcCard -> { isSrcCard -> {
// CAD: card → own BML account // CAD: card → own BML account
@@ -869,7 +887,7 @@ class TransferFragment : Fragment() {
} }
isDestMyCard -> { isDestMyCard -> {
// CPA: BML CASA → own card top-up // CPA: BML CASA → own card top-up
val card = allAccounts.first { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount } val card = allAccounts.first { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount }
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?) Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
} }
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB") isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
@@ -950,7 +968,7 @@ class TransferFragment : Fragment() {
return return
} }
val isDestMyCard = allAccounts.any { val isDestMyCard = allAccounts.any {
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount
} }
val (transferType, creditAccount, bank) = when { val (transferType, creditAccount, bank) = when {
isSrcCard -> { isSrcCard -> {
@@ -959,7 +977,7 @@ class TransferFragment : Fragment() {
} }
isDestMyCard -> { isDestMyCard -> {
val card = allAccounts.first { val card = allAccounts.first {
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount
} }
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?) Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
} }
@@ -1214,7 +1232,12 @@ class TransferFragment : Fragment() {
private fun updateTransferButton() { private fun updateTransferButton() {
if (bmlOtpState != BmlOtpState.NONE) return if (bmlOtpState != BmlOtpState.NONE) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0 val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
binding.btnTransfer.isEnabled = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0 val hasAll = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
val errors = viewModel.connectivityErrors.value ?: emptySet()
val bankOffline = "NO_INTERNET" in errors ||
selectedAccount?.bank?.uppercase()?.let { it in errors } == true
binding.btnTransfer.isEnabled = !bankOffline
} }
private fun clearForm() { private fun clearForm() {
@@ -1337,8 +1360,8 @@ class TransferFragment : Fragment() {
) : BaseAdapter(), Filterable { ) : BaseAdapter(), Filterable {
private val items: List<Any> = buildList { private val items: List<Any> = buildList {
val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN" } val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" } val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
addAll(regular) addAll(regular)
if (cards.isNotEmpty()) { if (cards.isNotEmpty()) {
add(getString(R.string.cards)) add(getString(R.string.cards))
@@ -1347,7 +1370,7 @@ class TransferFragment : Fragment() {
} }
fun getAccount(position: Int): BankAccount? = (items.getOrNull(position) as? BankAccount) fun getAccount(position: Int): BankAccount? = (items.getOrNull(position) as? BankAccount)
?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) } ?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
override fun getCount() = items.size override fun getCount() = items.size
override fun getItem(position: Int) = items[position] override fun getItem(position: Int) = items[position]
@@ -1378,12 +1401,14 @@ class TransferFragment : Fragment() {
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false) ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
.also { it.root.tag = it } .also { it.root.tag = it }
} }
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true) val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT" || acc.profileType == "BML_DEBIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
val isBmlAccount = acc.bank == "BML" val isBmlAccount = acc.bank == "BML"
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else "" val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
val hide = viewModel.hideAmounts.value ?: false
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}" b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
b.tvDropdownBalance.text = AccountListParser.from(acc)?.balance ?: "" val balance = AccountListParser.from(acc)?.balance ?: ""
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
b.root.alpha = if (inactive) 0.4f else 1f b.root.alpha = if (inactive) 0.4f else 1f
b.root b.root
} }

View File

@@ -64,7 +64,7 @@ class TransferHistoryFragment : Fragment() {
) { ) {
fun hasMore(): Boolean = when { fun hasMore(): Boolean = when {
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2 account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
} }
@@ -187,7 +187,7 @@ class TransferHistoryFragment : Fragment() {
async { async {
try { try {
when { when {
state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" -> { state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" || state.account.profileType == "BML_DEBIT" -> {
val session = app.bmlSessionFor(state.account) ?: return@async emptyList() val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -state.cardMonthOffset) cal.add(Calendar.MONTH, -state.cardMonthOffset)

View File

@@ -19,7 +19,7 @@ object AccountCache {
put("bank", acc.bank) put("bank", acc.bank)
put("profileName", acc.profileName) put("profileName", acc.profileName)
put("profileType", acc.profileType) put("profileType", acc.profileType)
put("cifType", acc.cifType) put("productCode", acc.productCode)
put("accountNumber", acc.accountNumber) put("accountNumber", acc.accountNumber)
put("accountBriefName", acc.accountBriefName) put("accountBriefName", acc.accountBriefName)
put("currencyName", acc.currencyName) put("currencyName", acc.currencyName)
@@ -44,6 +44,7 @@ object AccountCache {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
put("profileName", acc.profileName) put("profileName", acc.profileName)
put("profileType", acc.profileType) put("profileType", acc.profileType)
put("productCode", acc.productCode)
put("accountNumber", acc.accountNumber) put("accountNumber", acc.accountNumber)
put("accountBriefName", acc.accountBriefName) put("accountBriefName", acc.accountBriefName)
put("currencyName", acc.currencyName) put("currencyName", acc.currencyName)
@@ -55,6 +56,7 @@ object AccountCache {
put("statusDesc", acc.statusDesc) put("statusDesc", acc.statusDesc)
put("loginTag", acc.loginTag) put("loginTag", acc.loginTag)
put("internalId", acc.internalId) put("internalId", acc.internalId)
put("profileId", acc.profileId)
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -72,6 +74,7 @@ object AccountCache {
bank = "BML", bank = "BML",
profileName = o.optString("profileName"), profileName = o.optString("profileName"),
profileType = o.optString("profileType"), profileType = o.optString("profileType"),
productCode = o.optString("productCode", ""),
accountNumber = o.optString("accountNumber"), accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
@@ -83,7 +86,8 @@ object AccountCache {
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = null, profileImageHash = null,
loginTag = o.optString("loginTag"), loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "") internalId = o.optString("internalId", ""),
profileId = o.optString("profileId", "")
) )
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
@@ -162,7 +166,7 @@ object AccountCache {
bank = o.optString("bank", "MIB"), bank = o.optString("bank", "MIB"),
profileName = o.optString("profileName"), profileName = o.optString("profileName"),
profileType = o.optString("profileType"), profileType = o.optString("profileType"),
cifType = o.optString("cifType", ""), productCode = o.optString("productCode", ""),
accountNumber = o.optString("accountNumber"), accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),

View File

@@ -21,7 +21,7 @@ import java.util.Locale
class HistoryFetcher(private val account: BankAccount) { class HistoryFetcher(private val account: BankAccount) {
private val isMib get() = account.bank == "MIB" private val isMib get() = account.bank == "MIB"
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
private val isBmlLoan get() = account.profileType == "BML_LOAN" private val isBmlLoan get() = account.profileType == "BML_LOAN"
private val isFahipay get() = account.bank == "FAHIPAY" private val isFahipay get() = account.bank == "FAHIPAY"

View File

@@ -0,0 +1,45 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.api.models.BankAccount
object BmlCardParser {
/**
* Returns the asset path for the card image.
* The product code is stored in [BankAccount.productCode] for BML Card accounts.
*/
fun cardImageAsset(account: BankAccount): String =
productCodeToAsset(account.productCode)
fun productCodeToAsset(productCode: String): String = when (productCode) {
"C8201", "C8001", "C8009" -> "cards/bml/master_prepaid.png"
"C8205", "C8005", "C8008" -> "cards/bml/master_prepaid_travel.png"
"C3007", "C3017", "C3097", "C3095", "C3077", "C3177" -> "cards/bml/amex_debit_green.png"
"C3003", "C3013", "C3053", "C3023", "C3033", "C3052" -> "cards/bml/amex_debit_gold.png"
"C3009", "C3019", "C3029", "C3099", "C3088", "C3188" -> "cards/bml/amex_credit_gold.png"
"C3001", "C3011", "C3050", "C3051", "C3031" -> "cards/bml/amex_credit_green.png"
"C3005", "C3015", "C3055", "C3054" -> "cards/bml/amex_platinum.png"
"C1003", "C1013", "C1083", "C1084", "C1103", "C1113", "C1183", "C1184" -> "cards/bml/visa_gold.png"
"C1007", "C1027", "C1097", "C1107", "C1197", "C1077", "C1177" -> "cards/bml/visa_debit.png"
"C1020", "C1021" -> "cards/bml/visa_debit_platinum.png"
"C8020", "C8022" -> "cards/bml/master_gold.png"
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
"C8101" -> "cards/bml/master_masveriyaa.png"
"C8102" -> "cards/bml/master_odiveriyaa.png"
"C8010", "C8011" -> "cards/bml/master_platinum.png"
"C8040", "C8044" -> "cards/bml/master_world.png"
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
"C1030", "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
"C8905", "C8995" -> "cards/bml/visa_credit.png"
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png"
"C1005", "C1006", "C1089" -> "cards/bml/visa_debit_islamic.png"
"C1017" -> "cards/bml/visa_infinite.png"
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png"
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
"C8925", "C8926" -> "cards/bml/visa_student_blue.png"
"C1071", "C1073", "C1061", "C1063", "C1161", "C1163" -> "cards/bml/master.png"
"C1070", "C1072", "C1059", "C1062", "C1159", "C1162" -> "cards/bml/master_prepaid_business.png"
else -> "cards/bml/defaultcard.png"
}
}

View File

@@ -11,7 +11,8 @@ object BmlDashboardParser {
* Handles both BML CASA accounts and BML prepaid/credit cards. * Handles both BML CASA accounts and BML prepaid/credit cards.
*/ */
fun displayData(account: BankAccount): AccountListDisplay? { fun displayData(account: BankAccount): AccountListDisplay? {
if (account.profileType == "BML_LOAN") return null // Loans shown on financing page only if (account.profileType == "BML_LOAN") return null // Loans shown on financing page only
if (account.profileType == "BML_DEBIT") return null // Debit cards shown on card screens only
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
return if (isCard) { return if (isCard) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true) val isActive = account.statusDesc.equals("Active", ignoreCase = true)

View File

@@ -40,6 +40,20 @@
</FrameLayout> </FrameLayout>
<TextView
android:id="@+id/connectivityBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#C62828"
android:textColor="#FFFFFF"
android:gravity="center"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:textSize="12sp"
android:visibility="gone" />
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<LinearLayout <LinearLayout

View File

@@ -52,7 +52,16 @@
android:textSize="26sp" android:textSize="26sp"
android:letterSpacing="0.3" android:letterSpacing="0.3"
android:textColor="?attr/colorPrimary" android:textColor="?attr/colorPrimary"
android:layout_marginBottom="24dp" /> android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvPinHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:layout_marginBottom="20dp" />
<LinearLayout <LinearLayout
android:id="@+id/lockNumpadContainer" android:id="@+id/lockNumpadContainer"

View File

@@ -178,11 +178,14 @@
<!-- Pending Finances card --> <!-- Pending Finances card -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:id="@+id/cardPendingFinances"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
app:cardElevation="1dp" app:cardElevation="1dp"
app:cardCornerRadius="12dp"> app:cardCornerRadius="12dp"
android:clickable="true"
android:focusable="true">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -31,6 +31,21 @@
android:layout_gravity="bottom" android:layout_gravity="bottom"
android:background="@drawable/bg_card_overlay_gradient"/> android:background="@drawable/bg_card_overlay_gradient"/>
<TextView
android:id="@+id/tvCardStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_margin="8dp"
android:paddingStart="6dp"
android:paddingEnd="6dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:textSize="9sp"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:visibility="gone"/>
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -32,6 +32,21 @@
android:layout_gravity="bottom" android:layout_gravity="bottom"
android:background="@drawable/bg_card_overlay_gradient"/> android:background="@drawable/bg_card_overlay_gradient"/>
<TextView
android:id="@+id/tvCardStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_margin="10dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:textSize="10sp"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:visibility="gone"/>
<!-- Bottom-left: card owner name + masked number --> <!-- Bottom-left: card owner name + masked number -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -32,6 +32,21 @@
android:layout_gravity="bottom" android:layout_gravity="bottom"
android:background="@drawable/bg_card_overlay_gradient"/> android:background="@drawable/bg_card_overlay_gradient"/>
<TextView
android:id="@+id/tvCardStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_margin="10dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:textSize="10sp"
android:textColor="#FFFFFF"
android:textStyle="bold"
android:visibility="gone"/>
<!-- Bottom-left: card owner name + masked number --> <!-- Bottom-left: card owner name + masked number -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@@ -141,4 +141,8 @@
<!-- Home --> <!-- Home -->
<string name="accounts">އެކައުންޓްތައް</string> <string name="accounts">އެކައުންޓްތައް</string>
<string name="available_balance">ލިބެން ހުރި ބެލެންސް</string> <string name="available_balance">ލިބެން ހުރި ބެލެންސް</string>
<!-- Connectivity banner -->
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
</resources> </resources>

View File

@@ -302,4 +302,8 @@
<string name="card_action_freeze">Freeze</string> <string name="card_action_freeze">Freeze</string>
<string name="card_action_block">Block</string> <string name="card_action_block">Block</string>
<string name="cards_empty">No cards found</string> <string name="cards_empty">No cards found</string>
<!-- Connectivity banner -->
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
<string name="connectivity_server_error">Connectivity issue with %s</string>
</resources> </resources>

View File

@@ -187,15 +187,15 @@ The app presents different identities to different backends, which is intentiona
| Backend | User-Agent sent | | Backend | User-Agent sent |
|---|---| |---|---|
| MIB API | `android/1.0` | | MIB API | `android/1.0` |
| MIB WebView | `Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 ... Mobile Safari/537.36` | | MIB WebView | `Mozilla/5.0 (Linux; Android {version}; wv) AppleWebKit/537.36 ... Mobile Safari/537.36` |
| BML web steps | `Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0` | | BML web steps | `Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0` |
| BML API calls | `bml-mobile-banking/345 (POCO; Android 14; 22101320I)` | | BML API calls | `bml-mobile-banking/345 (POCO; Android {version}; {model})` |
| Fahipay login/OTP | WebView UA with actual `Build.MODEL` | | Fahipay login/OTP | WebView UA with actual `Build.MODEL` |
| Fahipay API calls | `okhttp/4.12.0` | | Fahipay API calls | `okhttp/4.12.0` |
| Ooredoo | No custom UA | | Ooredoo | No custom UA |
| Dhiraagu | `Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0` | | Dhiraagu | `Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0` |
The BML API User-Agent hardcodes a specific device model (`POCO; Android 14; 22101320I`) to mimic the official BML mobile app. This is required for the API to accept requests. The BML API User-Agent hardcodes a specific device model (`POCO; Android {version}; {model}`) to mimic the official BML mobile app. This is required for the API to accept requests.
The Fahipay login includes `Build.MODEL` and `Build.MANUFACTURER` from the actual device, sent as `device[model]` and `device[manufacturer]` form fields. This is a **device fingerprint** sent to Fahipay on every login. The Fahipay login includes `Build.MODEL` and `Build.MANUFACTURER` from the actual device, sent as `device[model]` and `device[manufacturer]` form fields. This is a **device fingerprint** sent to Fahipay on every login.
@@ -327,7 +327,7 @@ All hardcoded values in this codebase are protocol constants extracted from reve
| `P = BigInteger("2410312426921...")` | `MibCrypto.kt:17` | MIB DH prime modulus. Same value as in the official app. | Public parameter — no risk. | | `P = BigInteger("2410312426921...")` | `MibCrypto.kt:17` | MIB DH prime modulus. Same value as in the official app. | Public parameter — no risk. |
| `CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"` | `BmlLoginFlow.kt:30` | BML OAuth client ID, extracted from the official BML mobile app APK. | Not a personal secret — it is the same value for all BML mobile clients. | | `CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"` | `BmlLoginFlow.kt:30` | BML OAuth client ID, extracted from the official BML mobile app APK. | Not a personal secret — it is the same value for all BML mobile clients. |
| `REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"` | `BmlLoginFlow.kt:31` | BML OAuth redirect URI, must match what BML's server expects. | Fixed protocol value. | | `REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"` | `BmlLoginFlow.kt:31` | BML OAuth redirect URI, must match what BML's server expects. | Fixed protocol value. |
| `APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)"` | `BmlLoginFlow.kt:32` | Impersonates the official BML app and a specific POCO device model. | Intentional compatibility measure; no personal data. | | `APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android {version}; {model})"` | `BmlLoginFlow.kt:32` | Impersonates the official BML app and a specific POCO device model. | Intentional compatibility measure; no personal data. |
| `APP_VERSION = "2.1.43.345"` | `BmlLoginFlow.kt:33` | BML app version string being impersonated. | Fixed protocol value. | | `APP_VERSION = "2.1.43.345"` | `BmlLoginFlow.kt:33` | BML app version string being impersonated. | Fixed protocol value. |
| `website_id = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"` | `DhiraaguClient.kt:45` | Dhiraagu SDK identifier embedded in the lookup URL. Extracted from Dhiraagu's public Easy Pay page. | Public value embedded in their web page; not a secret. | | `website_id = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"` | `DhiraaguClient.kt:45` | Dhiraagu SDK identifier embedded in the lookup URL. Extracted from Dhiraagu's public Easy Pay page. | Public value embedded in their web page; not a secret. |
| `MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"` | `AddContactSheetFragment.kt:457` | BML's internal bank code (SWIFT/FinInstnId) used to identify MIB as the counterpart bank during BML transfers. | Protocol constant, not a secret. | | `MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"` | `AddContactSheetFragment.kt:457` | BML's internal bank code (SWIFT/FinInstnId) used to identify MIB as the counterpart bank during BML transfers. | Protocol constant, not a secret. |

256
docs/bmlapi/01-login.md Normal file
View File

@@ -0,0 +1,256 @@
# Login
Authenticate a user through the BML web session flow. This involves four sequential HTTP steps before OAuth can begin: seeding cookies, posting credentials, verifying TOTP, and selecting a profile.
All requests in this flow use the **web User-Agent** and a shared cookie jar that persists cookies (`XSRF-TOKEN`, `blaze_session`, `blaze_identity`) across steps.
---
## Step 1 — Seed Session Cookies
```
GET https://www.bankofmaldives.com.mv/internetbanking/web/login
```
This request initialises the session. The server sets the `XSRF-TOKEN` and `blaze_session` cookies which must be carried through the entire login flow.
### Request
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/login' \
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0'
```
### Response
`200 OK` — HTML page. The cookies set in the response headers are the only output needed:
```
Set-Cookie: XSRF-TOKEN=<token>; Path=/; SameSite=Lax
Set-Cookie: blaze_session=<session>; Path=/; HttpOnly; SameSite=Lax
```
Extract `XSRF-TOKEN` from the cookie store for use in Step 2.
---
## Step 2 — Submit Credentials
```
POST https://www.bankofmaldives.com.mv/internetbanking/web/login
```
### Request
**Content-Type:** `application/json`
```json
{
"username": "A123456",
"password": "your_password",
"code": ""
}
```
| Field | Notes |
|---|---|
| `username` | BML online banking username (typically the national ID card number) |
| `password` | Online banking password |
| `code` | Always empty string `""` at this step |
**Headers:**
| Header | Value |
|---|---|
| `X-XSRF-TOKEN` | Value of the `XSRF-TOKEN` cookie from Step 1 |
| `Content-Type` | `application/json` |
| `User-Agent` | `Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0` |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/login' \
--header 'Content-Type: application/json' \
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
--header 'X-XSRF-TOKEN: <xsrf_token>' \
--cookie 'XSRF-TOKEN=<xsrf_token>; blaze_session=<session>' \
--data '{"username":"A123456","password":"your_password","code":""}'
```
### Response
**Success:** `302` redirect to `/web/login/2fa`. The `blaze_session` cookie is updated.
**Failure:** Any non-`302` response (typically `200` with the login page re-rendered) means the credentials were rejected.
---
## Step 3 — Fetch 2FA Page
```
GET https://www.bankofmaldives.com.mv/internetbanking/web/login/2fa
```
This request refreshes the `XSRF-TOKEN` cookie for the TOTP submission in Step 4. The response body is an HTML page that can be discarded.
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/login/2fa' \
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
--cookie 'XSRF-TOKEN=<xsrf_token>; blaze_session=<session>'
```
Extract the fresh `XSRF-TOKEN` from the updated cookie store before Step 4.
---
## Step 4 — Submit TOTP
```
POST https://www.bankofmaldives.com.mv/internetbanking/web/login/2fa
```
### Request
**Content-Type:** `application/json`
```json
{
"code": "123456",
"channel": "authenticator"
}
```
| Field | Value | Notes |
|---|---|---|
| `code` | `123456` | 6-digit TOTP from the user's authenticator app |
| `channel` | `authenticator` | Always `authenticator` for TOTP |
**Headers:**
| Header | Value |
|---|---|
| `X-XSRF-TOKEN` | Fresh value from Step 3 |
| `Content-Type` | `application/json` |
| `User-Agent` | Web UA |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/login/2fa' \
--header 'Content-Type: application/json' \
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
--header 'X-XSRF-TOKEN: <xsrf_token_2>' \
--cookie 'XSRF-TOKEN=<xsrf_token_2>; blaze_session=<session>' \
--data '{"code":"123456","channel":"authenticator"}'
```
### Response
**Success:** `302` redirect to `/web/profile`.
**Failure:** Any non-`302` (typically a `200` with the 2FA page) means the TOTP was invalid. Generate a fresh code and retry.
---
## TOTP Details
BML uses standard RFC 6238 TOTP:
| Parameter | Value |
|---|---|
| Algorithm | HMAC-SHA1 |
| Period | 30 seconds |
| Digits | 6 |
| Encoding | Base32 secret |
---
## Step 5 — Profile Selection
```
GET https://www.bankofmaldives.com.mv/internetbanking/web/profile
```
### Response — Multi-profile account
`200 OK` with an HTML page containing an Inertia.js `data-page` payload. After [extracting the Inertia JSON](README.md#inertiajs-response-format):
```json
{
"props": {
"profiles": [
{
"profile_id": "12345",
"name": "Mohamed Ali",
"type": "Profile",
"profile": {
"profile_type": "default"
}
},
{
"profile_id": "67890",
"name": "My Company Ltd",
"type": "Business",
"profile": {
"profile_type": "business"
}
}
]
}
}
```
| Field | Type | Description |
|---|---|---|
| `profile_id` | `string` | ID used in Step 6 to activate this profile |
| `name` | `string` | Display name of the profile |
| `type` | `string` | `"Profile"` for personal, `"Business"` for business |
| `profile.profile_type` | `string` | `"default"` (personal) or `"business"` |
### Response — Single-profile account
`302` redirect (to `/web/redirect` or similar). The server has auto-activated the sole profile and set the `blaze_identity` cookie. Skip Step 6 and proceed directly to [OAuth Token Exchange](03-oauth-token.md).
---
## Step 6 — Activate Profile
```
GET https://www.bankofmaldives.com.mv/internetbanking/web/profile/{profile_id}
```
Replace `{profile_id}` with the value from the profile list.
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/profile/12345' \
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
--cookie 'XSRF-TOKEN=<xsrf_token>; blaze_session=<session>'
```
### Response — Personal profile (`profile_type: "default"`)
`302` redirect (to `/web/redirect` or any path other than `/web/profile/2fa/business`), or `409 Conflict` if the profile was already active.
Both outcomes mean success: the `blaze_identity` cookie is now set. Proceed to [OAuth Token Exchange](03-oauth-token.md).
### Response — Business profile (`profile_type: "business"`)
`302` redirect to `/web/profile/2fa/business`. The business profile requires an additional SMS/email OTP verification step.
Proceed to [Business Profile OTP](02-business-otp.md).
---
## Next Steps
- **Personal profile activated** → proceed to **[OAuth Token Exchange](03-oauth-token.md)**
- **Business profile** → proceed to **[Business Profile OTP](02-business-otp.md)**
---
&nbsp;
---
[← README](README.md) &nbsp;&nbsp;&nbsp; **Next →** [Business Profile OTP](02-business-otp.md)

View File

@@ -0,0 +1,164 @@
# Business Profile OTP
When activating a business profile (where `profile_type` is `"business"`), the server requires SMS or email OTP verification before issuing the `blaze_identity` cookie. This is a two-step process: request an OTP to a chosen channel, then submit the received code.
---
## Prerequisites
- Completed [Login Steps 15](01-login.md) (web session cookies are active)
- Received a `302` redirect to `/web/profile/2fa/business` after `GET /web/profile/{profile_id}`
---
## Step 1 — Fetch Available OTP Channels
```
GET https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business
```
Fetches the HTML page for the business 2FA screen. This also refreshes the `XSRF-TOKEN` cookie needed for the POST requests below.
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business' \
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
--cookie 'XSRF-TOKEN=<xsrf_token>; blaze_session=<session>; blaze_identity=<identity>'
```
### Response
`200 OK` — HTML with an Inertia.js `data-page` payload. After [extracting the Inertia JSON](README.md#inertiajs-response-format):
```json
{
"props": {
"channels": [
{
"channel": "sms",
"description": "SMS",
"masked": "+960 9XX XXXX"
},
{
"channel": "email",
"description": "Email",
"masked": "m****@example.com"
}
]
}
}
```
| Field | Type | Description |
|---|---|---|
| `channel` | `string` | Channel identifier — use in Step 2 and Step 3 |
| `description` | `string` | Human-readable channel name |
| `masked` | `string` | Partially masked destination (for display to user) |
Present the `masked` values to the user so they can choose where to receive their OTP.
---
## Step 2 — Request OTP
```
POST https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business
```
Send an empty `code` to trigger OTP dispatch to the chosen channel.
### Request
**Content-Type:** `application/json`
```json
{
"code": "",
"channel": "sms"
}
```
| Field | Value | Notes |
|---|---|---|
| `code` | `""` | Empty — signals OTP dispatch, not submission |
| `channel` | `"sms"` or `"email"` | Channel from Step 1 |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business' \
--header 'Content-Type: application/json' \
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
--header 'X-XSRF-TOKEN: <xsrf_token>' \
--cookie 'XSRF-TOKEN=<xsrf_token>; blaze_session=<session>' \
--data '{"code":"","channel":"sms"}'
```
### Response
**Success:** `302` redirect — OTP has been sent.
**Failure:** Any non-`302` — request failed; check session cookies and retry.
---
## Step 3 — Submit OTP
```
POST https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business
```
Before submitting, refresh the XSRF token by making a fresh `GET /web/profile/2fa/business` (repeat Step 1). This ensures the token is valid for the confirmation POST.
### Request
**Content-Type:** `application/json`
```json
{
"code": "123456",
"channel": "sms"
}
```
| Field | Value | Notes |
|---|---|---|
| `code` | `"123456"` | OTP received via SMS/email |
| `channel` | `"sms"` or `"email"` | Same channel used in Step 2 |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/web/profile/2fa/business' \
--header 'Content-Type: application/json' \
--header 'User-Agent: Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
--header 'X-XSRF-TOKEN: <xsrf_token_fresh>' \
--cookie 'XSRF-TOKEN=<xsrf_token_fresh>; blaze_session=<session>' \
--data '{"code":"123456","channel":"sms"}'
```
### Response — Success
`302` redirect to `/web/redirect` or `409 Conflict`. Both mean the OTP was accepted and the `blaze_identity` cookie is now set for the business profile.
Proceed to [OAuth Token Exchange](03-oauth-token.md).
### Response — Invalid OTP
`302` redirect to any path other than `/web/redirect`. The OTP was wrong. Retry Step 3 with a new code (re-requesting is not usually needed unless the OTP expired).
### Response — Other failure
Non-`302` HTTP status — session has likely expired. Return to the full [login flow](01-login.md).
---
## Next Steps
After the `blaze_identity` cookie is set → proceed to **[OAuth Token Exchange](03-oauth-token.md)**
---
&nbsp;
---
[← Login](01-login.md) &nbsp;&nbsp;&nbsp; **Next →** [OAuth Token](03-oauth-token.md)

View File

@@ -0,0 +1,193 @@
# OAuth Token Exchange and Refresh
After the web login flow has set the `blaze_identity` cookie (profile activated), the client exchanges a PKCE authorization code for an `access_token` and `refresh_token`. These tokens are used for all subsequent REST API calls.
---
## Prerequisites
- Completed [Login](01-login.md) (and [Business OTP](02-business-otp.md) if applicable)
- `blaze_identity` cookie set in the session
- PKCE `code_verifier` and `code_challenge` generated at the start of the login session
- `Device-ID` generated at the start of the login session
---
## PKCE Parameter Generation
Generate these once at the start of each login session:
| Parameter | Generation |
|---|---|
| `code_verifier` | 72 cryptographically random bytes, base64url-encoded (no padding) |
| `code_challenge` | SHA-256 hash of `code_verifier` (as ASCII bytes), base64url-encoded (no padding) |
| `Device-ID` | 8 cryptographically random bytes, hex-encoded (16 hex chars) |
| `state` | 16 random bytes, base64url-encoded |
| `nonce` | 12 random bytes, base64url-encoded |
---
## Step 1 — Authorize (Get Auth Code)
```
GET https://www.bankofmaldives.com.mv/internetbanking/oauth/authorize
```
### Query Parameters
| Parameter | Value |
|---|---|
| `redirect_uri` | `https://app.bankofmaldives.com.mv/oauth/mobile-callback` |
| `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` |
| `response_type` | `code` |
| `state` | Random base64url string (16 bytes) |
| `nonce` | Random base64url string (12 bytes) |
| `code_challenge` | SHA-256 of `code_verifier`, base64url-encoded |
| `code_challenge_method` | `S256` |
| `Device-ID` | Random 16-char hex string |
| `User-Agent` | App user agent string |
| `x-app-version` | `2.1.44.348` |
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/authorize?redirect_uri=https%3A%2F%2Fapp.bankofmaldives.com.mv%2Foauth%2Fmobile-callback&client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7&response_type=code&state=<state>&nonce=<nonce>&code_challenge=<code_challenge>&code_challenge_method=S256&Device-ID=<device_id>&User-Agent=bml-mobile-banking%2F348+%28{manufacturer}%3B+Android+14%3B+{model}%29&x-app-version=2.1.44.348' \
--header 'User-Agent: Mozilla/5.0 (Android {version}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
--cookie 'blaze_session=<session>; blaze_identity=<identity>'
```
### Response
`302` redirect to:
```
https://app.bankofmaldives.com.mv/oauth/mobile-callback?code=<auth_code>&state=<state>
```
Extract the `code` query parameter from the `Location` header. This is the one-time authorization code.
---
## Step 2 — Token Exchange
```
POST https://www.bankofmaldives.com.mv/internetbanking/oauth/token
```
### Request
**Content-Type:** `application/x-www-form-urlencoded`
| Field | Value |
|---|---|
| `grant_type` | `authorization_code` |
| `code` | Auth code from Step 1 |
| `code_verifier` | PKCE verifier generated at login start |
| `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` |
| `redirect_uri` | `https://app.bankofmaldives.com.mv/oauth/mobile-callback` |
| `Device-ID` | Same device ID used in Step 1 |
| `User-Agent` | App user agent string |
| `x-app-version` | `2.1.44.348` |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/token' \
--header 'User-Agent: Mozilla/5.0 (Android {version}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0' \
--data 'grant_type=authorization_code' \
--data 'code=<auth_code>' \
--data 'code_verifier=<code_verifier>' \
--data 'client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7' \
--data 'redirect_uri=https%3A%2F%2Fapp.bankofmaldives.com.mv%2Foauth%2Fmobile-callback' \
--data 'Device-ID=<device_id>' \
--data 'User-Agent=bml-mobile-banking%2F348+%28{manufacturer}%3B+Android+14%3B+{model}%29' \
--data 'x-app-version=2.1.44.348'
```
### Response
```json
{
"access_token": "eyJhbGciOiJSUzI1NiJ9...",
"refresh_token": "def50200aabbcc...",
"token_type": "Bearer",
"expires_in": 3600
}
```
| Field | Type | Description |
|---|---|---|
| `access_token` | `string` | JWT Bearer token — use in `Authorization: Bearer <token>` on all REST API calls |
| `refresh_token` | `string` | Long-lived token — use to refresh without re-login |
| `token_type` | `string` | Always `"Bearer"` |
| `expires_in` | `number` | Token lifetime in seconds (typically `3600`) |
Store `access_token`, `refresh_token`, `expires_in`, and `Device-ID` together as the session. The `access_token` expires at `now + expires_in * 1000` milliseconds.
---
## Token Refresh
When the access token expires (on `401` or `419` from any REST endpoint), obtain a new one using the refresh token. No web session or cookies are needed.
```
POST https://www.bankofmaldives.com.mv/internetbanking/oauth/token
```
### Request
**Content-Type:** `application/x-www-form-urlencoded`
| Field | Value |
|---|---|
| `grant_type` | `refresh_token` |
| `refresh_token` | Stored refresh token |
| `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` |
| `Device-ID` | Same device ID from the original login |
| `User-Agent` | App user agent string |
| `x-app-version` | `2.1.44.348` |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/token' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--data 'grant_type=refresh_token' \
--data 'refresh_token=def50200aabbcc...' \
--data 'client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7' \
--data 'Device-ID=a1b2c3d4e5f60718' \
--data 'User-Agent=bml-mobile-banking%2F348+%28{manufacturer}%3B+Android+14%3B+{model}%29' \
--data 'x-app-version=2.1.44.348'
```
### Response
Same structure as the token exchange response. The server may issue a new `refresh_token` (rotating tokens). If a new one is returned, replace the stored value; otherwise keep the original.
```json
{
"access_token": "eyJhbGciOiJSUzI1NiJ9...<new>",
"refresh_token": "def50200...<new or same>",
"token_type": "Bearer",
"expires_in": 3600
}
```
**Failure:** If `access_token` is absent or blank in the response, the refresh token has expired. Re-run the full [login flow](01-login.md).
---
## Session Storage
Persist the following to represent a saved BML session:
| Field | Description |
|---|---|
| `access_token` | Bearer token for REST API calls |
| `refresh_token` | Used to renew without re-login |
| `expires_at` | Unix timestamp (ms) when `access_token` expires |
| `device_id` | Must be sent with every refresh; ties tokens to the device |
---
&nbsp;
---
[← Business Profile OTP](02-business-otp.md) &nbsp;&nbsp;&nbsp; **Next →** [Dashboard](04-dashboard.md)

244
docs/bmlapi/04-dashboard.md Normal file
View File

@@ -0,0 +1,244 @@
# Dashboard
Fetch all accounts for the active profile: CASA (savings/current), cards (prepaid/credit/debit), and loans.
---
## Endpoint
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/dashboard
```
---
## Prerequisites
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
---
## Request
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/dashboard' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
```
---
## Response
```json
{
"success": true,
"payload": {
"dashboard": [
{
"id": "abc123def456",
"account": "7730000000001",
"account_type": "CASA",
"product": "Current Account",
"alias": "My Account",
"currency": "MVR",
"account_status": "Active",
"availableBalance": 1234.56,
"ledgerBalance": 1250.00,
"lockedAmount": 15.44
},
{
"id": "xyz789",
"account": "7730000000002",
"account_type": "CASA",
"product": "Savings Account",
"alias": "USD Savings",
"currency": "USD",
"account_status": "Active",
"availableBalance": 500.00,
"ledgerBalance": 500.00,
"lockedAmount": 0.0
},
{
"id": "card001",
"account": "4111111111111111",
"account_type": "Card",
"product": "Visa Debit",
"alias": "My Debit Card",
"currency": "MVR",
"account_status": "Active",
"prepaid_card": false,
"product_code": "VISA",
"account_visible": false,
"cardBalance": {
"AvailableLimit": 0.0,
"CurrentBalance": 0.0
}
},
{
"id": "card002",
"account": "4111111111112222",
"account_type": "Card",
"product": "Visa Prepaid",
"alias": "Prepaid",
"currency": "MVR",
"account_status": "Active",
"prepaid_card": true,
"product_code": "VISA",
"account_visible": true,
"cardBalance": {
"AvailableLimit": 200.00,
"CurrentBalance": 50.00
}
},
{
"id": "loan001",
"account": "9900000000001",
"account_type": "Loan",
"product": "Personal Finance",
"alias": "My Loan",
"currency": "MVR",
"account_status": "Active",
"availableBalance": -30000.0
}
]
}
}
```
---
## Response Fields
### Top-level
| Field | Type | Description |
|---|---|---|
| `success` | `bool` | `true` on success |
| `payload.dashboard` | `array` | List of account objects; order may vary |
### Common Account Fields
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Internal BML account ID — use this for history and loan detail calls |
| `account` | `string` | Account or card number (as displayed) |
| `account_type` | `string` | Account category — see table below |
| `product` | `string` | Product name (e.g. `"Current Account"`, `"Visa Prepaid"`) |
| `alias` | `string` | User-assigned label; may be blank |
| `currency` | `string` | ISO 4217 currency code (e.g. `"MVR"`, `"USD"`) |
| `account_status` | `string` | `"Active"` or other status strings |
### Account Types
| `account_type` | Description |
|---|---|
| `CASA` | Current Account or Savings Account |
| `Card` | Debit, credit, or prepaid card |
| `Loan` | Loan or financing account |
---
## CASA Fields
| Field | Type | Description |
|---|---|---|
| `availableBalance` | `number` | Available balance (funds available to use) |
| `ledgerBalance` | `number` | Book balance (may include pending transactions) |
| `lockedAmount` | `number` | Blocked/reserved amount |
---
## Card Fields
| Field | Type | Description |
|---|---|---|
| `prepaid_card` | `bool` | `true` for prepaid cards |
| `product_code` | `string` | Card scheme (e.g. `"VISA"`) |
| `account_visible` | `bool` | `true` for credit cards, `false` for debit cards |
| `cardBalance.AvailableLimit` | `number` | Available credit/prepaid balance |
| `cardBalance.CurrentBalance` | `number` | Current outstanding balance |
### Card Profile Types
| Condition | Type |
|---|---|
| `prepaid_card: true` | Prepaid card |
| `prepaid_card: false` AND `account_visible: true` | Credit card |
| `prepaid_card: false` AND `account_visible: false` | Debit card |
### Product Code → Card Name
The `product_code` field identifies the specific card product. Known mappings:
| `product_code` | Card name | Asset |
|---|---|---|
| `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` |
| `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` |
| `C8010`, `C8011` | Mastercard Platinum | `master_platinum` |
| `C8020`, `C8022` | Mastercard Gold | `master_gold` |
| `C8030`, `C8033` | Mastercard Business Debit | `master_business_debit` |
| `C8040`, `C8044` | Mastercard World | `master_world` |
| `C8101` | Mastercard Masveriyaa | `master_masveriyaa` |
| `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` |
| `C8901`, `C8991`, `C8980`, `C8981` | Mastercard Passport | `master_passport` |
| `C8902`, `C8907`, `C8909`, `C8912`, `C8992`, `C8996`, `C8997`, `C8982`, `C8983` | Mastercard Islamic | `master_islamic` |
| `C8905`, `C8995` | Visa Credit | `visa_credit` |
| `C8925`, `C8926` | Visa Student Blue | `visa_student_blue` |
| `C3001`, `C3011`, `C3050`, `C3051`, `C3031` | Amex Credit Green | `amex_credit_green` |
| `C3003`, `C3013`, `C3053`, `C3023`, `C3033`, `C3052` | Amex Debit Gold | `amex_debit_gold` |
| `C3005`, `C3015`, `C3055`, `C3054` | Amex Platinum | `amex_platinum` |
| `C3007`, `C3017`, `C3097`, `C3095`, `C3077`, `C3177` | Amex Debit Green | `amex_debit_green` |
| `C3009`, `C3019`, `C3029`, `C3099`, `C3088`, `C3188` | Amex Credit Gold | `amex_credit_gold` |
| `C1001`, `C1011`, `C1082`, `C1081`, `C1101`, `C1111`, `C1181`, `C1182` | Visa Debit Generic | `visa_debit_generic` |
| `C1003`, `C1013`, `C1083`, `C1084`, `C1103`, `C1113`, `C1183`, `C1184` | Visa Gold | `visa_gold` |
| `C1005`, `C1006`, `C1089` | Visa Debit Islamic | `visa_debit_islamic` |
| `C1007`, `C1027`, `C1097`, `C1107`, `C1197`, `C1077`, `C1177` | Visa Debit | `visa_debit` |
| `C1009`, `C1019`, `C1085`, `C1086`, `C1109`, `C1119`, `C1185`, `C1186` | Visa Platinum | `visa_platinum` |
| `C1017` | Visa Infinite | `visa_infinite` |
| `C1020`, `C1021` | Visa Debit Platinum | `visa_debit_platinum` |
| `C1030`, `C1090`, `C1130`, `C1033`, `C1133` | Visa Corporate | `visa_corporate` |
| `C1040`, `C1041`, `C1047`, `C1048`, `C1050`, `C1051`, `C1087`, `C1088`, `C1140`, `C1141`, `C1147`, `C1148`, `C1150`, `C1151`, `C1187`, `C1188` | Visa Student Black | `visa_student_black` |
| `C1059`, `C1062`, `C1070`, `C1072`, `C1159`, `C1162` | Mastercard Prepaid Business | `master_prepaid_business` |
| `C1061`, `C1063`, `C1071`, `C1073`, `C1161`, `C1163` | Mastercard | `master` |
| _(any other)_ | — | default card image |
---
## Loan Fields
| Field | Type | Description |
|---|---|---|
| `availableBalance` | `number` | Outstanding balance — returned as a negative number; use `abs()` for display |
For full loan details (interest rate, repayment schedule, overdue info), call [GET /api/mobile/account/{id}](05-userinfo.md#loan-detail).
---
## Error Responses
### Expired session
HTTP `401` or `419` — the access token has expired. Attempt [token refresh](03-oauth-token.md#token-refresh).
### Server error
HTTP `5xx` — BML server-side error. Retry after a delay.
---
&nbsp;
---
[← OAuth Token](03-oauth-token.md) &nbsp;&nbsp;&nbsp; **Next →** [User Info](05-userinfo.md)

141
docs/bmlapi/05-userinfo.md Normal file
View File

@@ -0,0 +1,141 @@
# User Info and Session Health
Two endpoints for checking the current session and fetching the authenticated user's personal details.
---
## Profile Health Check
A lightweight call to verify that the access token is still valid. Returns HTTP `200` on success, `401`/`419` if expired.
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/profile
```
### Request
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/profile' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
```
Use this to probe the session before making more expensive calls. On `401` or `419`, proceed to [token refresh](03-oauth-token.md#token-refresh).
---
## User Info
Fetch personal details for the currently authenticated user.
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/userinfo
```
### Request
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/userinfo' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
```
### Response
```json
{
"success": true,
"payload": {
"user": {
"fullname": "MOHAMED ALI",
"email": "user@example.com",
"mobile_phone": "9600000001",
"customer_number": "C0000001",
"idcard": "A123456",
"birthdate": "1990-01-01"
}
}
}
```
| Field | Type | Description |
|---|---|---|
| `fullname` | `string` | Full name (typically uppercase) |
| `email` | `string` | Registered email address |
| `mobile_phone` | `string` | Registered mobile number |
| `customer_number` | `string` | BML internal customer ID (e.g. `C0000001`) |
| `idcard` | `string` | National ID card number |
| `birthdate` | `string` | Date of birth (`YYYY-MM-DD`) |
### Failure
```json
{ "success": false }
```
Returns `null` payload if the account has no user info record, or `success: false` on any error.
---
## Loan Detail
Fetch extended details for a loan account. The `{id}` is the internal account ID (`id` field) from the [dashboard](04-dashboard.md) response.
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/{id}
```
### Request
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/loan001' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
```
### Response
```json
{
"success": true,
"payload": {
"loanAmount": 50000.00,
"outstandingAmt": -30000.00,
"repayAmount": 1500.00,
"intRate": 8.5,
"loanStatus": "Active",
"startDate": "2023-10-26T00:00:00+05:00",
"endDate": "2026-10-26T00:00:00+05:00",
"noOfRepayOverdue": 0,
"overdueAmount": 0.00
}
}
```
| Field | Type | Description |
|---|---|---|
| `loanAmount` | `number` | Original loan principal |
| `outstandingAmt` | `number` | Outstanding balance — returned as a **negative number**; use `abs()` for display |
| `repayAmount` | `number` | Periodic repayment amount |
| `intRate` | `number` | Interest rate (percentage) |
| `loanStatus` | `string` | Status string (e.g. `"Active"`) |
| `startDate` | `string` | Loan start date (ISO 8601) |
| `endDate` | `string` | Loan end date (ISO 8601) |
| `noOfRepayOverdue` | `number` | Number of overdue repayments |
| `overdueAmount` | `number` | Total overdue amount |
On `401`/`419` the session has expired — attempt [token refresh](03-oauth-token.md#token-refresh).
---
&nbsp;
---
[← Dashboard](04-dashboard.md) &nbsp;&nbsp;&nbsp; **Next →** [Account History](06-account-history.md)

View File

@@ -0,0 +1,194 @@
# Account Transaction History
Fetch paginated transaction history for a CASA (savings/current) account.
---
## Endpoint
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/{accountId}/history/{page}
```
| Path parameter | Description |
|---|---|
| `accountId` | Internal account ID (`id` field from [dashboard](04-dashboard.md)) |
| `page` | Page number, 1-based |
---
## Prerequisites
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
- Internal account `id` from the [Dashboard](04-dashboard.md) response (not the account number)
---
## Request
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/account/abc123def456/history/1' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
```
---
## Pagination
The API uses 1-based page numbering. The response includes `totalPages` — increment the page number until you reach or exceed it.
| Page | URL |
|---|---|
| First | `/api/mobile/account/{id}/history/1` |
| Second | `/api/mobile/account/{id}/history/2` |
| N-th | `/api/mobile/account/{id}/history/N` |
Stop when the current page number exceeds `totalPages`, or when the `history` array is empty.
---
## Response
```json
{
"success": true,
"payload": {
"totalPages": 5,
"history": [
{
"id": "TXN001",
"bookingDate": "2026-05-16",
"description": "Transfer Debit",
"narrative1": "16-05-2026 15-10-25",
"narrative2": "Mohamed Ali",
"amount": -500.00,
"currency": "MVR",
"reference": "FT20260516123456"
},
{
"id": "TXN002",
"bookingDate": "2026-05-15",
"description": "Transfer Credit",
"narrative1": "15-05-2026 10-30-00",
"narrative2": "Ahmed Hassan",
"amount": 1000.00,
"currency": "MVR",
"reference": "FT20260515103000"
},
{
"id": "TXN003",
"bookingDate": "2026-05-14",
"description": "Purchase",
"narrative1": "14-05-2026 041500",
"narrative2": "",
"amount": -75.00,
"currency": "MVR",
"reference": ""
}
]
}
}
```
---
## Response Fields
### Top-level
| Field | Type | Description |
|---|---|---|
| `success` | `bool` | `true` on success |
| `payload.totalPages` | `number` | Total number of pages |
| `payload.history` | `array` | List of transactions for this page |
### Transaction Object
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Transaction ID |
| `bookingDate` | `string` | Booking date (fallback date — prefer parsed `narrative1` where available) |
| `description` | `string` | Transaction type — see table below |
| `narrative1` | `string` | Encodes the precise timestamp; format depends on `description` |
| `narrative2` | `string` | Counterparty name (for transfers); may be blank |
| `amount` | `number` | Amount — **negative = debit, positive = credit** |
| `currency` | `string` | ISO 4217 currency code |
| `reference` | `string` | Transfer reference number; blank for non-transfer entries |
---
## Transaction Descriptions
| `description` | Meaning |
|---|---|
| `Transfer Debit` | Outgoing transfer |
| `Transfer Credit` | Incoming transfer |
| `Purchase` | Card purchase or point-of-sale transaction |
| Other | Various bank-generated transaction types |
---
## Date Parsing from `narrative1`
The `bookingDate` field is date-only. For precise timestamps, parse `narrative1`:
### Transfer Debit / Transfer Credit
Format: `DD-MM-YYYY HH-mm-ss`
```
"16-05-2026 15-10-25" → 2026-05-16 15:10:25
```
Parse with `SimpleDateFormat("dd-MM-yyyy HH-mm-ss")`.
### Purchase
Format: `DD-MM-YYYY HHmmSS` (time is first 4 digits of the numeric suffix)
```
"14-05-2026 041500" → date: 14-05-2026, time part: "0415" → 04:15
→ 2026-05-14 04:15:00
```
Parse: split on space → date part `DD-MM-YYYY`, time part take first 4 chars → `HH:mm`. Combine and parse with `SimpleDateFormat("dd-MM-yyyy HH:mm:ss")`.
### All other descriptions
Fall back to `bookingDate` as-is.
---
## Amount Sign Convention
| Sign | Meaning |
|---|---|
| Positive (`+`) | Credit — money received |
| Negative (`-`) | Debit — money spent |
---
## Error Responses
| HTTP Code | Meaning |
|---|---|
| `401` / `419` | Access token expired — attempt [token refresh](03-oauth-token.md#token-refresh) |
---
&nbsp;
---
[← User Info](05-userinfo.md) &nbsp;&nbsp;&nbsp; **Next →** [Card Statement](07-card-statement.md)

View File

@@ -0,0 +1,164 @@
# Card Statement
Fetch transaction history for a card account (prepaid, credit, or debit). Returns three sets of entries: outstanding authorisations, unbilled transactions, and billed statement entries.
---
## Endpoint
```
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/card/statement
```
---
## Prerequisites
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
- Internal card ID (`id` field from [dashboard](04-dashboard.md)) and a target month
---
## Request
**Content-Type:** `application/json`
### Body
```json
{
"card": "card001",
"month": "2026-05"
}
```
| Field | Description |
|---|---|
| `card` | Internal card ID — the `id` field from the dashboard response (not the card number) |
| `month` | Target month in `YYYY-MM` format |
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/card/statement' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' \
--header 'Content-Type: application/json' \
--data '{"card":"card001","month":"2026-05"}'
```
---
## Response
The payload contains up to three distinct sections, each may be absent or `null`:
```json
{
"success": true,
"payload": {
"outstanding": {
"CardOutStdAuthDetails": [
{
"TranApprCode": "123456",
"DateTime": "2026-05-16T15:10:25",
"TranDesc": "Online Purchase - Amazon",
"BillingAmount": 50.00,
"BillingCcy": "USD"
}
]
},
"unbilled": {
"CardUnbillTxnDetails": [
{
"TranApprCode": "789012",
"DateTime": "2026-05-15T10:30:00",
"TranDesc": "Supermarket",
"BillingAmount": 120.00,
"BillingCcy": "MVR"
}
]
},
"cardstatement": [
{
"TranRef": "STMT20260501001",
"TransDate": "2026-05-01",
"TranDate": "2026-05-01",
"TranDesc": "Monthly Fee",
"Description": "Monthly Fee",
"TranAmount": 25.00,
"TranCcy": "MVR"
}
]
}
}
```
---
## Response Sections
### `outstanding.CardOutStdAuthDetails` — Pending Authorisations
Transactions that have been authorised but not yet posted. Amounts are in the billing currency.
| Field | Type | Description |
|---|---|---|
| `TranApprCode` | `string` | Authorisation approval code |
| `DateTime` | `string` | Authorisation timestamp |
| `TranDesc` | `string` | Merchant or transaction description |
| `BillingAmount` | `number` | Amount in billing currency (positive) |
| `BillingCcy` | `string` | Billing currency code |
### `unbilled.CardUnbillTxnDetails` — Unbilled Transactions
Transactions posted to the card but not yet included in a statement cycle.
Same field structure as `CardOutStdAuthDetails`.
### `cardstatement` — Billed Statement Entries
Previously billed transactions from the statement cycle for the requested month.
| Field | Type | Description |
|---|---|---|
| `TranRef` | `string` | Statement reference |
| `TransDate` / `TranDate` | `string` | Transaction date (check both keys; `TransDate` takes priority) |
| `TranDesc` / `Description` | `string` | Description (check both keys; `TranDesc` takes priority) |
| `TranAmount` | `number` | Amount — **stored as positive, displayed as debit** (negate for sign convention) |
| `TranCcy` | `string` | Transaction currency |
> The `TranAmount` in `cardstatement` is always positive in the API response. Negate it to `TranAmount` so it follows the standard debit-negative convention.
---
## Amount Sign Convention
| Section | Sign in response | Meaning |
|---|---|---|
| `outstanding` / `unbilled` | Positive | Debit (charge to card) |
| `cardstatement` | Positive (negate on display) | Debit (charge to card) |
---
## Error Responses
| HTTP Code | Meaning |
|---|---|
| `401` / `419` | Access token expired — attempt [token refresh](03-oauth-token.md#token-refresh) |
---
&nbsp;
---
[← Account History](06-account-history.md) &nbsp;&nbsp;&nbsp; **Next →** [Transfer](08-transfer.md)

235
docs/bmlapi/08-transfer.md Normal file
View File

@@ -0,0 +1,235 @@
# Transfer
Initiate and confirm a fund transfer. The process is two-step: an initial POST triggers an OTP to the user's chosen channel; a second POST with the OTP confirms and completes the transfer.
The same endpoint (`POST /api/mobile/transfer`) handles both steps — the presence of an `otp` field distinguishes them.
---
## Prerequisites
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
- Validated destination account from [Account Validation](10-validate.md)
---
## Transfer Channels
Before initiating a transfer, fetch the OTP channels available to the user.
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer
```
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
```
### Response
```json
{
"success": true,
"payload": {
"transfer": {
"otpChannel": [
{
"channel": "token",
"description": "Authenticator App",
"masked": ""
},
{
"channel": "sms",
"description": "SMS",
"masked": "+960 9XX XXXX"
}
]
}
}
}
```
| Field | Type | Description |
|---|---|---|
| `channel` | `string` | Channel identifier — use in transfer requests |
| `description` | `string` | Human-readable channel name |
| `masked` | `string` | Partially masked destination (blank for authenticator) |
---
## Transfer Types
| `transfertype` | Description | Requires `bank` |
|---|---|---|
| `IAT` | Internal — BML account to BML account | No |
| `QTR` | Quick Transfer — via PayMV alias | No |
| `DOT` | Domestic Outside Transfer — BML to another bank (e.g. MIB) | Yes — BIC of the destination bank |
---
## Step 1 — Initiate Transfer (Trigger OTP)
```
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer
```
### Request Body
**Content-Type:** `application/json`
```json
{
"debitAccount": "7730000000001",
"creditAccount": "7730000000002",
"debitAmount": 100.00,
"transfertype": "IAT",
"currency": "MVR",
"channel": "token"
}
```
For `DOT` (outside bank) transfers, add `"bank"`:
```json
{
"debitAccount": "7730000000001",
"creditAccount": "90101000000001000",
"debitAmount": 250.00,
"transfertype": "DOT",
"currency": "MVR",
"channel": "sms",
"bank": "MIBVMVMV"
}
```
| Field | Type | Description |
|---|---|---|
| `debitAccount` | `string` | Source BML account number |
| `creditAccount` | `string` | Destination account number |
| `debitAmount` | `number` | Amount to transfer |
| `transfertype` | `string` | `IAT`, `QTR`, or `DOT` |
| `currency` | `string` | Currency code (e.g. `"MVR"`, `"USD"`) |
| `channel` | `string` | OTP channel from the channels list (e.g. `"token"`, `"sms"`) |
| `bank` | `string` | BIC of the destination bank — required for `DOT` only |
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
| `accept` | `application/json` |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' \
--header 'accept: application/json' \
--header 'Content-Type: application/json' \
--data '{"debitAccount":"7730000000001","creditAccount":"7730000000002","debitAmount":100.00,"transfertype":"IAT","currency":"MVR","channel":"token"}'
```
### Response — OTP Triggered
```json
{
"success": true,
"code": 22,
"message": "OTP sent to your authenticator app"
}
```
`success: true` AND `code: 22` together confirm that the OTP has been dispatched. Proceed to Step 2.
Any other combination means the request failed.
---
## Step 2 — Confirm Transfer (Submit OTP)
```
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer
```
Repeat the exact same body as Step 1, adding the `otp` field (and optionally `remarks`).
### Request Body
```json
{
"debitAccount": "7730000000001",
"creditAccount": "7730000000002",
"debitAmount": 100.00,
"transfertype": "IAT",
"currency": "MVR",
"channel": "token",
"otp": "123456",
"remarks": "Rent payment"
}
```
| Additional field | Type | Description |
|---|---|---|
| `otp` | `string` | OTP received via the chosen channel |
| `remarks` | `string` | Optional transfer reference/memo (omit if blank) |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/transfer' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' \
--header 'accept: application/json' \
--header 'Content-Type: application/json' \
--data '{"debitAccount":"7730000000001","creditAccount":"7730000000002","debitAmount":100.00,"transfertype":"IAT","currency":"MVR","channel":"token","otp":"123456","remarks":"Rent payment"}'
```
---
## Responses
### Success
```json
{
"success": true,
"message": "Transfer successful",
"payload": {
"reference": "FT202605160001",
"timestamp": "2026-05-16 15:10:25"
}
}
```
| Field | Type | Description |
|---|---|---|
| `success` | `bool` | `true` |
| `message` | `string` | Confirmation message |
| `payload.reference` | `string` | Transfer reference number |
| `payload.timestamp` | `string` | Completion timestamp |
### Failure
```json
{
"success": false,
"message": "Invalid OTP. Please try again."
}
```
`success: false` — the `message` field contains the reason. Common causes: wrong OTP, insufficient balance, invalid account.
---
&nbsp;
---
[← Card Statement](07-card-statement.md) &nbsp;&nbsp;&nbsp; **Next →** [Contacts](09-contacts.md)

157
docs/bmlapi/09-contacts.md Normal file
View File

@@ -0,0 +1,157 @@
# Contacts (Saved Beneficiaries)
Manage the user's saved beneficiary list: list all contacts, save a new one, and delete an existing one.
---
## List Contacts
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts
```
### Request
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
```
### Response
```json
{
"success": true,
"payload": [
{
"id": 1,
"account": "7730000000001",
"name": "Mohamed Ali",
"alias": "Ali",
"status": "S",
"currency": "MVR"
},
{
"id": 2,
"account": "90101000000001000",
"name": "Ahmed Hassan",
"alias": "Hassan",
"status": "S",
"currency": "MVR"
}
]
}
```
| Field | Type | Description |
|---|---|---|
| `id` | `number` | Internal contact ID — use for delete |
| `account` | `string` | Beneficiary account number |
| `name` | `string` | Account holder name |
| `alias` | `string` | User-assigned nickname; falls back to `name` if blank |
| `status` | `string` | Contact status (typically `"S"` for saved) |
| `currency` | `string` | Transfer currency for this contact |
Entries where `account` is blank are skipped.
---
## Save Contact
```
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts
```
### Request
**Content-Type:** `application/json`
```json
{
"contact_type": "BML",
"account": "7730000000001",
"alias": "Ali",
"currency": "MVR",
"name": "Mohamed Ali"
}
```
| Field | Type | Required | Description |
|---|---|---|---|
| `contact_type` | `string` | Yes | Contact category (e.g. `"BML"`) |
| `account` | `string` | Yes | Beneficiary account number |
| `alias` | `string` | Yes | Display nickname |
| `currency` | `string` | No | Transfer currency |
| `name` | `string` | No | Full name of the beneficiary |
| `swift` | `string` | No | SWIFT/BIC code (for international contacts) |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--data '{"contact_type":"BML","account":"7730000000001","alias":"Ali","currency":"MVR","name":"Mohamed Ali"}'
```
### Response
```json
{ "success": true }
```
`success: true` confirms the contact was saved. `success: false` on failure.
---
## Delete Contact
```
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts/{contactId}
```
BML does not support `DELETE` directly — the delete is sent as a POST with a `_method: delete` body override.
| Path parameter | Description |
|---|---|
| `contactId` | The `id` from the contacts list |
### Request
**Content-Type:** `application/json`
```json
{ "_method": "delete" }
```
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/contacts/1' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' \
--header 'accept: application/json' \
--header 'Content-Type: application/json' \
--data '{"_method":"delete"}'
```
### Response
```json
{ "success": true }
```
`success: true` confirms the contact was deleted.
---
&nbsp;
---
[← Transfer](08-transfer.md) &nbsp;&nbsp;&nbsp; **Next →** [Account Validation](10-validate.md)

175
docs/bmlapi/10-validate.md Normal file
View File

@@ -0,0 +1,175 @@
# Account Validation
Resolve and validate a destination account before initiating a transfer. Two endpoints cover different account types:
- **BML / alias accounts** — `GET /api/mobile/validate/account/{input}` — resolves BML account numbers and PayMV aliases
- **MIB accounts** — `GET /api/mobile/favara/account-verification/{account}/MIB` — resolves MIB accounts via BML's Favara interbank network
---
## Validate BML Account or Alias
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/validate/account/{input}
```
`{input}` is either a BML account number or a PayMV alias string (e.g. `"MALI"` or a phone number used as alias).
### Request
```bash
# Validate a BML account number
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/validate/account/7730000000001' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' \
--header 'Accept: application/json'
# Validate an alias
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/validate/account/MALI' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' \
--header 'Accept: application/json'
```
---
## Responses
The `validationType` in the response determines which transfer type to use.
### BML Account (`validationType: "BML"`) — use `IAT`
```json
{
"success": true,
"payload": {
"trnType": "IAT",
"validationType": "BML",
"account": "7730000000001",
"name": "Mohamed Ali",
"alias": "MALI",
"currency": "MVR"
}
}
```
| Field | Type | Description |
|---|---|---|
| `trnType` | `string` | Transfer type to use — `"IAT"` |
| `validationType` | `string` | `"BML"` |
| `account` | `string` | Resolved BML account number |
| `name` | `string` | Account holder name |
| `alias` | `string` | Alias (if any) — blank or `"null"` if none |
| `currency` | `string` | Account currency |
---
### PayMV Alias (`validationType: "alias"`) — use `QTR`
Returned when the input resolves to a PayMV alias (interbank quick transfer).
```json
{
"success": true,
"payload": {
"trnType": "QTR",
"validationType": "alias",
"CdtrAcct": {
"Acct": "7730000000001",
"FinInstnId": ""
},
"contact_name": "Mohamed Ali",
"currency": "MVR"
}
}
```
| Field | Type | Description |
|---|---|---|
| `trnType` | `string` | Transfer type to use — `"QTR"` |
| `validationType` | `string` | `"alias"` |
| `CdtrAcct.Acct` | `string` | Resolved account number |
| `CdtrAcct.FinInstnId` | `string` | BIC of the destination institution (blank for BML-to-BML) |
| `contact_name` | `string` | Account holder name |
| `currency` | `string` | Account currency |
> For `QTR` transfers, pass the **original alias input** as `creditAccount` (not the resolved account number), as the alias is what the server routes on.
---
### Failure
```json
{ "success": false }
```
Account or alias not found, or not eligible for transfer.
---
## Verify MIB Account (Favara)
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/favara/account-verification/{account}/MIB
```
Verifies a Maldives Islamic Bank (MIB) account number via BML's Favara interbank network. Use the result for a `DOT` transfer.
### Request
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/favara/account-verification/90101000000001000/MIB' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' \
--header 'Accept: application/json'
```
### Response
```json
{
"success": true,
"account": "90101000000001000",
"name": "Mohamed Ali",
"agnt": "MIBVMVMV"
}
```
| Field | Type | Description |
|---|---|---|
| `success` | `bool` | `true` if the account exists and is reachable |
| `account` | `string` | MIB account number |
| `name` | `string` | Account holder name |
| `agnt` | `string` | BIC of MIB — send as the `bank` field in the [transfer](08-transfer.md) request |
### Failure
```json
{ "success": false }
```
Account not found or not accessible via Favara.
---
## Transfer Type Summary
| `validationType` / source | `trnType` to use | `bank` required |
|---|---|---|
| `"BML"` | `IAT` | No |
| `"alias"` | `QTR` | No |
| MIB (Favara) | `DOT` | Yes — `agnt` value from verification response |
---
&nbsp;
---
[← Contacts](09-contacts.md) &nbsp;&nbsp;&nbsp; **Next →** [Foreign Limits](11-foreign-limits.md)

View File

@@ -0,0 +1,147 @@
# Foreign Transaction Limits
Fetch the user's USD foreign currency transaction limits per card, broken down by channel (ATM, ECOM, POS) and category (general, medical).
This endpoint uses a **different base URL** from the main REST API.
---
## Endpoint
```
GET https://app.bankofmaldives.com.mv/api/v2/foreign-limits
```
---
## Prerequisites
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
---
## Request
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
| `Accept` | `application/json` |
```bash
curl --request GET \
--url 'https://app.bankofmaldives.com.mv/api/v2/foreign-limits' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' \
--header 'Accept: application/json'
```
---
## Response
```json
{
"success": true,
"payload": [
{
"type": "Debit",
"used": 150.00,
"totalLimit": 1000.00,
"generalCap": 1000.00,
"generalRemaining": 850.00,
"medicalRemaining": 500.00,
"isAtmEnabled": true,
"isPosEnabled": true,
"usageByCategory": {
"ATM": {
"remaining": 350.00,
"limit": 500.00
},
"ECOM": {
"remaining": 200.00,
"limit": 300.00
},
"POS": {
"remaining": 300.00,
"limit": 500.00
}
}
},
{
"type": "Credit",
"used": 0.00,
"totalLimit": 2000.00,
"generalCap": 2000.00,
"generalRemaining": 2000.00,
"medicalRemaining": 1000.00,
"isAtmEnabled": true,
"isPosEnabled": true,
"usageByCategory": {
"ATM": { "remaining": 500.00, "limit": 500.00 },
"ECOM": { "remaining": 500.00, "limit": 500.00 },
"POS": { "remaining": 1000.00, "limit": 1000.00 }
}
}
]
}
```
---
## Response Fields
### Top-level
| Field | Type | Description |
|---|---|---|
| `success` | `bool` | `true` on success |
| `payload` | `array` | One entry per card type (e.g. Debit, Credit, Prepaid) |
### Limit Object
| Field | Type | Description |
|---|---|---|
| `type` | `string` | Card type — e.g. `"Debit"`, `"Credit"`, `"Prepaid"` |
| `used` | `number` | Total amount used this period (USD) |
| `totalLimit` | `number` | Overall foreign transaction limit (USD) |
| `generalCap` | `number` | General spending cap (USD) |
| `generalRemaining` | `number` | Remaining general limit (USD) |
| `medicalRemaining` | `number` | Remaining medical category limit (USD) |
| `isAtmEnabled` | `bool` | Whether ATM withdrawals are enabled |
| `isPosEnabled` | `bool` | Whether POS payments are enabled |
### `usageByCategory` — Channel Breakdown
| Channel | Description |
|---|---|
| `ATM` | ATM cash withdrawals |
| `ECOM` | E-commerce / online purchases |
| `POS` | Point-of-sale payments |
Each channel object:
| Field | Type | Description |
|---|---|---|
| `remaining` | `number` | Remaining limit for this channel (USD) |
| `limit` | `number` | Total limit for this channel (USD) |
---
## Error Responses
| HTTP Code | Meaning |
|---|---|
| `401` / `419` | Access token expired — attempt [token refresh](03-oauth-token.md#token-refresh) |
---
&nbsp;
---
[← Account Validation](10-validate.md)

198
docs/bmlapi/README.md Normal file
View File

@@ -0,0 +1,198 @@
# BML Internet Banking API Documentation
Reverse-engineered from traffic captures of the BML Mobile Banking Android app (`mv.com.bml.mib`).
[Play Store](https://play.google.com/store/apps/details?id=mv.com.bml.mib)
---
## Overview
Bank of Maldives (BML) uses a hybrid authentication model: a web session flow (cookie-based, Inertia.js frontend) handles login and profile selection, which then feeds into a PKCE OAuth 2.0 exchange to obtain a Bearer token for the REST API.
The login process is stateful and must be executed in order:
1. Web login (credentials + TOTP)
2. Profile activation
3. PKCE OAuth token exchange
4. Authenticated REST API calls using the Bearer token
---
## Base URLs
| Purpose | Base URL |
|---|---|
| Web login / OAuth | `https://www.bankofmaldives.com.mv/internetbanking` |
| REST API (authenticated) | `https://www.bankofmaldives.com.mv/internetbanking/api/mobile` |
| Foreign limits API | `https://app.bankofmaldives.com.mv/api/v2` |
---
## Authentication Model
| Value | How obtained | How used |
|---|---|---|
| `XSRF-TOKEN` cookie | Set by server on `GET /web/login` | Sent as `X-XSRF-TOKEN` header on all web POST requests |
| `blaze_session` cookie | Set by server during web flow | Managed automatically by cookie jar |
| `blaze_identity` cookie | Set by server after profile activation | Managed automatically; identifies the active profile |
| `access_token` | Returned by `POST /oauth/token` after PKCE exchange | Sent as `Authorization: Bearer <token>` on all REST API calls |
| `refresh_token` | Returned alongside `access_token` | Used to obtain a new `access_token` without re-login |
The web flow uses a standard browser cookie jar. The REST API only needs the Bearer token — no cookies required after the OAuth exchange.
---
## OAuth 2.0 PKCE Parameters
| Parameter | Value |
|---|---|
| `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` |
| `redirect_uri` | `https://app.bankofmaldives.com.mv/oauth/mobile-callback` |
| `response_type` | `code` |
| `code_challenge_method` | `S256` |
The `code_verifier` is a cryptographically random 72-byte value, base64url-encoded (no padding). The `code_challenge` is the SHA-256 hash of the verifier, also base64url-encoded.
The `Device-ID` is a random 8-byte hex string generated once per login session.
---
## User-Agent Strategy
Two different User-Agent strings are used depending on the phase:
| Phase | User-Agent to use |
|---|---|
| Web login steps (GET/POST `/web/*`, `/oauth/authorize`) | Browser UA |
| OAuth token endpoint (`POST /oauth/token`) | Browser UA |
| All authenticated REST API calls (`/api/mobile/*`, `/api/v2/*`) | App UA |
**Browser UA** (used during the entire web session and OAuth flow):
```
Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0
```
**App UA** (used for all REST API calls after the token is obtained):
```
bml-mobile-banking/348 (<Build.MANUFACTURER>; Android <Build.VERSION.RELEASE>; <Build.MODEL>)
```
Example app UA:
```
bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})
```
---
## Inertia.js Response Format
The BML web frontend is built with Inertia.js. All web page responses embed their data as HTML-escaped JSON in the `data-page` attribute of the root `<div>`:
```html
<div id="app" data-page="{&quot;component&quot;:&quot;Login&quot;,&quot;props&quot;:{...}}">
```
To extract: find `data-page="..."`, unescape HTML entities (`&quot;``"`, `&amp;``&`, `&#39;``'`, `&lt;``<`, `&gt;``>`), then parse as JSON. The useful data is inside the `props` key.
---
## Login Flow
```
Client Server
| |
| GET /web/login | ← seeds XSRF-TOKEN + blaze_session cookies
|------------------------------------------------>|
| Set-Cookie: XSRF-TOKEN=...; blaze_session=... |
|<------------------------------------------------|
| |
| POST /web/login | ← JSON: {username, password, code:""}
| X-XSRF-TOKEN: <xsrf> |
|------------------------------------------------>|
| 302 Redirect → /web/login/2fa |
|<------------------------------------------------|
| |
| GET /web/login/2fa | ← refreshes cookies
|------------------------------------------------>|
| Set-Cookie: XSRF-TOKEN=<new> |
|<------------------------------------------------|
| |
| POST /web/login/2fa | ← JSON: {code: <TOTP>, channel: "authenticator"}
| X-XSRF-TOKEN: <xsrf2> |
|------------------------------------------------>|
| 302 Redirect → /web/profile |
|<------------------------------------------------|
| |
| GET /web/profile | ← profile picker (multi) or auto-redirect (single)
|------------------------------------------------>|
| 200 (profile list) OR 302 (auto-activated) |
|<------------------------------------------------|
| |
| (multi-profile) GET /web/profile/{profileId} | ← activate selected profile
|------------------------------------------------>|
| 302 → /web/redirect (personal) |
| 302 → /web/profile/2fa/business (business) |
|<------------------------------------------------|
| |
| GET /oauth/authorize?...&code_challenge=... | ← PKCE authorize
|------------------------------------------------>|
| 302 → mobile-callback?code=<auth_code> |
|<------------------------------------------------|
| |
| POST /oauth/token | ← exchange auth code for tokens
| {code, code_verifier, grant_type, client_id} |
|------------------------------------------------>|
| {access_token, refresh_token, expires_in} |
|<------------------------------------------------|
| |
| GET /api/mobile/dashboard | ← authenticated REST API
| Authorization: Bearer <access_token> |
|------------------------------------------------>|
| {success: true, payload: {dashboard: [...]}} |
|<------------------------------------------------|
```
---
## Session Expiry
The access token expires after `expires_in` seconds (typically 3600). On a `401` or `419` response from any REST endpoint:
1. Attempt to refresh using the stored `refresh_token` → [Token Refresh](03-oauth-token.md#token-refresh)
2. If refresh fails, re-run the full login flow
---
## Transfer Types
| Code | Description |
|---|---|
| `IAT` | Internal Account Transfer — BML account to BML account |
| `QTR` | Quick Transfer — transfer via PayMV alias |
| `DOT` | Domestic Outside Transfer — BML to another bank (e.g. MIB) |
---
## Documents
| # | File | Description |
|---|---|---|
| 1 | [Login](01-login.md) | Web login: credentials, TOTP, profile selection |
| 2 | [Business Profile OTP](02-business-otp.md) | SMS/email OTP for business profile activation |
| 3 | [OAuth Token](03-oauth-token.md) | PKCE token exchange and token refresh |
| 4 | [Dashboard](04-dashboard.md) | Fetch all accounts (CASA, card, loan) |
| 5 | [User Info](05-userinfo.md) | User profile details and session health check |
| 6 | [Account History](06-account-history.md) | Paginated transaction history for CASA accounts |
| 7 | [Card Statement](07-card-statement.md) | Card transaction history (prepaid, credit, debit) |
| 8 | [Transfer](08-transfer.md) | Initiate and confirm fund transfers |
| 9 | [Contacts](09-contacts.md) | Saved beneficiaries — list, save, delete |
| 10 | [Account Validation](10-validate.md) | Validate BML accounts, aliases, and MIB accounts |
| 11 | [Foreign Limits](11-foreign-limits.md) | USD foreign transaction limits by card and channel |
---
&nbsp;
---
> **Next →** [Login](01-login.md)

View File

@@ -29,8 +29,8 @@ POST https://fahipay.mv/api/app/login/
| `device[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) | | `device[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) |
| `device[platform]` | `Android` | | | `device[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install | | `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install |
| `device[model]` | `22101320I` | `Build.MODEL` | | `device[model]` | `{model}` | `Build.MODEL` |
| `device[manufacturer]` | `Xiaomi` | `Build.MANUFACTURER` | | `device[manufacturer]` | `{manufacturer}` | `Build.MANUFACTURER` |
| `device[isVirtual]` | `false` | | | `device[isVirtual]` | `false` | |
| `device[serial]` | `unknown` | | | `device[serial]` | `unknown` | |
@@ -47,7 +47,7 @@ curl --request POST \
--header 'accept: application/json' \ --header 'accept: application/json' \
--header 'accept-encoding: gzip, deflate, br' \ --header 'accept-encoding: gzip, deflate, br' \
--header 'connection: keep-alive' \ --header 'connection: keep-alive' \
--header 'user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \ --header 'user-agent: Mozilla/5.0 (Linux; Android {version}; {model} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \
--form 'email=A123456' \ --form 'email=A123456' \
--form 'password=your_password' \ --form 'password=your_password' \
--form 'grant_type=auth_id' \ --form 'grant_type=auth_id' \
@@ -57,8 +57,8 @@ curl --request POST \
--form 'device[available]=true' \ --form 'device[available]=true' \
--form 'device[platform]=Android' \ --form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \ --form 'device[uuid]=a1b2c3d4e5f60718' \
--form 'device[model]=22101320I' \ --form 'device[model]={model}' \
--form 'device[manufacturer]=Xiaomi' \ --form 'device[manufacturer]={manufacturer}' \
--form 'device[isVirtual]=false' \ --form 'device[isVirtual]=false' \
--form 'device[serial]=unknown' --form 'device[serial]=unknown'
``` ```

View File

@@ -38,8 +38,8 @@ POST https://fahipay.mv/api/app/otp/
| `device[available]` | `true` | Same device fields as login — must match | | `device[available]` | `true` | Same device fields as login — must match |
| `device[platform]` | `Android` | | | `device[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request | | `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request |
| `device[model]` | `22101320I` | | | `device[model]` | `{model}` | |
| `device[manufacturer]` | `Xiaomi` | | | `device[manufacturer]` | `{manufacturer}` | |
| `device[isVirtual]` | `false` | | | `device[isVirtual]` | `false` | |
| `device[serial]` | `unknown` | | | `device[serial]` | `unknown` | |
@@ -57,7 +57,7 @@ curl --request POST \
--header 'accept: application/json' \ --header 'accept: application/json' \
--header 'accept-encoding: gzip, deflate, br' \ --header 'accept-encoding: gzip, deflate, br' \
--header 'connection: keep-alive' \ --header 'connection: keep-alive' \
--header 'user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \ --header 'user-agent: Mozilla/5.0 (Linux; Android {version}; {model} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \
--form 'code=123456' \ --form 'code=123456' \
--form 'channel=totp' \ --form 'channel=totp' \
--form 'action=login' \ --form 'action=login' \
@@ -68,8 +68,8 @@ curl --request POST \
--form 'device[available]=true' \ --form 'device[available]=true' \
--form 'device[platform]=Android' \ --form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \ --form 'device[uuid]=a1b2c3d4e5f60718' \
--form 'device[model]=22101320I' \ --form 'device[model]={model}' \
--form 'device[manufacturer]=Xiaomi' \ --form 'device[manufacturer]={manufacturer}' \
--form 'device[isVirtual]=false' \ --form 'device[isVirtual]=false' \
--form 'device[serial]=unknown' --form 'device[serial]=unknown'
``` ```

View File

@@ -1,6 +1,8 @@
# Fahipay API Documentation # Fahipay API Documentation
Reverse-engineered from traffic captures of the Fahipay Android WebView app (`fahipay.mv`). Reverse-engineered from traffic captures of the Fahipay Android WebView app (`mv.fahipay`).
[Play Store](https://play.google.com/store/apps/details?id=mv.fahipay)
--- ---
@@ -41,7 +43,7 @@ Content-Type: multipart/form-data; boundary=<boundary>
accept: application/json accept: application/json
accept-encoding: gzip, deflate, br accept-encoding: gzip, deflate, br
connection: keep-alive connection: keep-alive
user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36 user-agent: Mozilla/5.0 (Linux; Android {version}; {model} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36
``` ```
### Authenticated data endpoints ### Authenticated data endpoints
@@ -64,8 +66,8 @@ All login and OTP requests include a standard set of device fields:
| `device[available]` | `true` | Always `true` | | `device[available]` | `true` | Always `true` |
| `device[platform]` | `Android` | Always `Android` | | `device[platform]` | `Android` | Always `Android` |
| `device[uuid]` | `a1b2c3d4e5f60718` | 16 hex chars, generated once per install, persisted | | `device[uuid]` | `a1b2c3d4e5f60718` | 16 hex chars, generated once per install, persisted |
| `device[model]` | `22101320I` | Device model string | | `device[model]` | `{model}` | Device model string |
| `device[manufacturer]` | `Xiaomi` | Device manufacturer | | `device[manufacturer]` | `{manufacturer}` | Device manufacturer |
| `device[isVirtual]` | `false` | Always `false` | | `device[isVirtual]` | `false` | Always `false` |
| `device[serial]` | `unknown` | Always `unknown` | | `device[serial]` | `unknown` | Always `unknown` |

View File

@@ -0,0 +1,234 @@
# Encryption, Key Exchange & Nonce
All traffic to the encrypted API (`faisanet.mib.com.mv`) uses Blowfish encryption. This document covers the cipher, the DH key exchange that derives the session key, and the nonce algorithm required by every request.
---
## Cipher
- **Algorithm**: Blowfish, ECB mode, PKCS5 padding
- **Input**: raw UTF-8 bytes of the JSON payload string
- **Key**: raw UTF-8 bytes of the key string
- **Output**: base64-encoded ciphertext (URL-encoded when sent as form data)
```python
from Crypto.Cipher import Blowfish
from Crypto.Util.Padding import pad, unpad
import base64, json
from urllib.parse import quote
def encrypt(payload: dict, key: str) -> str:
plaintext = json.dumps(payload, separators=(',', ':')).encode('utf-8')
key_bytes = key.encode('latin-1')
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
ct = cipher.encrypt(pad(plaintext, Blowfish.block_size))
return base64.b64encode(ct).decode()
def decrypt(ciphertext_b64: str, key: str) -> dict:
key_bytes = key.encode('latin-1')
ct = base64.b64decode(ciphertext_b64)
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
plaintext = unpad(cipher.decrypt(ct), Blowfish.block_size)
return json.loads(plaintext.decode('utf-8'))
def build_request_body(payload: dict, key: str, extra_fields: dict = {}) -> str:
sfunc = payload.get('sfunc', '')
encrypted = encrypt(payload, key)
body = '&'.join(f"{k}={v}" for k, v in extra_fields.items())
if body:
return f"{body}&sfunc={sfunc}&data={quote(encrypted)}"
return f"sfunc={sfunc}&data={quote(encrypted)}"
```
---
## Request / Response Transport
**Request** — form field `data`:
```
sfunc=<value>&data=<url-encoded base64 Blowfish ciphertext>
```
For `sfunc=n` calls, `xxid` must be the **first** field:
```
xxid=<session_xxid>&sfunc=n&data=<encrypted>
```
For `sfunc=i` calls, `key2` is a separate unencrypted field:
```
key2=<key2>&sfunc=i&data=<encrypted>
```
**Response** — body is raw base64 Blowfish ciphertext (no form encoding); base64-decode then decrypt directly.
---
## Keys
| Key | Value | Used for |
|---|---|---|
| `DEFAULT_KEY` | `8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678` | `sfunc=r` key exchange request/response |
| Session key | DH-derived (44-char base64, 32 bytes) | All subsequent requests |
---
## Diffie-Hellman Session Key Derivation
The session key is derived via a custom DH exchange. All three parameters are hardcoded in the app — this provides no real security since the private key `A` never rotates.
> **Note**: The variable names in the app source are **swapped** from their DH role. `A_VALUE` in source is the exponent (shorter number); `P_VALUE` is the prime modulus (longer number).
```
G (generator) = 2
A (client privkey) = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
P (prime modulus) = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
```
### Steps
1. Client sends `cmod = G^A mod P` (as decimal string) in the `sfunc=r` request
2. Server responds with `smod` (its DH public key, as decimal string)
3. Client computes shared secret: `shared = smod^A mod P`
4. Client SHA-256 hashes `str(shared)` → uppercase hex
5. Client converts the hex string to raw bytes, then base64-encodes → session key
```python
import hashlib, base64
def derive_session_key(smod: int) -> str:
A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
shared = pow(smod, A, P)
sha256_hex = hashlib.sha256(str(shared).encode()).hexdigest().upper()
return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
```
The result is always a **44-character base64 string** (32 bytes). It changes every session.
---
## Nonce Computation
Every request after key exchange includes a `nonce` field. It is computed from the `nonceGenerator` string returned by the key exchange response.
### `nonceGenerator` format
A string of 4 groups separated by `-`. Each group contains 8 space-separated tokens. Each token is a letter followed by a number (e.g. `M85`, `A37`, `C95`, `X2`).
```
M85 A87 A82 M82 M60 M31 A46 C95-M14 X83 A37 X2 C4 X22 X46 C95-M57 X29 C51 C34 S91 X60 S1 A15-M54 A89 S13 S18 C81 A70 X92 X59
```
### Nonce output format
4 groups separated by `-`. Each group: a zero-padded 5-digit seed followed by 7 two-digit numbers separated by spaces.
```
08160 19 73 45 17 89 07 10-00924 64 73 18 08 48 80 67-01026 20 17 13 26 26 43 24-00648 12 32 17 69 14 63 92
```
### Algorithm
**Phase 1 — seed values (one per group):**
For each of the 4 groups:
1. Extract the number from `token[0]` (e.g. `M85``85`)
2. Generate random `r = floor(random() * 99) + 1` (range 199 inclusive)
3. `product = N * r` → zero-pad to 5 digits
4. `digitSum = sum of digits of padded`
5. `lastTwo = int(padded[-2:])` (last two digits)
6. Accumulate `cumSum += digitSum`
**Phase 2 — nonce digits (tokens 17 of each group):**
For each group, start with `carry = lastTwo[i]`:
| op letter | Formula |
|---|---|
| `M` | `(carry % num) + digitSum + cumSum` |
| `A` | `carry + num + digitSum + cumSum` |
| `S` | `(carry * carry) + num + digitSum + cumSum` |
| `X` | `(carry * num) + digitSum + cumSum` |
| `C` | `(carry * carry * carry) + num + digitSum + cumSum` |
Nonce digit = last two digits of the result. Update `carry = nonceDigit` for the next token.
**Output**: join padded seed + 7 two-digit nonce digits per group, join 4 groups with `-`.
### Python implementation
```python
import math, random
def generate_nonce(nonce_generator: str) -> str:
groups = nonce_generator.split('-')
padded_list, last_two, digit_sum = [], [], []
cum_sum = 0
for group in groups:
tokens = group.split(' ')
n = int(''.join(c for c in tokens[0] if c.isdigit()))
r = math.floor(random.random() * 99) + 1
product = n * r
padded = str(product).zfill(5)
ds = sum(int(d) for d in padded)
lt = int(padded[-2:])
padded_list.append(padded)
last_two.append(lt)
digit_sum.append(ds)
cum_sum += ds
result_groups = []
for i, group in enumerate(groups):
tokens = group.split(' ')
carry = last_two[i]
ds = digit_sum[i]
nonce_digits = []
for token in tokens[1:]:
op = ''.join(c for c in token if c.isalpha())
num = int(''.join(c for c in token if c.isdigit()))
if op == 'M': val = (carry % num) + ds + cum_sum
elif op == 'A': val = carry + num + ds + cum_sum
elif op == 'S': val = (carry * carry) + num + ds + cum_sum
elif op == 'X': val = (carry * num) + ds + cum_sum
elif op == 'C': val = (carry * carry * carry) + num + ds + cum_sum
else: val = 0
digit = int(str(val)[-2:])
nonce_digits.append(digit)
carry = digit
group_str = padded_list[i] + ' ' + ' '.join(str(d).zfill(2) for d in nonce_digits)
result_groups.append(group_str)
return '-'.join(result_groups)
```
### Notes
- `nonce` and `sodium` are separate fields. `sodium` is an independent random integer (~2324 bit range).
- The `nonceGenerator` is returned once by the key exchange response and reused for the entire session.
---
## Known `sfunc` / `routePath` Values
| `sfunc` | `routePath` | Description |
|---|---|---|
| `r` | `S40` | Initial DH key exchange (DEFAULT_KEY) |
| `i` | `S40` | Authenticated DH key exchange (key1/key2) |
| `n` | `A44` | Get auth type / `userSalt` |
| `n` | `A41` | Regular login initialization |
| `n` | `A42` | OTP verification (regular login) |
| `n` | `C41` | Device registration initialization |
| `n` | `C42` | OTP verification (registration) |
| `n` | `P41` | Get profile image (by hash) |
| `n` | `P40` | Update profile image |
| `n` | `P42` | Delete profile image |
| `n` | `P47` | Select profile / fetch account balances |
---
&nbsp;
---
**Next →** [Login Flow](02-login.md)

346
docs/mibapi/02-login.md Normal file
View File

@@ -0,0 +1,346 @@
# Login Flow
MIB uses a two-phase authentication model:
| Phase | Trigger | Key used |
|---|---|---|
| **Device Registration** | First time this device+account pair is seen | `DEFAULT_KEY` → DH session key |
| **Regular Login** | Every subsequent login (stored `key1`/`key2`) | `key1` → DH session key |
---
## Password Hashing (`pgf03`)
The password is never sent in plaintext. Required by both `C41` (registration) and `A41` (login).
```
pgf03 = SHA256( clientSalt + SHA256( userSalt + SHA256( password ) ) )
```
All SHA-256 values are uppercase hex strings. `clientSalt` is a fresh random 32-character alphanumeric string each time.
```python
import hashlib
def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
h1 = hashlib.sha256(password.encode()).hexdigest().upper()
h2 = hashlib.sha256((user_salt + h1).encode()).hexdigest().upper()
return hashlib.sha256((client_salt + h2).encode()).hexdigest().upper()
```
---
## Device Registration Flow (first time only)
```
[0] sfunc=r DEFAULT_KEY → DH exchange → derive session_key_1, get xxid + nonceGenerator
[1] sfunc=n A44 → get userSalt
[2] sfunc=n C41 → submit credentials → returns key1, key2 (persist!)
[3] sfunc=n C42 → verify OTP
[48] regular login (below) using the key1/key2 just received
```
---
## Regular Login Flow
```
[0] sfunc=i key1 → DH exchange → derive session_key_2, get xxid + nonceGenerator
[1] sfunc=n A44 → get userSalt
[2] sfunc=n A41 → submit credentials → returns otpTypes, email, uuid
[3] sfunc=n P41 → fetch profile image (optional)
[4] sfunc=n A42 → verify OTP → session established
[5] sfunc=n P47 → select profile → returns accountBalance array
```
---
## Step-by-Step Reference
### [0] Initial Key Exchange — `sfunc=r`
**Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678`
**Request** (outer + inner are encrypted together):
```json
{
"sfunc": "r",
"data": {
"cmod": "<G^A mod P as decimal string>",
"appId": "IOS17.2-<15 random alphanumeric chars>",
"routePath": "S40",
"sodium": "<random 20-bit int as string>",
"xxid": "<random 40-bit int as string>"
}
}
```
**Response** (decrypted with `DEFAULT_KEY`):
```json
{
"success": true,
"reasonCode": "201",
"reasonText": "Key generated successfully.",
"smod": "<server DH public key as decimal string>",
"nonceGenerator": "<instruction string>",
"xxid": "<session token — use for all subsequent calls>",
"sodium": "<server random>",
"encMethod": 2
}
```
After: `session_key = derive_session_key(int(smod))`. Save `xxid` and `nonceGenerator`.
---
### [1] Get Auth Type — `sfunc=n`, `routePath: A44`
**Key**: session key
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"uname": "<username>",
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random>",
"routePath": "A44",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"data": [{ "loginType": "1", "userSalt": "<server salt>" }]
}
```
Use `userSalt` in `pgf03` computation.
---
### [2a] Device Registration Init — `sfunc=n`, `routePath: C41`
_First-time only._
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"uname": "<username>",
"pgf03": "<computed>",
"clientSalt": "<random 32-char string>",
"nonce": "<nonce>",
"appId": "<appId>",
"sodium": "<random>",
"routePath": "C41",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"reasonCode": "201",
"primaryOTPType": "3",
"otpTypes": [2, 3],
"fullName": "<user full name>",
"customerImgHash": "<hash>"
}
```
---
### [3a] OTP Verification (Registration) — `sfunc=n`, `routePath: C42`
_First-time only. Receive and persist `key1`/`key2`._
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"otp": "<6-digit OTP>",
"uname": "<username>",
"otpType": "3",
"nonce": "<nonce>",
"appId": "<appId>",
"sodium": "<random>",
"routePath": "C42",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"reasonCode": "101",
"data": [{
"key1": "<store securely — Blowfish key for next sfunc=i>",
"key2": "<store securely — sent plaintext in sfunc=i wrapper>",
"appId": "<appId>",
"encryptionMethod": "2"
}]
}
```
`key1` and `key2` are long-lived device credentials. Store them securely on the device.
---
### [0b] Authenticated Key Exchange — `sfunc=i`
_Regular login. `key2` is a separate unencrypted outer field._
**Key**: `key1`
Form body: `key2=<key2>&sfunc=i&data=<encrypted payload>`
**Encrypted payload**:
```json
{
"sfunc": "i",
"key2": "<key2>",
"data": {
"cmod": "<G^A mod P>",
"appId": "<appId>",
"routePath": "S40",
"sodium": "<random 20-bit int>",
"xxid": "<random 40-bit int>"
}
}
```
**Response** (decrypted with `key1`):
```json
{
"success": true,
"smod": "<new server DH public key>",
"nonceGenerator": "<new instruction string>",
"xxid": "<new session token>",
"encMethod": 2
}
```
After: derive new session key, replace `xxid` and `nonceGenerator`.
---
### [2b] Regular Login Init — `sfunc=n`, `routePath: A41`
**Key**: session key
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"uname": "<username>",
"pgf03": "<computed>",
"clientSalt": "<random 32-char>",
"pmodTime": 0,
"requireBankData": 1,
"nonce": "<nonce>",
"appId": "<appId>",
"sodium": "<random>",
"routePath": "A41",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"reasonCode": "104",
"primaryOTPType": "3",
"otpTypes": [2, 3],
"email": "<masked email>",
"uuid": "<uuid1>",
"uuid2": "<uuid2>"
}
```
---
### [3b] Get Profile Image — `sfunc=n`, `routePath: P41`
Optional. Fetch the user's avatar to display on the OTP screen.
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"imageHash": "<customerImgHash from C41/A41>",
"nonce": "<nonce>",
"appId": "<appId>",
"sodium": "<random>",
"routePath": "P41",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"reasonCode": "201",
"profileImage": "<base64-encoded JPEG>"
}
```
---
### [4b] OTP Verification (Login) — `sfunc=n`, `routePath: A42`
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"otp": "<6-digit OTP>",
"uname": "<username>",
"otpType": "3",
"nonce": "<nonce>",
"appId": "<appId>",
"sodium": "<random>",
"routePath": "A42",
"xxid": "<session xxid>"
}
}
```
After successful `A42`, the `xxid` and `nonceGenerator` from the `sfunc=i` response become the WebView session cookies. See [README](README.md) for the cookie format.
---
### [5] Select Profile — `sfunc=n`, `routePath: P47`
See [03-accounts.md](03-accounts.md) for the full P47 reference.
---
&nbsp;
---
[← Encryption](01-encryption.md) &nbsp;&nbsp;&nbsp; **Next →** [Accounts](03-accounts.md)

137
docs/mibapi/03-accounts.md Normal file
View File

@@ -0,0 +1,137 @@
# Accounts & Balances
Account numbers and balances are returned by the **Select Profile** call (`routePath: P47`). The login init call (`A41`) returns an empty `accountBalance` array — balances are only available after `P47`.
---
## Select Profile — `sfunc=n`, `routePath: P47`
**Key**: session key (from `sfunc=i` response)
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"profileType": "<profileType from A41 operatingProfiles>",
"profileId": "<profileId from A41 operatingProfiles>",
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "P47",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"reasonCode": "101",
"reasonText": "Profile Selected!",
"landingPage": "0",
"accountBalance": [ ... ],
"accessRights": { ... },
"services": []
}
```
To switch between profiles (personal ↔ business), call `P47` again with the other profile's `profileId` and `profileType`.
---
## Profiles (from `A41` response)
The `A41` login init response includes `operatingProfiles`:
```json
{
"operatingProfiles": [
{
"profileId": "<profile ID>",
"customerProfileId": "<customer profile ID>",
"annexId": "<annex ID>",
"customerId": "<customer ID>",
"name": "<display name>",
"cifType": "Individual",
"customerImage": "<image hash>",
"profileType": "0",
"color": "<hex color>"
}
]
}
```
| `profileType` | Meaning |
|---|---|
| `"0"` | Individual (personal) |
| `"1"` | Sole Proprietor (business) |
---
## `accountBalance` Array
Each element represents one account:
```json
{
"cif": "<CIF number>",
"accountNumber": "<full account number>",
"accountBriefName": "<short label, e.g. 'SAR MVR - Savings'>",
"template": "<display template ID>",
"currencyCode": "<ISO 4217 numeric>",
"currencyName": "<ISO 4217 alpha>",
"accountTypeName": "<account type label>",
"transfer": "Y",
"branchName": "<branch name>",
"availableBalance": "<decimal string>",
"currentBalance": "<decimal string>",
"blockedAmount": "<decimal string, may be negative>",
"settlementBalance": "<decimal string>",
"mvrBalance": "<MVR equivalent>",
"statusDesc": "Active"
}
```
| Field | Description |
|---|---|
| `accountNumber` | Full account number |
| `accountBriefName` | Human-readable account label |
| `currencyCode` | ISO 4217 numeric (e.g. `"462"` = MVR, `"840"` = USD) |
| `currencyName` | ISO 4217 alpha (e.g. `"MVR"`, `"USD"`) |
| `accountTypeName` | Account type (e.g. `"Saving Account"`) |
| `availableBalance` | Spendable balance (decimal string) |
| `currentBalance` | Ledger balance (decimal string) |
| `blockedAmount` | Held/blocked funds — negative means funds are held |
| `settlementBalance` | Balance including pending settlements |
| `mvrBalance` | All balances converted to MVR for unified display |
| `transfer` | `"Y"` if usable as transfer source |
| `statusDesc` | Account status (e.g. `"Active"`) |
| `cif` | Customer Information File number |
| `template` | UI template ID |
> All balance fields are **decimal strings**, not numbers — parse with `Decimal` for precision.
---
## `accessRights`
```json
{
"numAccounts": "<number of accounts>",
"packageRights": "[1,2,3,4,6,7,8,9,10,11,12]",
"roleRights": "[]"
}
```
`packageRights` is a JSON array encoded as a string — parse it separately.
---
&nbsp;
---
[← Login Flow](02-login.md) &nbsp;&nbsp;&nbsp; **Next →** [Transaction History](04-history.md)

107
docs/mibapi/04-history.md Normal file
View File

@@ -0,0 +1,107 @@
# Transaction History
Fetch paginated transaction history for a single MIB account. Served from the WebView subdomain.
---
## Endpoint
```
POST https://faisamobilex-wv.mib.com.mv/ajaxAccounts/trxHistory
```
---
## Authentication
See [README](README.md) for cookie and AJAX header format.
```
Referer: https://faisamobilex-wv.mib.com.mv//accountDetails?trxh=1&dashurl=1&accountNo=<accountNo>
```
---
## Request Body (form-urlencoded)
| Field | Example | Description |
|---|---|---|
| `accountNo` | `90101000000001000` | Account number to fetch history for |
| `trxNo` | `` | Transaction number filter (empty = all) |
| `trxType` | `0` | Transaction type filter (`0` = all) |
| `sortTrx` | `date` | Sort field |
| `sortDir` | `desc` | Sort direction |
| `fromDate` | `` | From date filter (empty = no filter) |
| `toDate` | `` | To date filter (empty = no filter) |
| `start` | `1` | Start record index (1-based) |
| `end` | `20` | End record index (`start + pageSize - 1`) |
| `includeCount` | `1` | Include `total_count` in response |
### Pagination
Page size is 20. Compute `start`/`end` per page:
| Page | `start` | `end` |
|---|---|---|
| 1 | `1` | `20` |
| 2 | `21` | `40` |
| N | `(N-1)*20 + 1` | `N*20` |
Stop when total fetched equals `total_count` or `data` is empty.
---
## Response
```json
{
"success": true,
"total_count": "87",
"data": [
{
"trxNumber": "TXN20260516001",
"trxDate": "2026-05-16",
"descr1": "Transfer Debit",
"baseAmount": "-500.00",
"curCodeDesc": "MVR",
"benefName": "Mohamed Ali",
"trxNumber2": "FT20260516001"
},
{
"trxNumber": "TXN20260515001",
"trxDate": "2026-05-15",
"descr1": "Transfer Credit",
"baseAmount": "1000.00",
"curCodeDesc": "MVR",
"benefName": "",
"trxNumber2": ""
}
]
}
```
| Field | Type | Description |
|---|---|---|
| `success` | `bool` | `true` on success |
| `total_count` | `string` | Total transaction count — parse to `int` |
| `data` | `array` | Transactions for this page |
### Transaction Object
| Field | Type | Description |
|---|---|---|
| `trxNumber` | `string` | Unique transaction ID |
| `trxDate` | `string` | Transaction date (`YYYY-MM-DD`) |
| `descr1` | `string` | Transaction description — trim whitespace |
| `baseAmount` | `string` | Decimal string — **negative = debit, positive = credit** |
| `curCodeDesc` | `string` | Currency code (e.g. `"MVR"`, `"USD"`) |
| `benefName` | `string` | Counterparty name — blank or literal `"null"` means none |
| `trxNumber2` | `string` | Secondary reference; may be blank |
---
&nbsp;
---
[← Accounts](03-accounts.md) &nbsp;&nbsp;&nbsp; **Next →** [Cards](05-cards.md)

86
docs/mibapi/05-cards.md Normal file
View File

@@ -0,0 +1,86 @@
# Cards
Fetch debit card information for the authenticated session.
---
## Endpoint
```
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/fetchCardInfos
```
---
## Authentication
See [README](README.md) for cookie and AJAX header format.
```
Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
```
---
## Request Body (form-urlencoded)
| Field | Value | Description |
|---|---|---|
| `name` | `` | Card name filter (empty = all) |
| `start` | `1` | Start index (1-based) |
| `end` | `50` | End index |
| `includeCount` | `1` | Include total count |
---
## Response
```json
{
"success": true,
"data": [
{
"cardId": "CARD001",
"maskedCardNumber": "4111 **** **** 1234",
"cardStatus": "A",
"cardType": "D",
"cardTypeDesc": "Debit Card",
"customerId": "C000001",
"phoneNumber": "9600000001",
"cardHolderName": "MOHAMED ALI"
}
]
}
```
| Field | Type | Description |
|---|---|---|
| `success` | `bool` | `true` on success |
| `data` | `array` | List of card objects |
### Card Object
| Field | Type | Description |
|---|---|---|
| `cardId` | `string` | Internal card identifier |
| `maskedCardNumber` | `string` | Partially masked card number for display |
| `cardStatus` | `string` | Card status (`A` = Active) |
| `cardType` | `string` | Card type code (e.g. `D` = Debit) |
| `cardTypeDesc` | `string` | Human-readable card type (e.g. `"Debit Card"`) |
| `customerId` | `string` | Customer ID |
| `phoneNumber` | `string` | Registered phone number |
| `cardHolderName` | `string` | Name on card |
### Failure
```json
{ "success": false }
```
---
&nbsp;
---
[← Transaction History](04-history.md) &nbsp;&nbsp;&nbsp; **Next →** [Financing](06-financing.md)

106
docs/mibapi/06-financing.md Normal file
View File

@@ -0,0 +1,106 @@
# Financing
Fetch active financing deals. Unlike other data endpoints, this returns an HTML page — deal data is embedded in `data-*` attributes on card elements.
---
## Endpoint
```
GET https://faisamobilex-wv.mib.com.mv/financing?dashurl=1
```
---
## Authentication
See [README](README.md) for cookie format.
### Additional Header
| Header | Value |
|---|---|
| `X-Requested-With` | `mv.com.mib.faisamobilex` |
Note: this endpoint uses the app package name as `X-Requested-With`, not `XMLHttpRequest`.
---
## Response
**Content-Type:** `text/html; charset=UTF-8`
Each financing deal is a `<div>` with class `finance-card-holder` and all fields as `data-*` attributes:
```html
<div class="card border finance-card-holder"
data-productDesc = "Product Name"
data-dealStatus = "P"
data-statusDesc = "Approved"
data-dealAmount = "10000.00"
data-dealNo = "12345"
data-paidAmount = "2500.00"
data-outstandingAmount = "7500.00"
data-dealDate = "2024-01-15 00:00:00"
data-overdueAmount = "0"
data-installmentAmount = "500.00"
data-noOfInstallments = "24"
data-lastPaidDate = "2026-05-01 00:00:00"
data-lastPayAmount = "500.00"
data-financeCurrency = "462"
data-curCodeDesc = "MVR">
```
### Data Fields
| Attribute | 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 numeric code (`462` = MVR) |
| `curCodeDesc` | String | Currency abbreviation (e.g. `"MVR"`) |
### Parsing
```kotlin
val cardPattern = Regex("""finance-card-holder[^>]+>""")
val attrPattern = Regex("""data-(\w+)\s*=\s*"([^"]*)"""")
```
Find all `finance-card-holder` elements, then extract `data-*` key/value pairs from each match.
---
## Completion Date Estimation
```
remainingInstallments = ceil(outstandingAmount / installmentAmount)
completionDate = today + remainingInstallments months
```
---
## Notes
- No encryption — session maintained purely via cookies.
- The response is gzip/brotli compressed; OkHttp handles decompression automatically.
- `time-tracker=597` appears static — omitting it may affect behavior.
---
&nbsp;
---
[← Cards](05-cards.md) &nbsp;&nbsp;&nbsp; **Next →** [Personal Profile](07-profile.md)

92
docs/mibapi/07-profile.md Normal file
View File

@@ -0,0 +1,92 @@
# Personal Profile
Fetch the user's personal profile details. This endpoint returns an HTML page; data is extracted via HTML scraping.
---
## Endpoint
```
GET https://faisamobilex-wv.mib.com.mv/personalProfile
```
---
## Authentication
Session cookies only — no additional AJAX headers required.
```
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
```
---
## Response
**Content-Type:** `text/html; charset=UTF-8`
The page contains an `<h5>` with the user's full name and `<span>` elements with labelled fields.
### Parsing Strategy
**Full name** — extracted from:
```html
<h5 class="mb-1 text-dark fw-semibold">Mohamed Ali</h5>
```
Regex:
```kotlin
Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
```
**Labelled fields** — each follows this pattern:
```html
<span ...><b ...>Username:</b ...>...<span ...>myusername</span>
```
Regex (used for each label):
```kotlin
Regex(
"""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE)
)
```
---
## Extracted Fields
| Label in HTML | Field | Description |
|---|---|---|
| `Username:` | `username` | Login username |
| `Email:` | `email` | Registered email address |
| `Mobile no:` | `mobile` | Registered mobile number |
| `Enrolled:` | `enrolled` | Enrollment date or status |
Combined with the `fullName` from the `<h5>`:
```kotlin
data class MibPersonalProfile(
val fullName: String,
val username: String,
val email: String,
val mobile: String,
val enrolled: String
)
```
---
## Notes
- Returns `null` if the response cannot be parsed (network error or unexpected HTML structure).
- This endpoint does not have a JSON equivalent — scraping is the only method.
---
&nbsp;
---
[← Financing](06-financing.md) &nbsp;&nbsp;&nbsp; **Next →** [Transfer](08-transfer.md)

208
docs/mibapi/08-transfer.md Normal file
View File

@@ -0,0 +1,208 @@
# Account Lookup & Transfer
Two-step process: look up the recipient to validate the account and get the holder name, then execute the transfer.
All endpoints are on the WebView subdomain. See [README](README.md) for cookie and AJAX header format.
```
Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick
```
---
## Step 1 — Account Lookup
The lookup endpoint depends on the format of the input:
| 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` |
| Contains `@` (email) | `AjaxAlias/getAlias` | `aliasName` |
---
### 1a. IPS Account — BML / local bank (13 digits, starts with `7`)
```
POST https://faisamobilex-wv.mib.com.mv/AjaxAlias/getIPSAccount
```
Body: `benefAccount=7700000000000`
**Response**:
```json
{
"success": true,
"responseCode": "2",
"accountName": "ACCOUNT HOLDER NAME",
"bankBic": "MALBMVMV"
}
```
- `accountName` — account holder name
- `bankBic` — bank BIC (e.g. `MALBMVMV` for BML)
- Account number is the input itself — not returned in response
Use `bankNo=3` and `transferLocal` for the transfer.
---
### 1b. MIB Internal Account (17 digits, starts with `9`)
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getAccountName
```
Body: `accountNo=90100000000000000`
**Response**:
```json
{
"success": true,
"accountName": "ACCOUNT HOLDER NAME"
}
```
- `accountName` may be at root level or inside a `data` object — check both
- Bank is always MIB (`MADVMVMV`)
Use `bankNo=2` and `transferInternal` for the transfer.
---
### 1c. Favara Alias — shortcodes, A-IDs, emails
```
POST https://faisamobilex-wv.mib.com.mv/AjaxAlias/getAlias
```
Body: `aliasName=<alias>`
**Response**:
```json
{
"success": true,
"responseCode": "2",
"data": {
"BfyNm": "Account Holder Name",
"CdtrAcct": {
"Acct": "90100000000000000",
"FinInstnId": "MADVMVMV"
}
}
}
```
- `BfyNm` — beneficiary name (trim whitespace)
- `CdtrAcct.Acct` — resolved account number to use for the transfer
- `CdtrAcct.FinInstnId` — bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML)
Use `bankNo=2` (MIB) or `3` (BML/local) depending on `FinInstnId`, and the matching transfer endpoint.
---
### Lookup Errors
All three lookup endpoints return `success: false` with a human-readable `reasonText` on failure:
```json
{
"success": false,
"reasonText": "Account not found"
}
```
Always show `reasonText` directly to the user.
---
## Step 2 — Execute Transfer
Two endpoints depending on the destination bank:
| `bankNo` | Endpoint | Destination |
|---|---|---|
| `2` | `ajaxTransfer/transferInternal` | MIB internal account |
| `3` | `ajaxTransfer/transferLocal` | BML or other local bank |
```
POST https://faisamobilex-wv.mib.com.mv/ajaxTransfer/transferInternal
POST https://faisamobilex-wv.mib.com.mv/ajaxTransfer/transferLocal
```
### Request Body (form-urlencoded)
| Field | Description |
|---|---|
| `benefName` | Recipient name (from lookup) |
| `benefNo` | `0` (not a saved contact) |
| `fromAccountNo` | Source account number |
| `benefAccountNo` | Destination account number |
| `transferCy` | Currency numeric code (`"462"` = MVR, `"840"` = USD) |
| `benefCurrencyCode` | Same as `transferCy` |
| `amount` | Amount as string (e.g. `"100.00"`) |
| `bankNo` | `2` = MIB internal, `3` = local/BML |
| `purpose` | Transfer purpose; send `"-"` if blank |
| `otp` | OTP from the user |
| `otpType` | `"3"` (SMS/authenticator OTP) |
### Response — Success
```json
{
"success": true,
"data": [
{
"trxId": "TRX20260516001",
"date": "2026-05-16 15:10:25"
}
]
}
```
| Field | Description |
|---|---|
| `trxId` | Transaction ID |
| `date` | Completion timestamp |
### Response — Failure
```json
{
"success": false,
"reasonText": "Insufficient balance"
}
```
`reasonText` contains the error reason. On HTTP `419`, the session has expired — re-login required.
---
## Transfer Type Summary
| Recipient | Lookup endpoint | `bankNo` | Transfer endpoint |
|---|---|---|---|
| MIB (17-digit `9…`) | `getAccountName` | `2` | `transferInternal` |
| BML (13-digit `7…`) | `getIPSAccount` | `3` | `transferLocal` |
| Favara alias → MIB | `getAlias` | `2` | `transferInternal` |
| Favara alias → BML | `getAlias` | `3` | `transferLocal` |
---
## Currency Codes
| `transferCy` | Currency |
|---|---|
| `"462"` | MVR (Maldivian Rufiyaa) |
| `"840"` | USD |
---
&nbsp;
---
[← Personal Profile](07-profile.md) &nbsp;&nbsp;&nbsp; **Next →** [Contacts](09-contacts.md)

229
docs/mibapi/09-contacts.md Normal file
View File

@@ -0,0 +1,229 @@
# Contacts (Beneficiaries)
Manage the user's saved beneficiary list. All endpoints use WebView session auth — see [README](README.md).
```
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary?dashurl=1
```
---
## List Categories
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getCategories
```
Empty POST body.
### Response
```json
{
"success": true,
"data": [
{ "id": "100001", "categoryName": "Myself", "numBenef": "2" },
{ "id": "100002", "categoryName": "Friends", "numBenef": "10" },
{ "id": "100003", "categoryName": "Business", "numBenef": "8" },
{ "id": "100004", "categoryName": "Family", "numBenef": "5" }
]
}
```
| Field | Description |
|---|---|
| `id` | Category ID — use as `searchCategoryId` when filtering contacts |
| `categoryName` | Display name |
| `numBenef` | Number of beneficiaries in this category (string) |
---
## List Contacts
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
```
### Request Body (form-urlencoded)
| Field | Example | Description |
|---|---|---|
| `page` | `1` | Page number (1-based) |
| `search` | `` | Search query (empty = all) |
| `searchCategoryId` | `0` | Category filter (`0` = all) |
| `benefType` | `A` | `A`=All, `L`=Local, `I`=Internal, `S`=Swift |
| `sortBenef` | `name` | Sort field |
| `sortDir` | `asc` | Sort direction |
| `start` | `1` | Start record index (1-based) |
| `end` | `100` | End record index |
| `includeCount` | `1` | Include `total_count` |
### Beneficiary Types
| `benefType` | Meaning |
|---|---|
| `I` | Internal (MIB to MIB) |
| `L` | Local (other Maldivian banks, e.g. BML) |
| `S` | Swift (international) |
### Response
```json
{
"success": true,
"total_count": "48",
"data": [
{
"benefNo": "100001",
"benefName": "Person Name",
"benefNickName": "Nickname",
"benefAccount": "7700000000001",
"benefType": "L",
"bankColor": "#AC0000",
"benefBankName": "Bank of Maldives PLC",
"bankCode": "BML",
"benefSwiftCode": "MALBMVMV",
"benefStatus": "A",
"benefBankId": "3",
"transferCy": "462",
"transferCyDesc": "MVR",
"customerImgHash": "abcd1234hash",
"benefCategoryID": "100002"
}
]
}
```
| Field | Description |
|---|---|
| `benefNo` | Unique beneficiary ID — use for delete |
| `benefNickName` | User-assigned nickname (prefer over `benefName` for display) |
| `benefType` | `L`, `I`, or `S` |
| `bankColor` | Hex color for placeholder avatar background |
| `customerImgHash` | Hash for fetching profile photo (`null` if no photo) |
| `benefCategoryID` | Category ID — `"0"` means uncategorized |
| `transferCyDesc` | Currency (e.g. `"MVR"`, `"USD"`) |
---
## Get Profile Image
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getProfileImage
```
Body: `imageHash=<customerImgHash>`
### Response
```json
{
"success": true,
"profileImage": "<base64-encoded JPEG>"
}
```
`profileImage` is raw base64 JPEG with no data URI prefix. Decode with `Base64.decode(value, Base64.DEFAULT)` then `BitmapFactory.decodeByteArray(...)`. Cache decoded bitmaps — the same hash may appear across multiple contacts.
---
## Create Contact
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/createLocalBeneficiary
```
```
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary/createNew
```
### Request Body (form-urlencoded)
| Field | Description |
|---|---|
| `benefType` | `"I"` = MIB internal, `"L"` = local/BML |
| `benefAccount` | Beneficiary account number |
| `benefName` | Full name |
| `nickName` | Display nickname |
| `bankNo` | `2` = MIB, `3` = BML/local |
| `transferCy` | Currency numeric code (`"462"` = MVR) |
| `categoryId` | Category ID (`"0"` = uncategorized) |
| `imageSet` | `"1"` if image provided, `"0"` otherwise |
| `image` | Base64-encoded image (empty string if none) |
| `benefIban` | Leave blank |
| `benefAddress` | Leave blank |
| `benefCity` | Leave blank |
| `benefCountry` | `"4"` |
| `benefBankSwift` | Leave blank |
| `benefBankName` | Leave blank |
| `benefBankBranch` | Leave blank |
| `benefBankAddress` | Leave blank |
| `benefBankCity` | Leave blank |
| `benefBankCountry` | `"4"` |
| `intBankSwift` | Leave blank |
| `intBankName` | Leave blank |
| `intBankAddress` | Leave blank |
| `intBankBranch` | Leave blank |
| `intBankCity` | Leave blank |
| `intBankCountry` | `"4"` |
| `transferCySwift` | `"840"` (USD numeric — static) |
| `email` | Leave blank |
| `contactNumber` | Leave blank |
| `website` | Leave blank |
### Response
```json
{ "success": true }
```
`success: true` confirms the contact was saved.
---
## Delete Contact
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/deleteBeneficiary
```
Body: `benefNo=<benefNo>`
### Response
```json
{ "success": true }
```
---
## Contact Stats
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getStats
```
Empty POST body.
### Response
```json
{
"success": true,
"data": [
{ "type": "L", "count": "30" },
{ "type": "I", "count": "10" },
{ "type": "S", "count": "2" }
]
}
```
Gives counts per beneficiary type. Useful for showing tab badges.
---
&nbsp;
---
[← Transfer](08-transfer.md)

View File

@@ -1,172 +0,0 @@
# MIB Faisanet — List Accounts & Balances
Account numbers and balances are returned by the **Select Profile** call (`routePath: P47`).
The login initialization call (`A41`) returns an empty `accountBalance` array until a profile is selected.
---
## Flow to Get Account Balances
```
[0] sfunc=i (key1) → DH key exchange → derive session_key
[1] sfunc=n A44 → get userSalt
[2] sfunc=n A41 → login with password → returns operatingProfiles (no balances yet)
[3] sfunc=n A42 → OTP verify
[4] sfunc=n P47 → select profile → returns accountBalance array
```
Steps 03 are the standard login flow (see `LOGIN_FLOW.md`). Step 4 is the new call.
---
## Step 1 — Get Profile List from A41 Response
The `A41` login initialization response includes `operatingProfiles` — the list of
profiles available to the user (personal, business, etc.).
**Relevant fields from A41 response:**
```json
{
"defaultProfile": "2",
"operatingProfiles": [
{
"profileId": "<profile ID>",
"customerProfileId": "<customer profile ID>",
"annexId": "<annex ID>",
"customerId": "<customer ID>",
"name": "<profile display name>",
"cifType": "Individual",
"customerImage": "<image hash>",
"profileType": "0",
"color": "<hex color>"
},
{
"profileId": "<profile ID>",
"customerProfileId": "<customer profile ID>",
"annexId": "<annex ID>",
"customerId": "<customer ID>",
"name": "<business name / owner name>",
"cifType": "Sole Propr",
"customerImage": "<image hash>",
"profileType": "1",
"color": "<hex color>"
}
],
"selectedProfileId": null,
"selectedProfileType": null,
"profileSelected": false
}
```
`profileType` values observed:
| Value | Meaning |
|---|---|
| `"0"` | Individual (personal) |
| `"1"` | Sole Proprietor (business) |
---
## Step 2 — Select Profile (`sfunc=n`, `routePath: P47`)
**Key**: session key (derived from `sfunc=i` response)
**Request:**
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"profileType": "<profileType from operatingProfiles>",
"profileId": "<profileId from operatingProfiles>",
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "P47",
"xxid": "<session xxid>"
}
}
```
**Response:**
```json
{
"success": true,
"reasonCode": "101",
"reasonText": "Profile Selected!",
"landingPage": "0",
"accountBalance": [ ... ],
"accessRights": { ... },
"services": []
}
```
---
## accountBalance Array
Each element in `accountBalance` represents one account:
```json
{
"cif": "<CIF number>",
"accountNumber": "<full account number>",
"accountBriefName": "<short label, e.g. 'SAR MVR - Savings'>",
"template": "<display template ID>",
"currencyCode": "<ISO 4217 numeric code>",
"currencyName": "<ISO 4217 alpha code>",
"accountTypeName": "<account type label>",
"transfer": "Y",
"branchName": "<branch name>",
"availableBalance": "<decimal string>",
"currentBalance": "<decimal string>",
"blockedAmount": "<decimal string, may be negative>",
"settlementBalance": "<decimal string>",
"mvrBalance": "<MVR equivalent as decimal string>",
"statusDesc": "Active"
}
```
### Field reference
| Field | Description |
|---|---|
| `accountNumber` | Full account number |
| `accountBriefName` | Human-readable account label |
| `currencyCode` | ISO 4217 numeric (e.g. `"462"` = MVR, `"840"` = USD) |
| `currencyName` | ISO 4217 alpha (e.g. `"MVR"`, `"USD"`) |
| `accountTypeName` | Account type (e.g. `"Saving Account"`) |
| `availableBalance` | Spendable balance |
| `currentBalance` | Ledger balance |
| `blockedAmount` | Held/blocked funds (negative means funds are held) |
| `settlementBalance` | Balance including pending settlements |
| `mvrBalance` | All balances converted to MVR for display |
| `transfer` | `"Y"` if account can be used as transfer source |
| `statusDesc` | Account status (e.g. `"Active"`) |
| `cif` | Customer Information File number |
| `template` | UI template ID (controls how card is rendered in-app) |
---
## accessRights
Also returned in the P47 response, describes what the selected profile can do:
```json
{
"numAccounts": "<number of accounts>",
"packageRights": "[1,2,3,4,6,7,8,9,10,11,12,...]",
"roleRights": "[]"
}
```
`packageRights` is a JSON array encoded as a string — parse it separately.
---
## Notes
- `accountBalance` in the `A41` response is always `[]`. Balances are only returned after `P47`.
- To switch between profiles (personal ↔ business), call `P47` again with the other profile's `profileId` and `profileType`.
- `mvrBalance` is always in MVR regardless of the account's native currency, useful for showing a unified total.
- All balance fields are **decimal strings**, not numbers — parse with `Decimal` for precision.

View File

@@ -1,345 +0,0 @@
# Faisanet MIB API Documentation
Reverse-engineered from `mv.com.mib.faisamobilex` (React Native, Hermes bytecode v96).
---
## Base
- **URL**: `https://faisanet.mib.com.mv/faisamobilex_smvc/`
- **Method**: `POST /`
- **Content-Type**: `application/x-www-form-urlencoded; charset=utf-8`
- **User-Agent**: `android/1.0`
- **Accept**: `application/json`
All requests share the same form body structure:
```
sfunc=<function_code>&data=<urlencode(blowfish_ecb_base64_ciphertext)>
```
---
## Encryption
### Algorithm
- **Cipher**: Blowfish, ECB mode, PKCS5 padding
- **Input**: raw UTF-8 bytes of JSON payload string
- **Key**: raw UTF-8 bytes of key string
- **Output**: base64-encoded ciphertext (URL-encoded when sent as form data)
### Python equivalent
```python
from Crypto.Cipher import Blowfish
from Crypto.Util.Padding import pad, unpad
import base64
def encrypt(payload: dict, key: str) -> str:
import json
plaintext = json.dumps(payload).encode()
key_bytes = key.encode('latin-1')
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
ct = cipher.encrypt(pad(plaintext, Blowfish.block_size))
return base64.b64encode(ct).decode()
def decrypt(ciphertext_b64: str, key: str) -> dict:
import json
key_bytes = key.encode('latin-1')
ct = base64.b64decode(ciphertext_b64)
cipher = Blowfish.new(key_bytes, Blowfish.MODE_ECB)
plaintext = unpad(cipher.decrypt(ct), Blowfish.block_size)
return json.loads(plaintext.decode())
```
### Key lifecycle
| Phase | Key used |
|---|---|
| `sfunc=r` (key exchange) | `DEFAULT_KEY` (hardcoded in app) |
| All subsequent requests | DH-derived session key |
**DEFAULT_KEY** (hardcoded):
```
8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678
```
---
## Diffie-Hellman Key Exchange
The app uses a **custom Diffie-Hellman** scheme to derive a session key.
### Fixed parameters (hardcoded in app)
> Note: the variable names in the app's source are swapped from their DH role.
> `A_VALUE` in the source is the **exponent** (shorter number), `P_VALUE` is the **prime modulus** (longer number).
```
G (generator) = 2
A (client privkey) = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
P (prime modulus) = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
```
> **Note**: `A` (client private key) is hardcoded in the app — this DH provides no real security.
### Session key derivation
```python
import hashlib, base64
def derive_session_key(smod: int) -> str:
A = 1563516802667282387226490351799736881442299778484610378722158765594241028592123324764949712696577
P = 2410312426921032588552076022197566074856950548502459942654116941958108831682612228890093858261341614673227141477904012196503648957050582631942730706805009223062734745341073406696246014589361659774041027169249453200378729434170325843778659198143763193776859869524088940195577346119843545301547043747207749969763750084308926339295559968882457872412993810129130294592999947926365264059284647209730384947211681434464714438488520940127459844288859336526896320919633919
shared_secret = pow(smod, A, P)
sha256_hex = hashlib.sha256(str(shared_secret).encode()).hexdigest().upper()
return base64.b64encode(bytes.fromhex(sha256_hex)).decode()
```
The resulting session key is always a **44-character base64 string** (32 bytes / 256-bit SHA-256 output), for example:
```
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=
```
It changes every session because `smod` is different each time.
---
## Endpoints (sfunc values)
### `r` — Key Exchange (initiate session)
**Request payload** (encrypted with DEFAULT_KEY):
```json
{
"sfunc": "r",
"data": {
"cmod": "<G^A mod P as decimal string>",
"appId": "IOS17.2-<random 15-char string>",
"routePath": "S40",
"sodium": "<random 20-bit integer as string>",
"xxid": "<random 40-bit integer as string>"
}
}
```
**Response payload** (encrypted with DEFAULT_KEY):
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "201",
"reasonText": "Key generated successfully.",
"smod": "<server DH public key as decimal string>",
"nonceGenerator": "<instruction string for nonce computation>",
"xxid": "<session token>",
"sodium": "<server random>",
"encMethod": 1
}
```
After this call:
- Compute `encryptionKey = derive_session_key(int(smod))`
- Store `xxid` and `nonceGenerator` for subsequent calls
---
## Request envelope structure
All requests after key exchange use this structure:
```json
{
"sfunc": "<function_code>",
"xxid": "<session xxid>",
"data": {
"nonce": "<computed from nonceGenerator>",
"appId": "<same appId>",
"sodium": "<random 20-bit>",
"routePath": "<route constant>",
"xxid": "<session xxid>",
...additional fields...
}
}
```
Encrypted with the DH-derived `encryptionKey`.
---
## Login Flows
### First-time device registration (no stored key1/key2)
1. `sfunc=r``S40` — DH key exchange with `DEFAULT_KEY` → receive `xxid`, `nonceGenerator`, `smod` → derive session key
2. `sfunc=n``A44` — get `userSalt` for username
3. `sfunc=n``C41` — submit `pgf03` (computed from password + userSalt + random clientSalt)
4. `sfunc=n``C42` — verify TOTP OTP → receive `key1` and `key2`; persist them
5. Continue with regular login below (using the just-received key1/key2)
### Regular login (stored key1/key2 present)
1. `sfunc=i``S40` — DH key exchange with `key1`, sending `key2` as extra form field → derive session key
2. `sfunc=n``A44` — get `userSalt` for username
3. `sfunc=n``A41` — submit `pgf03` → receive `operatingProfiles` list
4. For each profile: `sfunc=n``P47` — fetch `accountBalance` array
> **No A42 step in regular login.** OTP is only verified once during first-time registration (C42).
### pgf03 formula
```python
h1 = SHA256(password).hexdigest().upper()
h2 = SHA256(h1 + userSalt).hexdigest().upper()
pgf03 = SHA256(clientSalt + h2).hexdigest().upper()
```
`clientSalt` is a random 32-character alphanumeric string generated fresh each login.
---
## Known route paths
| sfunc | routePath | Description |
|---|---|---|
| `r` | `S40` | DH key exchange (first-time registration) |
| `i` | `S40` | DH key exchange (regular login, sends `key1`/`key2`) |
| `n` | `A44` | Get auth type — returns `userSalt` for the given `uname` |
| `n` | `C41` | Registration: submit credentials (`uname`, `pgf03`, `clientSalt`) |
| `n` | `C42` | Registration: verify OTP (`otp`, `uname`, `otpType=3`) — returns `key1`/`key2` |
| `n` | `A41` | Login: submit credentials (`uname`, `pgf03`, `clientSalt`, `pmodTime`, `requireBankData`) — returns `operatingProfiles` |
| `n` | `P47` | Fetch account balances for a profile (`profileType`, `profileId`) — returns `accountBalance` array |
| `n` | `P40` | Update profile image |
| `n` | `P42` | Delete profile image |
> Note: `A42` (login OTP verify) is **not sent** during regular login. It was present in an older flow but is no longer used. `C42` is only sent during first-time device registration.
---
## Nonce Computation
Every request after key exchange includes a `nonce` field computed from the `nonceGenerator`
string returned by the key exchange response.
### nonceGenerator format
A string of 4 groups separated by `-`. Each group contains 8 space-separated tokens.
Each token is a letter followed by a number (e.g. `M85`, `A37`, `C95`, `X2`).
```
M85 A87 A82 M82 M60 M31 A46 C95-M14 X83 A37 X2 C4 X22 X46 C95-M57 X29 C51 C34 S91 X60 S1 A15-M54 A89 S13 S18 C81 A70 X92 X59
```
### Nonce output format
4 groups separated by `-`. Each group: a zero-padded 5-digit number followed by 7 two-digit
numbers separated by spaces.
```
08160 19 73 45 17 89 07 10-00924 64 73 18 08 48 80 67-01026 20 17 13 26 26 43 24-00648 12 32 17 69 14 63 92
```
### Algorithm
**Phase 1 — process first token of each group (produces seed values):**
For each of the 4 groups (index `i`):
1. Take `token[0]` (e.g. `M85`). Extract the number: `N = parseInt(token.replace(/\D/g, ''))`.
2. Generate a random integer: `r = floor(random() * 99) + 1` (range 199 inclusive).
3. Compute `product = N * r`. Zero-pad to 5 digits: `padded = product.toString().padStart(5, '0')`.
4. Compute `digitSum[i]` = sum of all digits in `padded`.
5. Store `lastTwo[i]` = `parseInt(padded.slice(-2))` (last two digits as integer).
6. Accumulate `cumSum += digitSum[i]`.
After all 4 groups: `cumSum` = sum of all four `digitSum` values.
**Phase 2 — process tokens 17 of each group (produces nonce digits):**
For each group (index `i`), process `token[1]` through `token[7]`:
- Initialise `carry = lastTwo[i]`.
- For each token at position `j` (17):
- Extract letter `op` and number `num`.
- Compute `val` based on `op`:
| op | formula |
|---|---|
| `M` | `(carry % num) + digitSum[i] + cumSum` |
| `A` | `carry + num + digitSum[i] + cumSum` |
| `S` | `(carry * carry) + num + digitSum[i] + cumSum` |
| `X` | `(carry * num) + digitSum[i] + cumSum` |
| `C` | `(carry * carry * carry) + num + digitSum[i] + cumSum` |
- Nonce digit = `parseInt(val.toString().slice(-2))` (last two digits as integer).
- Update `carry = nonceDigit` for the next token.
**Assembling the nonce string:**
For each group `i`:
```
group_str = padded[i] + " " + nonceDigit[i][0].toString().padStart(2,'0') + " " + ... (7 digits)
```
Join 4 groups with `-`.
### Python implementation
```python
import math, random
def generate_nonce(nonce_generator: str) -> str:
groups = nonce_generator.split('-')
padded_list, last_two, digit_sum = [], [], []
cum_sum = 0
# Phase 1
for group in groups:
tokens = group.split(' ')
n = int(''.join(c for c in tokens[0] if c.isdigit()))
r = math.floor(random.random() * 99) + 1
product = n * r
padded = str(product).zfill(5)
ds = sum(int(d) for d in padded)
lt = int(padded[-2:])
padded_list.append(padded)
last_two.append(lt)
digit_sum.append(ds)
cum_sum += ds
# Phase 2
result_groups = []
for i, group in enumerate(groups):
tokens = group.split(' ')
carry = last_two[i]
ds = digit_sum[i]
nonce_digits = []
for token in tokens[1:]:
op = ''.join(c for c in token if c.isalpha())
num = int(''.join(c for c in token if c.isdigit()))
if op == 'M':
val = (carry % num) + ds + cum_sum
elif op == 'A':
val = carry + num + ds + cum_sum
elif op == 'S':
val = (carry * carry) + num + ds + cum_sum
elif op == 'X':
val = (carry * num) + ds + cum_sum
elif op == 'C':
val = (carry * carry * carry) + num + ds + cum_sum
else:
val = 0
digit = int(str(val)[-2:])
nonce_digits.append(digit)
carry = digit
group_str = padded_list[i] + ' ' + ' '.join(str(d).zfill(2) for d in nonce_digits)
result_groups.append(group_str)
return '-'.join(result_groups)
```
### Notes
- `nonce` and `sodium` are **separate** request fields. `sodium` is an independent random integer
(observed range ~1M16M, approximately 2324 bit).
- The nonce string is the same value for both the `nonce` and ... actually they are different fields:
`nonce` = the computed nonce string; `sodium` = a random integer sent as a plain string.
- For `sfunc=i`, `key2` is sent as a **separate form field** (not inside the encrypted payload):
`key2=<key2>&sfunc=i&data=<encrypted>`. The encrypted payload is the inner data object only,
encrypted with `key1`.
- For all `sfunc=n` requests (every request after key exchange), `xxid` is sent as a **separate
unencrypted form field** as the FIRST field:
`xxid=<session_xxid>&sfunc=n&data=<encrypted>`. The `xxid` also appears inside the encrypted
payload. Field order matters — `xxid` must come before `sfunc` and `data`.

Some files were not shown because too many files have changed in this diff Show More