Compare commits

...
19 Commits
Author SHA1 Message Date
shihaam 2764c6e452 update mib api docs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-10-23 05:38:33 +05:00
shihaam 740030e061 release 1.0.36
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 4m16s
2026-10-23 05:30:02 +05:00
shihaam 6b9ac5532b MIB now fetch users info (idcard, phone num, email) to display in settings
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-23 05:27:09 +05:00
shihaam 46edefe4d7 MIB v2 history, cards, contacts, transfers, contact edit 2026-10-23 05:15:43 +05:00
shihaam 870d3eb0f6 MIB v2 login works
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-23 03:09:42 +05:00
shihaam 7ccd8aa4f5 untrack claude 2026-10-23 03:09:26 +05:00
shihaam 05ff5a829a upgrade mib plan
Auto Tag on Version Change / check-version (push) Successful in 8s
2026-10-23 02:26:53 +05:00
shihaam d4805219f4 new version number because fayaz broke build
Auto Tag on Version Change / check-version (push) Successful in 5s
Build and Release APK / build (push) Successful in 3m34s
2026-10-03 01:50:27 +05:00
shihaam 4c680ad22c remove unused transfer_select_service translation 2026-10-03 01:49:59 +05:00
shihaam 494a42c1a8 build on versionname change
Auto Tag on Version Change / check-version (push) Successful in 7s
Build and Release APK / build (push) Failing after 3m3s
2026-10-03 01:43:25 +05:00
shihaam 9ace8dd724 release v1.0.34
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-03 01:40:43 +05:00
shihaam a3449167db fix build issue applyfahipaycontact 2026-10-03 01:39:19 +05:00
shihaam 1612e21471 Revert "release v1.0.34"
This reverts commit f02b05b180.
2026-10-03 01:37:51 +05:00
shihaam f02b05b180 release v1.0.34
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Failing after 1m27s
2026-10-03 01:33:22 +05:00
shihaam 5abd981096 save trsnaction type to history for reload, raastas and bill pay to autoselct card
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-10-03 01:32:39 +05:00
shihaam bb76f4e591 ooredoo billpay via bml card 2026-10-03 01:27:29 +05:00
shihaam 8795d5f758 ooredoo raastas via bml card
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-10-03 01:08:33 +05:00
quillfires 0d4f0074c7 feat: add Dhivehi localization
Auto Tag on Version Change / check-version (push) Successful in 3s
Add a complete Dhivehi translation for the app's user-facing strings.

- Add the `values-b+dv/strings.xml` resource
- Localize onboarding, login, security, navigation, dashboard, transfers, contacts, financing, cards, settings, and related UI
- Adapt wording naturally for Dhivehi rather than using literal English translations
- Preserve existing string keys, format arguments, and Android resource structure
2026-10-03 01:05:40 +05:00
shihaam 237d25af67 add dhiraagu bill pay
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-10-03 00:26:00 +05:00
63 changed files with 2388 additions and 1187 deletions
+3 -12
View File
@@ -24,20 +24,11 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
BEFORE="${{ github.event.before }}" if git tag -l | grep -q "^v${VERSION}$"; then
if [ -z "$BEFORE" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then echo "Tag v${VERSION} already exists, skipping"
BEFORE="HEAD~1"
fi
PREV_VERSION_CODE=$(git show "${BEFORE}:app/build.gradle.kts" 2>/dev/null | grep 'versionCode = ' | sed 's/.*versionCode = \([0-9]*\).*/\1/')
if [ "$VERSION_CODE" = "$PREV_VERSION_CODE" ]; then
echo "versionCode unchanged (${VERSION_CODE}), skipping"
echo "should_release=false" >> $GITHUB_OUTPUT echo "should_release=false" >> $GITHUB_OUTPUT
elif git tag -l | grep -q "^v${VERSION}$"; then
echo "versionCode changed (${PREV_VERSION_CODE} -> ${VERSION_CODE}) but tag v${VERSION} already exists; bump versionName"
exit 1
else else
echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})" echo "New version detected: v${VERSION}"
echo "should_release=true" >> $GITHUB_OUTPUT echo "should_release=true" >> $GITHUB_OUTPUT
fi fi
+4
View File
@@ -1,3 +1,4 @@
.envrc
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
@@ -18,6 +19,9 @@ docs/bmlapi/tmp
docs/fahipayapi/tmp docs/fahipayapi/tmp
docs/mfaisaapi/tmp docs/mfaisaapi/tmp
docs/dhiraaguapi/tmp docs/dhiraaguapi/tmp
docs/ooredooapi/tmp
docs/ooredooapi/tmp
tmp tmp
app/key.jks app/key.jks
.kotlin/* .kotlin/*
.claude/*
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 34 versionCode = 36
versionName = "1.0.33" versionName = "1.0.36"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -34,10 +34,12 @@ import android.util.Base64
* 4. The 3-D Secure challenge on BML's Wibmo ACS: the render page auto-posts the `creq`, we pick * 4. The 3-D Secure challenge on BML's Wibmo ACS: the render page auto-posts the `creq`, we pick
* the "Authenticator" channel and submit the BML token's TOTP. The ACS then auto-posts the * the "Authenticator" channel and submit the BML token's TOTP. The ACS then auto-posts the
* result to the Mastercard gateway, which posts it back to BML's `mpgsNotification`. * result to the Mastercard gateway, which posts it back to BML's `mpgsNotification`.
* 5. Poll next-action until TRANSACTION_CONFIRMED. * 5. Poll next-action until TRANSACTION_CONFIRMED. A decline after 3-D Secure (e.g. insufficient
* funds) doesn't show up there: it's a new `paymentErrorHistory` entry on the transaction,
* checked alongside.
* 6. Return to the merchant: `GET <txn>?wait=1` redirects to the merchant's `redirectUrl` with a * 6. Return to the merchant: `GET <txn>?wait=1` redirects to the merchant's `redirectUrl` with a
* signed `state=CONFIRMED` — the browser's last hop, and how some merchants (Dhiraagu) learn * signed `state=CONFIRMED` — the browser's last hop, and how merchants (Dhiraagu, Ooredoo)
* they were paid. Without it the card is charged but the merchant never delivers. * learn they were paid. Without it the card is charged but the merchant never delivers.
* *
* Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS * Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS
* session cookies. * session cookies.
@@ -90,7 +92,10 @@ class BmlMerchantCardPayClient {
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments") val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
val txnId = page.transactionId val txnId = page.transactionId
runCatching { BmlMerchantTxnClient().announceBrowser(txnId) } val txnClient = BmlMerchantTxnClient()
val browserId = runCatching { txnClient.announceBrowser(txnId) }.getOrNull()
// Earlier attempts' declines are already in the history; only newer ones are ours
val priorErrors = browserId?.let { id -> runCatching { txnClient.paymentErrors(txnId, id).size }.getOrNull() }
// 1-2. Credentials, then tokenise the card with Pomelo // 1-2. Credentials, then tokenise the card with Pomelo
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk) val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
@@ -154,6 +159,11 @@ class BmlMerchantCardPayClient {
"TRANSACTION_CONFIRMED" -> return confirmed(txnId) "TRANSACTION_CONFIRMED" -> return confirmed(txnId)
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment") "TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
} }
if (browserId != null && priorErrors != null) {
runCatching { txnClient.paymentErrors(txnId, browserId) }.getOrNull()
?.drop(priorErrors)?.lastOrNull()
?.let { return Result.Failure(it.message.ifBlank { "The bank declined the payment" }) }
}
Thread.sleep(POLL_MS / 2) Thread.sleep(POLL_MS / 2)
} }
return Result.Failure("Payment status unknown — check with the merchant before retrying") return Result.Failure("Payment status unknown — check with the merchant before retrying")
@@ -165,25 +175,37 @@ class BmlMerchantCardPayClient {
/** /**
* What the browser does once the payment page sees the confirmation: loads `<txn>?wait=1`, * What the browser does once the payment page sees the confirmation: loads `<txn>?wait=1`,
* which 302s to the merchant's `redirectUrl` (`…?transactionId=<id>&state=CONFIRMED&signature=…`) * which 302s to the merchant's `redirectUrl` (`…?transactionId=<id>&state=CONFIRMED&signature=…`)
* and on to its receipt page. Follows the redirects and retries a couple of times; true when * and on to its receipt page. Some merchants' callback page instead auto-submits a form on
* the chain ended on a 2xx page. * load (Ooredoo's posts the result on to its own site), so those forms are submitted too.
* Retries a couple of times; true when the chain ended on a 2xx page.
*/ */
private fun returnToMerchant(txnId: String): Boolean { private fun returnToMerchant(txnId: String): Boolean {
repeat(RETURN_ATTEMPTS) { attempt -> repeat(RETURN_ATTEMPTS) { attempt ->
if (attempt > 0) Thread.sleep(RETURN_RETRY_MS) if (attempt > 0) Thread.sleep(RETURN_RETRY_MS)
val ok = runCatching { val ok = runCatching {
client.newCall(Request.Builder() var request = browserNav(Request.Builder().url("$PAGE_ORIGIN/$txnId?wait=1"))
.url("$PAGE_ORIGIN/$txnId?wait=1") for (hop in 0..MAX_AUTO_SUBMITS) {
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") val (code, html, url) = client.newCall(request).execute().use {
.header("Accept-Language", "en-US,en;q=0.9") Triple(it.code, it.body?.string().orEmpty(), it.request.url.toString())
.build() }
).execute().use { it.isSuccessful } if (code !in 200..299) return@runCatching false
// A page that only exists to post itself onward, like the ACS's own hops
if (hop == MAX_AUTO_SUBMITS || !AUTO_SUBMIT.containsMatchIn(html)) return@runCatching true
val form = AcsForm.parse(html, url) ?: return@runCatching true
request = browserNav(form.toRequest().newBuilder())
}
true
}.getOrDefault(false) }.getOrDefault(false)
if (ok) return true if (ok) return true
} }
return false return false
} }
private fun browserNav(builder: Request.Builder): Request = builder
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "en-US,en;q=0.9")
.build()
/** Drives the ACS challenge. Returns null on success, or a Failure to stop the payment. */ /** Drives the ACS challenge. Returns null on success, or a Failure to stop the payment. */
private fun runThreeDs(threeDsUrl: String, otp: (Boolean) -> String): Result? { private fun runThreeDs(threeDsUrl: String, otp: (Boolean) -> String): Result? {
// render-tds: an auto-submitting form (with an explicit action) that posts the creq to the // render-tds: an auto-submitting form (with an explicit action) that posts the creq to the
@@ -206,6 +228,8 @@ class BmlMerchantCardPayClient {
} }
// OTP entry. Submit the token code; if it expired, ask for a fresh one once and retry. // OTP entry. Submit the token code; if it expired, ask for a fresh one once and retry.
// A rejected code comes back as the OTP page again with "The OTP code you entered is
// incorrect Please try again."
var retry = false var retry = false
for (attempt in 0..1) { for (attempt in 0..1) {
form = AcsForm.parse(html, acsUrl) ?: break form = AcsForm.parse(html, acsUrl) ?: break
@@ -213,9 +237,10 @@ class BmlMerchantCardPayClient {
form.fields["otpValue"] = otp(retry) form.fields["otpValue"] = otp(retry)
form.fields["formReqType"] = "SUBMIT" form.fields["formReqType"] = "SUBMIT"
html = execText(form.toRequest()) html = execText(form.toRequest())
if (!html.contains("incorrect", true) && !html.contains("expired", true)) break if (!otpRejected(html)) break
retry = true retry = true
} }
if (otpRejected(html)) return Result.Failure("The bank rejected the BML token code. Check the phone's clock and try again.")
// On success the ACS returns an auto-posting form to the gateway; follow it (and the // On success the ACS returns an auto-posting form to the gateway; follow it (and the
// gateway's own auto-post back to BML) so the verdict is recorded before we poll. // gateway's own auto-post back to BML) so the verdict is recorded before we poll.
repeat(3) { repeat(3) {
@@ -226,6 +251,9 @@ class BmlMerchantCardPayClient {
return null return null
} }
private fun otpRejected(html: String) = html.contains("name=\"otpValue\"") &&
(html.contains("incorrect", true) || html.contains("expired", true))
// ── next-action helpers ────────────────────────────────────────────────── // ── next-action helpers ──────────────────────────────────────────────────
private fun nextAction(pk: String, body: JSONObject): JSONObject = private fun nextAction(pk: String, body: JSONObject): JSONObject =
@@ -329,5 +357,9 @@ class BmlMerchantCardPayClient {
private const val MAX_POLLS = 10 private const val MAX_POLLS = 10
private const val RETURN_ATTEMPTS = 3 private const val RETURN_ATTEMPTS = 3
private const val RETURN_RETRY_MS = 2_000L private const val RETURN_RETRY_MS = 2_000L
/** Auto-submitting merchant pages followed on the way back; Ooredoo has one. */
private const val MAX_AUTO_SUBMITS = 2
/** `<body onload="document.forms['x'].submit()">` and the like. */
private val AUTO_SUBMIT = Regex("""onload\s*=\s*("[^"]*|'[^']*)\.submit\(\)""", RegexOption.IGNORE_CASE)
} }
} }
@@ -113,12 +113,36 @@ class BmlMerchantTxnClient {
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR") return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
} }
/** The PATCHes the page sends on load: register this "browser" and clear any FX selection. */ /**
fun announceBrowser(transactionId: String) { * The PATCHes the page sends on load: register this "browser" and clear any FX selection.
patch(transactionId, JSONObject().put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}")) * Returns the browser id, for [paymentErrors].
*/
fun announceBrowser(transactionId: String): String {
val browserId = "${transactionId}_${System.currentTimeMillis()}"
patch(transactionId, JSONObject().put("activeBrowserId", browserId))
patch(transactionId, JSONObject().put("fx", "reset")) patch(transactionId, JSONObject().put("fx", "reset"))
return browserId
} }
/**
* The transaction's failed payment attempts, oldest first, read with the page's load PATCH
* as [browserId]. A declined card (e.g. `INSUFFICIENT_FUNDS`) lands here while the
* transaction stays payable — `state` doesn't change, `hasError` turns true.
*/
fun paymentErrors(transactionId: String, browserId: String): List<PaymentError> {
val history = patch(transactionId, JSONObject().put("activeBrowserId", browserId))
.optJSONArray("paymentErrorHistory") ?: return emptyList()
return (0 until history.length()).mapNotNull { history.optJSONObject(it) }.map {
PaymentError(
code = it.optString("code"),
message = it.optString("customerVisibleDescription").ifBlank { it.optString("reason") }
)
}
}
/** One entry of `paymentErrorHistory`: the gateway's code and the wording BML shows for it. */
data class PaymentError(val code: String, val message: String)
private fun patch(transactionId: String, body: JSONObject): JSONObject { private fun patch(transactionId: String, body: JSONObject): JSONObject {
val request = Request.Builder() val request = Request.Builder()
.url("$API_BASE/transactions/$transactionId") .url("$API_BASE/transactions/$transactionId")
@@ -13,14 +13,14 @@ import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/** /**
* Dhiraagu prepaid reload through the dhiraagu.com.mv "Easy TopUp" page, paid by card on BML's * Dhiraagu prepaid reload ("Easy TopUp") and bill payment ("Easy Pay") through dhiraagu.com.mv,
* merchant gateway. Dhiraagu only builds the order; the money moves on the BML Merchant Services * paid by card on BML's merchant gateway. Dhiraagu only builds the order; the money moves on the
* transaction [createBmlTransaction] hands back, which is paid like any card-only BML merchant * BML Merchant Services transaction handed back, which is paid like any card-only BML merchant
* link. See `docs/dhiraaguapi/02-reload.md`. * link. See `docs/dhiraaguapi/02-reload.md` and `docs/dhiraaguapi/03-bill-pay.md`.
* *
* Every call blocks, so run it on an IO thread. * Every call blocks, so run it on an IO thread.
*/ */
class DhiraaguReloadClient { class DhiraaguPaymentClient {
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
@@ -33,11 +33,11 @@ class DhiraaguReloadClient {
* amount paid, GST included. Returns the 24-hex BML transaction id. Throws with Dhiraagu's * amount paid, GST included. Returns the 24-hex BML transaction id. Throws with Dhiraagu's
* wording when a step is refused. * wording when a step is refused.
*/ */
fun createBmlTransaction(number: String, amount: Int): String { fun createReloadTransaction(number: String, amount: Int): String {
val topupNonce = pageNonce("$BASE/services/easy-topup") val topupNonce = pageNonce("$BASE/services/easy-topup")
val gst = gstOf(amount) val gst = gstOf(amount)
val cart = api("cart", "recharge", topupNonce, JSONObject() val cart = api("cart", "recharge", topupNonce, JSONObject()
.put("formId", FORM_ID) .put("formId", FORM_RELOAD)
.put("serviceNumber", number) .put("serviceNumber", number)
.put("amount", amount) .put("amount", amount)
.put("amountGST", gst.toDouble()) .put("amountGST", gst.toDouble())
@@ -46,21 +46,66 @@ class DhiraaguReloadClient {
.put("memberId", "").put("memberName", "").put("memberNId", "") .put("memberId", "").put("memberName", "").put("memberNId", "")
.put("customerId", "").put("customerCode", "") .put("customerId", "").put("customerCode", "")
.put("version", 2)) .put("version", 2))
return payCart(FORM_RELOAD, cart, BigDecimal(amount))
}
/**
* Creates the bill payment order for postpaid [number] and the BML transaction paying for
* it: lookup (for the billing account) → cart → merchant → payment → BML transaction.
* [amount] is MVR, up to 2 decimal places. Returns the 24-hex BML transaction id. Throws
* with Dhiraagu's wording when a step is refused.
*/
fun createBillPayTransaction(number: String, amount: BigDecimal): String {
val easyPayNonce = pageNonce("$BASE/services/easy-pay")
// The cart needs the billing account the number belongs to, which only the lookup gives
val info = call("dhiraaguIO", "infoUnlisted", easyPayNonce, JSONObject().put("number", number))
if (info.optJSONArray("serviceDetails")?.optJSONObject(0)?.optString("prepaidIndicator") == "Y") {
throw Exception("Prepaid number is not allowed.")
}
val accountNumber = info.optString("accountNumber").ifBlank { throw Exception("Invalid account/service number") }
// The page refuses some account statuses and customer types before ordering; so do we
val rules = runCatching { get("setting", "bill", easyPayNonce).getJSONObject("resp").getJSONObject("settingAppJson1") }.getOrNull()
val status = info.optString("accountStatus")
if (rules != null && status in rules.blockedValues("accountStatus")) {
throw Exception("Dhiraagu can't accept payment for this service. Contact Dhiraagu customer service. [Account Status: $status]")
}
val customerType = info.optString("customerType")
if (rules != null && customerType in rules.blockedValues("customerType")) {
throw Exception("The number is not allowed. [Customer Type: $customerType]")
}
val cart = api("cart", "easyPay", easyPayNonce, JSONObject()
.put("formId", FORM_BILL)
.put("serviceNumber", number)
.put("accountNumber", accountNumber)
.put("amount", money(amount))
.put("memberId", "").put("memberName", "").put("memberNId", "")
.put("billRef", "")
.put("billType", if (info.optString("type") == BILL_WRITE_OFF) BILL_WRITE_OFF else BILL_PAYMENT))
return payCart(FORM_BILL, cart, amount)
}
/**
* The payment page's half, shared by every form: picks the BML gateway, creates the payment
* for [cart] and has Dhiraagu create the BML transaction. Returns its 24-hex id.
*/
private fun payCart(formId: Int, cart: JSONObject, amount: BigDecimal): String {
val cartId = cart.optString("cartId").ifBlank { throw Exception("Dhiraagu didn't create the order") } val cartId = cart.optString("cartId").ifBlank { throw Exception("Dhiraagu didn't create the order") }
// The payment page carries its own nonce, used for the rest of the order // The payment page carries its own nonce, used for the rest of the order
val paymentNonce = pageNonce("$BASE/services/payment-v2?cartid=$cartId") val paymentNonce = pageNonce("$BASE/services/payment-v2?cartid=$cartId")
val merchants = apiList("merchant", "form", paymentNonce, JSONObject().put("formId", FORM_ID)) val merchants = apiList("merchant", "form", paymentNonce, JSONObject().put("formId", formId))
val bml = (0 until merchants.length()).map { merchants.getJSONObject(it) } val bml = (0 until merchants.length()).map { merchants.getJSONObject(it) }
.firstOrNull { it.optInt("gatewayId") == GATEWAY_BML } .firstOrNull { it.optInt("gatewayId") == GATEWAY_BML }
?: throw Exception("Dhiraagu isn't taking BML card payments right now") ?: throw Exception("Dhiraagu isn't taking BML card payments right now")
val payment = api("payment", "create", paymentNonce, JSONObject() val payment = api("payment", "create", paymentNonce, JSONObject()
.put("formId", FORM_ID) .put("formId", formId)
.put("cartId", cartId) .put("cartId", cartId)
.put("gatewayId", GATEWAY_BML) .put("gatewayId", GATEWAY_BML)
.put("dhiraaguPayNumber", "") .put("dhiraaguPayNumber", "")
.put("amount", String.format(Locale.US, "%.2f", amount.toDouble())) .put("amount", money(amount))
.put("paymentMerchantId", bml.getString("merchantId")) .put("paymentMerchantId", bml.getString("merchantId"))
.put("memberId", "").put("tokenize", "").put("paymentType", "") .put("memberId", "").put("tokenize", "").put("paymentType", "")
.put("recurringFrequency", "").put("bmlTokenId", "")) .put("recurringFrequency", "").put("bmlTokenId", ""))
@@ -71,13 +116,22 @@ class DhiraaguReloadClient {
?: throw Exception("BML didn't create the transaction") ?: throw Exception("BML didn't create the transaction")
} }
private fun money(amount: BigDecimal) =
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
/** The `val` list of a `setting` rule, e.g. `{"accountStatus":{"val":["F"]}}`. */
private fun JSONObject.blockedValues(rule: String): Set<String> {
val vals = optJSONObject(rule)?.optJSONArray("val") ?: return emptySet()
return (0 until vals.length()).map { vals.optString(it) }.toSet()
}
// ── HTTP ───────────────────────────────────────────────────────────────── // ── HTTP ─────────────────────────────────────────────────────────────────
/** Every page embeds a `var nonce = "…"` that its API calls send as the `nonce` header. */ /** Every page embeds a `var nonce = "…"` that its API calls send as the `nonce` header. */
private fun pageNonce(url: String): String = private fun pageNonce(url: String): String =
NONCE.find(get(url))?.groupValues?.get(1) ?: throw Exception("Dhiraagu page didn't load") NONCE.find(page(url))?.groupValues?.get(1) ?: throw Exception("Dhiraagu page didn't load")
private fun get(url: String): String = client.newCall( private fun page(url: String): String = client.newCall(
Request.Builder().url(url) Request.Builder().url(url)
.header("User-Agent", UA) .header("User-Agent", UA)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
@@ -94,10 +148,16 @@ class DhiraaguReloadClient {
call(sub, act, nonce, body).getJSONArray("resp") call(sub, act, nonce, body).getJSONArray("resp")
/** POSTs to `sdk-dhr-webapi.ashx`; throws unless `respStatus` is OK. */ /** POSTs to `sdk-dhr-webapi.ashx`; throws unless `respStatus` is OK. */
private fun call(sub: String, act: String, nonce: String, body: JSONObject): JSONObject { private fun call(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
send(sub, act, nonce, body.toString().toRequestBody(JSON))
/** GETs from `sdk-dhr-webapi.ashx` (the settings calls); throws unless `respStatus` is OK. */
private fun get(sub: String, act: String, nonce: String): JSONObject = send(sub, act, nonce, null)
private fun send(sub: String, act: String, nonce: String, body: okhttp3.RequestBody?): JSONObject {
val text = client.newCall( val text = client.newCall(
Request.Builder().url("$API?website_id=$WEBSITE_ID&sub=$sub&act=$act") Request.Builder().url("$API?website_id=$WEBSITE_ID&sub=$sub&act=$act")
.post(body.toString().toRequestBody(JSON)) .apply { if (body != null) post(body) }
.header("User-Agent", UA) .header("User-Agent", UA)
.header("Accept", "application/json, text/javascript, */*; q=0.01") .header("Accept", "application/json, text/javascript, */*; q=0.01")
.header("X-Requested-With", "XMLHttpRequest") .header("X-Requested-With", "XMLHttpRequest")
@@ -112,7 +172,7 @@ class DhiraaguReloadClient {
throw Exception("Unexpected response from Dhiraagu") throw Exception("Unexpected response from Dhiraagu")
} }
if (obj.optString("respStatus") != "OK") { if (obj.optString("respStatus") != "OK") {
throw Exception(obj.optString("respMsg").ifBlank { obj.optString("resp") }.ifBlank { "Dhiraagu refused the reload" }) throw Exception(obj.optString("respMsg").ifBlank { obj.optString("resp") }.ifBlank { "Dhiraagu refused the payment" })
} }
return obj return obj
} }
@@ -126,14 +186,20 @@ class DhiraaguReloadClient {
private val NONCE = Regex("""var nonce = "([^"]+)"""") private val NONCE = Regex("""var nonce = "([^"]+)"""")
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""") private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
/** Easy Pay's (bill payment) form id across cart / merchant / payment. */
private const val FORM_BILL = 1
/** Easy TopUp's form id across cart / merchant / payment. */ /** Easy TopUp's form id across cart / merchant / payment. */
private const val FORM_ID = 2 private const val FORM_RELOAD = 2
/** Bank of Maldives in `merchant&act=form` (1 = BML, 2 = MIB, 3 = DhiraaguPay). */ /** Bank of Maldives in `merchant&act=form` (1 = BML, 2 = MIB, 3 = DhiraaguPay). */
private const val GATEWAY_BML = 1 private const val GATEWAY_BML = 1
private const val GST_RATE = 0.08 private const val GST_RATE = 0.08
/** Easy Pay's `billType`s; the lookup's `type` says which applies. */
private const val BILL_PAYMENT = "BillPayment"
private const val BILL_WRITE_OFF = "writeOffPayments"
/** /**
* The GST inside a GST-inclusive [amount], as the page works it out: * The GST inside a GST-inclusive reload [amount], as the page works it out:
* `amount × rate / (1 + rate)`, to 2 places. The number is credited `amount − gst`. * `amount × rate / (1 + rate)`, to 2 places. The number is credited `amount − gst`.
*/ */
fun gstOf(amount: Int): BigDecimal { fun gstOf(amount: Int): BigDecimal {
@@ -11,7 +11,7 @@ import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
private val SKIP_TYPES = setOf("Switch Profile", "Log in") private val SKIP_TYPES = setOf("Switch Profile", "Log in")
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv" private const val MIB_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
class MibActivityHistoryClient { class MibActivityHistoryClient {
@@ -35,7 +35,7 @@ class MibActivityHistoryClient {
start: Int, start: Int,
end: Int end: Int
): FetchResult { ): FetchResult {
val cookieHeader = "mbmodel=IOS-1.0; " + val cookieHeader = "mbmodel=AND-1.0; " +
"xxid=${session.xxid}; " + "xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " + "IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " + "mbnonce=${session.nonceGenerator}; " +
@@ -15,7 +15,7 @@ data class MibCardActionResult(
class MibCardsClient { class MibCardsClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv" private val BASE_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
private val 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" private val 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"
@@ -25,7 +25,7 @@ class MibCardsClient {
.build() .build()
private fun cookieHeader(session: MibSession) = private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " + "mbmodel=AND-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597" "mbnonce=${session.nonceGenerator}; time-tracker=597"
fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> { fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> {
@@ -1,135 +1,83 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibContactsClient { class MibContactsClient(private val flow: MibLoginFlow) {
private val TAG = "MibContactsClient" fun fetchCategories(session: MibSession): List<MibBeneficiaryCategory> =
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv" fetchContactsAndCategories(session).second
private val client = OkHttpClient.Builder() fun fetchContacts(session: MibSession): List<MibBeneficiary> =
.connectTimeout(30, TimeUnit.SECONDS) fetchContactsAndCategories(session).first
.readTimeout(30, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) = /**
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " + * Pages through B41. Every page also carries the full category list.
"mbnonce=${session.nonceGenerator}; time-tracker=597" * start/end select rows by their 1-based `rnum`, both ends inclusive.
*/
private fun Request.Builder.withSessionHeaders(session: MibSession): Request.Builder = this fun fetchContactsAndCategories(session: MibSession): Pair<List<MibBeneficiary>, List<MibBeneficiaryCategory>> {
.header("Cookie", cookieHeader(session))
.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("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/beneficiary?dashurl=1")
fun fetchCategories(session: MibSession): List<MibBeneficiaryCategory> {
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/getCategories")
.post(FormBody.Builder().build())
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return emptyList()
parseCategories(response.body?.string() ?: return emptyList())
}
}
private fun parseCategories(json: String): List<MibBeneficiaryCategory> {
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiaryCategory(
id = o.getString("id"),
categoryName = o.getString("categoryName"),
numBenef = o.optString("numBenef", "0").toIntOrNull() ?: 0
)
}
} catch (e: Exception) {
emptyList()
}
}
fun fetchContacts(session: MibSession): List<MibBeneficiary> {
val all = mutableListOf<MibBeneficiary>() val all = mutableListOf<MibBeneficiary>()
var page = 1 val seen = mutableSetOf<String>()
val pageSize = 100 var categories = emptyList<MibBeneficiaryCategory>()
val pageSize = 10 // the official app always requests 10-row windows
var start = 1
while (true) { while (true) {
val start = (page - 1) * pageSize + 1 val resp = flow.request(session, "B41") {
val end = page * pageSize put("start", start)
val body = FormBody.Builder() put("end", start + pageSize - 1)
.add("page", page.toString()) put("benefType", "A")
.add("search", "") put("search", "")
.add("searchCategoryId", "0") put("includeCount", 1)
.add("benefType", "A") put("searchCategoryId", 0)
.add("sortBenef", "name") put("sortBenef", "")
.add("sortDir", "asc")
.add("start", start.toString())
.add("end", end.toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/main")
.post(body)
.withSessionHeaders(session)
.build()
val (contacts, totalCount) = client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return all
parseContacts(response.body?.string() ?: return all)
} }
all.addAll(contacts) if (!resp.optBoolean("success")) break
if (all.size >= totalCount || contacts.isEmpty()) break if (start == 1) categories = parseCategories(resp)
page++ val totalCount = resp.optString("total_count", "0").toIntOrNull() ?: 0
val arr = resp.optJSONArray("data") ?: break
val page = (0 until arr.length()).map { parseContact(arr.getJSONObject(it)) }
for (c in page) if (seen.add(c.benefNo)) all.add(c)
val isLast = arr.length() == 0 ||
arr.getJSONObject(arr.length() - 1).optString("last") == "1"
if (isLast || all.size >= totalCount) break
start += pageSize
} }
return all return Pair(all, categories)
} }
private fun parseContacts(json: String): Pair<List<MibBeneficiary>, Int> { private fun parseCategories(resp: JSONObject): List<MibBeneficiaryCategory> {
return try { val arr = resp.optJSONArray("categories") ?: return emptyList()
val obj = JSONObject(json) return (0 until arr.length()).map { i ->
val totalCount = obj.optString("total_count", "0").toIntOrNull() ?: 0 val o = arr.getJSONObject(i)
val arr = obj.getJSONArray("data") MibBeneficiaryCategory(
val contacts = (0 until arr.length()).map { i -> id = o.getString("id"),
val o = arr.getJSONObject(i) categoryName = o.getString("categoryName"),
val hash = o.optString("customerImgHash") numBenef = o.optString("numBenef", "0").toIntOrNull() ?: 0
MibBeneficiary( )
benefNo = o.optString("benefNo"),
benefName = o.optString("benefName"),
benefNickName = o.optString("benefNickName")
.ifBlank { o.optString("benefName") },
benefAccount = o.optString("benefAccount"),
benefType = o.optString("benefType"),
bankColor = o.optString("bankColor", "#888888"),
benefBankName = o.optString("benefBankName"),
bankCode = o.optString("bankCode"),
benefStatus = o.optString("benefStatus"),
transferCyDesc = o.optString("transferCyDesc", "MVR"),
customerImgHash = hash.takeIf { it.isNotBlank() && it != "null" },
benefCategoryId = o.optString("benefCategoryID", "0")
)
}
Pair(contacts, totalCount)
} catch (e: Exception) {
Pair(emptyList(), 0)
} }
} }
private fun parseContact(o: JSONObject): MibBeneficiary {
val hash = o.optString("customerImgHash")
return MibBeneficiary(
benefNo = o.optString("benefNo"),
benefName = o.optString("benefName"),
benefNickName = o.optString("benefNickName")
.ifBlank { o.optString("benefName") },
benefAccount = o.optString("benefAccount"),
benefType = o.optString("benefType"),
bankColor = o.optString("bankColor", "#888888"),
benefBankName = o.optString("benefBankName"),
bankCode = o.optString("bankCode"),
benefStatus = o.optString("benefStatus"),
transferCyDesc = o.optString("transferCyDesc", "MVR"),
customerImgHash = hash.takeIf { it.isNotBlank() && it != "null" },
benefCategoryId = o.optString("benefCategoryID", "0")
)
}
/** Creates a beneficiary via B43. The server rejects nicknames longer than ~20 chars. */
fun createContact( fun createContact(
session: MibSession, session: MibSession,
benefType: String, // "I" = MIB internal, "L" = local/BML
bankNo: Int, // 2 = MIB, 3 = BML bankNo: Int, // 2 = MIB, 3 = BML
benefAccount: String, benefAccount: String,
benefName: String, benefName: String,
@@ -138,82 +86,62 @@ class MibContactsClient {
categoryId: String = "0", categoryId: String = "0",
imageBase64: String = "" imageBase64: String = ""
): Boolean { ): Boolean {
val body = FormBody.Builder() val (bankName, bankSwift) = bankInfo(bankNo)
.add("benefType", benefType) val resp = flow.request(session, "B43") {
.add("imageSet", if (imageBase64.isNotBlank()) "1" else "0") put("benefName", benefName)
.add("benefAccount", benefAccount) put("benefAccount", benefAccount)
.add("benefIban", "") put("bankNo", bankNo.toString())
.add("benefName", benefName) put("nickName", nickName)
.add("nickName", nickName) put("transferCy", transferCy)
.add("transferCy", transferCy) put("categoryId", categoryId)
.add("transferCySwift", "840") if (imageBase64.isNotBlank()) put("image", imageBase64)
.add("benefAddress", "") put("bankName", bankName)
.add("benefCity", "") put("bankSwift", bankSwift)
.add("benefCountry", "4")
.add("benefBankSwift", "")
.add("bankNo", bankNo.toString())
.add("benefBankName", "")
.add("benefBankBranch", "")
.add("benefBankAddress", "")
.add("benefBankCity", "")
.add("benefBankCountry", "4")
.add("intBankSwift", "")
.add("intBankName", "")
.add("intBankAddress", "")
.add("intBankBranch", "")
.add("intBankCity", "")
.add("intBankCountry", "4")
.add("categoryId", categoryId)
.add("email", "")
.add("contactNumber", "")
.add("website", "")
.add("image", imageBase64)
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/createLocalBeneficiary")
.post(body)
.withSessionHeaders(session)
.header("Referer", "$BASE_WV_URL/beneficiary/createNew")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
} }
return resp.optBoolean("success")
} }
fun deleteContact(session: MibSession, benefNo: String): Boolean { /** Updates a beneficiary via B52. A blank [imageBase64] keeps the current photo. */
val body = FormBody.Builder().add("benefNo", benefNo).build() fun updateContact(
val request = Request.Builder() session: MibSession,
.url("$BASE_WV_URL/ajaxBeneficiary/deleteBeneficiary") benefNo: String,
.post(body) bankNo: Int, // 2 = MIB, 3 = BML
.withSessionHeaders(session) benefAccount: String,
.build() benefName: String,
return client.newCall(request).execute().use { response -> nickName: String,
val bodyStr = response.body?.string() ?: return@use false transferCy: String = "462",
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false } categoryId: String = "0",
imageBase64: String = ""
): Boolean {
val (bankName, bankSwift) = bankInfo(bankNo)
val resp = flow.request(session, "B52") {
put("benefName", benefName)
put("benefAccount", benefAccount)
put("bankNo", bankNo.toString())
put("nickName", nickName)
put("transferCy", transferCy)
put("categoryId", categoryId)
put("benefNo", benefNo)
put("imageChanged", if (imageBase64.isNotBlank()) "1" else "0")
if (imageBase64.isNotBlank()) put("image", imageBase64)
put("bankName", bankName)
put("bankSwift", bankSwift)
} }
return resp.optBoolean("success")
} }
/** Bank name/SWIFT as listed by B49 for the bank IDs we support. */
private fun bankInfo(bankNo: Int): Pair<String, String> = when (bankNo) {
2 -> "Maldives Islamic Bank" to "MADVMVMV"
else -> "Bank of Maldives PLC" to "MALBMVMV"
}
fun deleteContact(session: MibSession, benefNo: String): Boolean =
flow.request(session, "B48") { put("benefNo", benefNo) }.optBoolean("success")
fun fetchProfileImageBase64(session: MibSession, imageHash: String): String? { fun fetchProfileImageBase64(session: MibSession, imageHash: String): String? {
val body = FormBody.Builder() val resp = flow.request(session, "B46") { put("imageHash", imageHash) }
.add("imageHash", imageHash) if (!resp.optBoolean("success")) return null
.build() return resp.optString("profileImage").takeIf { it.isNotBlank() }
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxBeneficiary/getProfileImage")
.post(body)
.withSessionHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return null
try {
val json = response.body?.string() ?: return null
JSONObject(json).optString("profileImage").takeIf { it.isNotBlank() }
} catch (e: Exception) {
null
}
}
} }
} }
@@ -9,7 +9,7 @@ import kotlin.math.ceil
class MibFinancingClient { class MibFinancingClient {
private val TAG = "MibFinancingClient" private val TAG = "MibFinancingClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv" private val BASE_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
@@ -17,7 +17,7 @@ class MibFinancingClient {
.build() .build()
fun fetchFinancing(session: MibSession): List<MibFinanceDeal> { fun fetchFinancing(session: MibSession): List<MibFinanceDeal> {
val cookieHeader = "mbmodel=IOS-1.0; " + val cookieHeader = "mbmodel=AND-1.0; " +
"xxid=${session.xxid}; " + "xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " + "IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " + "mbnonce=${session.nonceGenerator}; " +
@@ -1,28 +1,9 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build class MibHistoryClient(private val flow: MibLoginFlow) {
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit
class MibHistoryClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
/** /**
* Fetches transaction history for a single MIB account. * Fetches transaction history for a single MIB account via the encrypted `A84` route.
* @param start 1-based index; use 1 for first page, 21 for second, etc. (pageSize=20) * @param start 1-based index; use 1 for first page, 21 for second, etc. (pageSize=20)
* @return Pair of (transactions, totalCount) * @return Pair of (transactions, totalCount)
*/ */
@@ -33,58 +14,36 @@ class MibHistoryClient {
start: Int = 1, start: Int = 1,
pageSize: Int = 20 pageSize: Int = 20
): Pair<List<Transaction>, Int> { ): Pair<List<Transaction>, Int> {
val body = FormBody.Builder() // A84 uses a 0-based inclusive range: first page is start=0, end=19
.add("accountNo", accountNo) val json = flow.request(session, "A84") {
.add("trxNo", "") put("accountNo", accountNo)
.add("trxType", "0") put("start", start - 1)
.add("sortTrx", "date") put("end", start - 1 + pageSize - 1)
.add("sortDir", "desc") put("includeCount", true)
.add("fromDate", "") put("trxType", 0)
.add("toDate", "") put("sortTrx", "date")
.add("start", start.toString()) put("sortDir", "desc")
.add("end", (start + pageSize - 1).toString())
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxAccounts/trxHistory")
.post(body)
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android ${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("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL//accountDetails?trxh=1&dashurl=1&accountNo=$accountNo")
.build()
return client.newCall(request).execute().use { response ->
if (response.code in 500..599) throw BankServerException("MIB")
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
val total = json.optString("total_count", "0").toIntOrNull() ?: 0
val data = json.optJSONArray("data") ?: return Pair(emptyList(), total)
val transactions = (0 until data.length()).map { i ->
val item = data.getJSONObject(i)
Transaction(
id = item.optString("trxNumber"),
date = item.optString("trxDate"),
description = item.optString("descr1").trim(),
amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0,
currency = item.optString("curCodeDesc"),
counterpartyName = item.optString("benefName").takeIf {
it.isNotBlank() && it != "null"
},
reference = item.optString("trxNumber2").takeIf { it.isNotBlank() },
accountNumber = accountNo,
accountDisplayName = accountDisplayName,
source = "MIB"
)
}
Pair(transactions, total)
} }
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
val total = json.optString("total_count", "0").toIntOrNull() ?: 0
val data = json.optJSONArray("data") ?: return Pair(emptyList(), total)
val transactions = (0 until data.length()).map { i ->
val item = data.getJSONObject(i)
Transaction(
id = item.optString("trxNumber"),
date = item.optString("trxDate"),
description = item.optString("descr1").trim(),
amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0,
currency = item.optString("curCodeDesc"),
counterpartyName = item.optString("benefName").takeIf {
it.isNotBlank() && it != "null"
},
reference = item.optString("trxNumber2").takeIf { it.isNotBlank() },
accountNumber = accountNo,
accountDisplayName = accountDisplayName,
source = "MIB"
)
}
return Pair(transactions, total)
} }
} }
@@ -7,6 +7,10 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import java.security.MessageDigest import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs import kotlin.math.abs
@@ -16,7 +20,7 @@ class SessionExpiredException : Exception("MIB session expired")
class MibLoginFlow(private val credentialStore: CredentialStore) { class MibLoginFlow(private val credentialStore: CredentialStore) {
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/" private val BASE_URL = "https://faisamobilex-smvc-v2.mib.com.mv/index/"
/** The active session after a successful login, usable for subsequent WebView requests. */ /** The active session after a successful login, usable for subsequent WebView requests. */
var lastSession: MibSession? = null var lastSession: MibSession? = null
@@ -26,6 +30,12 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
var lastProfiles: List<MibProfile> = emptyList() var lastProfiles: List<MibProfile> = emptyList()
private set private set
/** Full name and email from the last A41 login response. */
var lastFullName: String = ""
private set
var lastEmail: String = ""
private set
/** Called after automatic session recovery so callers can update their session reference. */ /** Called after automatic session recovery so callers can update their session reference. */
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
@@ -42,8 +52,9 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.addNetworkInterceptor { chain -> .addNetworkInterceptor { chain ->
val req = chain.request().newBuilder() val req = chain.request().newBuilder()
.header("User-Agent", "android/1.0") .header("User-Agent", "android/2.0")
.header("Accept", "application/json") .header("Accept", "application/json")
.header("X-Request-ID", generateRequestId())
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8") .header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
.build() .build()
chain.proceed(req) chain.proceed(req)
@@ -144,6 +155,8 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
} }
val profiles = parseProfiles(loginResp) val profiles = parseProfiles(loginResp)
lastFullName = loginResp.optString("fullName").takeIf { it != "null" } ?: ""
lastEmail = loginResp.optString("email").takeIf { it != "null" } ?: ""
lastSession = session2 lastSession = session2
lastProfiles = profiles // keep ALL profiles so settings can show them all lastProfiles = profiles // keep ALL profiles so settings can show them all
@@ -300,6 +313,13 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
put("xxid", session.xxid) put("xxid", session.xxid)
} }
/**
* Sends an encrypted `sfunc=n` request for [routePath] with the base fields plus [fields].
* Expired sessions are recovered the same way as login requests.
*/
fun request(session: MibSession, routePath: String, fields: JSONObject.() -> Unit = {}): JSONObject =
doRequest(session, baseData(session, routePath).apply(fields), "n")
/** /**
* Activates [profile] server-side via P47, setting the session role context so that * Activates [profile] server-side via P47, setting the session role context so that
* subsequent WebView requests run under that profile. * subsequent WebView requests run under that profile.
@@ -424,6 +444,16 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
return sha256Upper(clientSalt + h2) return sha256Upper(clientSalt + h2)
} }
private fun generateRequestId(): String {
val sdf = SimpleDateFormat("yyyyMMddHHmmss", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
val timestamp = sdf.format(Date())
val millis = System.currentTimeMillis() % 1000
val randomHex = Random.nextBytes(8).joinToString("") { "%02x".format(it) }
return "$timestamp${millis.toString().padStart(3, '0')}-$randomHex"
}
companion object { companion object {
/** Returns SHA-256(password) as uppercase hex. Store this instead of the raw password. */ /** Returns SHA-256(password) as uppercase hex. Store this instead of the raw password. */
fun hashPassword(password: String): String = fun hashPassword(password: String): String =
@@ -35,7 +35,8 @@ data class MibTransferResult(
val success: Boolean, val success: Boolean,
val trxId: String = "", val trxId: String = "",
val date: String = "", val date: String = "",
val errorMessage: String = "" val errorMessage: String = "",
val reasonCode: String = ""
) )
@@ -1,50 +1,87 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.util.CredentialStore
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
data class MibPersonalProfile( data class MibPersonalProfile(
val fullName: String, val fullName: String,
val username: String,
val email: String, val email: String,
val mobile: String, val mobile: String,
val enrolled: String val idCard: String
) )
class MibProfileClient { class MibProfileClient(private val flow: MibLoginFlow) {
private val BASE_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.build() .build()
/**
* Name comes from the A41 login response; email, mobile and National ID from the
* Favara alias suggestions (`/ajaxAlias/getAliasTypes`), which belong to the active
* profile — so the personal profile is selected first.
*/
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? { fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " + val personal = flow.lastProfiles.firstOrNull { it.profileType == "0" }
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597" if (personal != null) flow.switchProfile(session, personal)
val request = Request.Builder() val request = Request.Builder()
.url("https://faisamobilex-wv.mib.com.mv/personalProfile") .url("$BASE_WV_URL/ajaxAlias/getAliasTypes")
.get() .post(FormBody.Builder().build())
.header("Cookie", cookieHeader) .header("Cookie", "mbmodel=AND-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}")
.build() .header(
return try { "User-Agent",
val resp = client.newCall(request).execute() "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"
val html = resp.body?.string() ?: return null
resp.close()
fun scrape(label: String): String {
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
}
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
MibPersonalProfile(
fullName = fullName,
username = scrape("Username:"),
email = scrape("Email:"),
mobile = scrape("Mobile no:"),
enrolled = scrape("Enrolled:")
) )
} catch (_: Exception) { null } .header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/Alias/Alias?dashurl=1")
.build()
val json = client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: return null
try { JSONObject(body) } catch (_: Exception) { return null }
}
if (!json.optBoolean("success")) return null
// Alias type ids: 0 = mobile number, 1 = email, 3 = National ID
val aliases = json.optJSONArray("data")
fun alias(id: Int): String {
if (aliases == null) return ""
for (i in 0 until aliases.length()) {
val o = aliases.getJSONObject(i)
if (o.optInt("id", -1) == id) return o.optString("suggestedAlias").takeIf { it != "null" } ?: ""
}
return ""
}
return MibPersonalProfile(
fullName = flow.lastFullName.ifBlank { personal?.name ?: "" },
email = alias(1).ifBlank { flow.lastEmail },
mobile = alias(0),
idCard = alias(3)
)
}
/** Fetches and caches the user's details for [loginId] unless a v2 fetch is already cached. */
fun cacheIfMissing(store: CredentialStore, loginId: String, session: MibSession) {
if (store.loadMibUserProfile(loginId)?.idCard != null) return
val p = fetchPersonalProfile(session) ?: return
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
fullName = p.fullName,
username = loginId,
email = p.email,
mobile = p.mobile,
enrolled = "",
idCard = p.idCard
))
} }
} }
@@ -1,42 +1,12 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibLookupException(message: String) : Exception(message) class MibLookupException(message: String) : Exception(message)
class MibTransferClient { class MibTransferClient(private val flow: MibLoginFlow) {
private val TAG = "MibTransferClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
private fun Request.Builder.withWvHeaders(session: MibSession): Request.Builder = this
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android ${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("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL/transfer/quick")
/** /**
* Execute a transfer. bankNo=2 → MIB internal (/transferInternal), bankNo=3 → local/BML (/transferLocal). * Execute a transfer. bankNo=2 → MIB internal (T50), bankNo=3 → local/BML via Favara (T51).
* currencyCode: "462"=MVR, "840"=USD. purpose uses "-" if blank. * currencyCode: "462"=MVR, "840"=USD.
*/ */
fun transfer( fun transfer(
session: MibSession, session: MibSession,
@@ -49,157 +19,79 @@ class MibTransferClient {
purpose: String, purpose: String,
otp: String otp: String
): MibTransferResult { ): MibTransferResult {
val endpoint = if (bankNo == 2) "transferInternal" else "transferLocal" val json = flow.request(session, if (bankNo == 2) "T50" else "T51") {
val body = FormBody.Builder() put("benefName", benefName)
.add("benefName", benefName) put("benefNo", "0")
.add("benefNo", "0") put("fromAccountNo", fromAccount)
.add("fromAccountNo", fromAccount) put("benefAccountNo", toAccount)
.add("benefAccountNo", toAccount) put("transferCy", currencyCode)
.add("transferCy", currencyCode) put("benefCurrencyCode", currencyCode)
.add("benefCurrencyCode", currencyCode) put("amount", amount)
.add("amount", amount) put("bankNo", bankNo.toString())
.add("bankNo", bankNo.toString()) put("purpose", purpose)
.add("purpose", purpose.ifBlank { "-" }) put("otp", otp)
.add("otp", otp) put("otpType", "3")
.add("otpType", "3")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxTransfer/$endpoint")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 419) throw SessionExpiredException()
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (json == null || !json.optBoolean("success")) {
MibTransferResult(
success = false,
errorMessage = json?.optString("reasonText")?.ifBlank { null } ?: "Transfer failed"
)
} else {
val data = json.optJSONArray("data")?.optJSONObject(0)
MibTransferResult(
success = true,
trxId = data?.optString("trxId") ?: "",
date = data?.optString("date") ?: ""
)
}
} }
if (!json.optBoolean("success")) {
return MibTransferResult(
success = false,
errorMessage = json.optString("reasonText").ifBlank { "Transfer failed" },
reasonCode = json.optString("reasonCode")
)
}
val data = json.optJSONArray("data")?.optJSONObject(0)
return MibTransferResult(
success = true,
trxId = data?.optString("trxId") ?: "",
date = data?.optString("date") ?: ""
)
} }
/** /**
* Routes the lookup to the correct endpoint based on input format: * Resolves a destination via B40, which takes any supported input:
* *
* - 15 digits starting with 7 → IPS account lookup (BML / local bank) * - 13 digits starting with 7 → BML / local bank account
* - 17 digits starting with 9 → MIB internal account name lookup * - 17 digits starting with 9 → MIB account
* - 7 digits starting with 7 or 9, * - 7 digits starting with 7 or 9,
* A + 6 digits, * A + 6 digits,
* or email address → Favara alias lookup * or email address → Favara alias
*
* The format check only avoids sending inputs B40 can't resolve.
*/ */
fun lookup(session: MibSession, input: String): MibIpsAccountInfo { fun lookup(session: MibSession, input: String): MibIpsAccountInfo {
val trimmed = input.trim() // B40 only resolves Favara IDs with an uppercase "A" ("a239225" fails, "A239225" works)
return when { val trimmed = input.trim().let { if (it.matches(Regex("^a\\d{6}$"))) it.uppercase() else it }
trimmed.matches(Regex("^7\\d{12}$")) -> lookupIpsAccount(session, trimmed) val supported = trimmed.matches(Regex("^7\\d{12}$")) ||
trimmed.matches(Regex("^9\\d{16}$")) -> lookupAccountName(session, trimmed) trimmed.matches(Regex("^9\\d{16}$")) ||
trimmed.matches(Regex("^[79]\\d{6}$")) -> lookupAlias(session, trimmed) trimmed.matches(Regex("^[79]\\d{6}$")) ||
trimmed.matches(Regex("^[Aa]\\d{6}$")) -> lookupAlias(session, trimmed) trimmed.matches(Regex("^[Aa]\\d{6}$")) ||
trimmed.contains("@") -> lookupAlias(session, trimmed) trimmed.contains("@")
else -> throw MibLookupException("Unrecognized account number format") if (!supported) throw MibLookupException("Unrecognized account number format")
}
}
/** BML / local bank: 15-digit account starting with 7. */ val json = flow.request(session, "B40") { put("search", trimmed) }
private fun lookupIpsAccount(session: MibSession, accountNumber: String): MibIpsAccountInfo { val match = json.optJSONArray("data")?.optJSONObject(0)
val body = FormBody.Builder().add("benefAccount", accountNumber).build() if (!json.optBoolean("success") || match == null) {
val request = Request.Builder() throw MibLookupException(
.url("$BASE_WV_URL/AjaxAlias/getIPSAccount") json.optString("reasonText").trim().ifBlank { "Account not found" }
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
MibIpsAccountInfo(
accountName = json.optString("accountName").trim(),
accountNumber = accountNumber,
bankId = json.optString("bankBic"),
// MIB IPS only returns success for MVR cross-bank accounts;
// USD cross-bank accounts fail this lookup entirely.
currency = "MVR"
) )
} }
val accountNumber = match.optString("benefAccount")
// B40 reports MIB accounts either as "MIB" or by BIC; callers expect the BIC
val bankId = match.optString("bankCode").let { if (it == "MIB") "MADVMVMV" else it }
val currency = match.optString("transferCyDesc").takeIf { it == "MVR" || it == "USD" }
?: if (bankId == "MADVMVMV") "" else "MVR" // cross-bank lookups only resolve MVR accounts
return MibIpsAccountInfo(
accountName = match.optString("benefName").trim(),
accountNumber = accountNumber,
bankId = bankId,
currency = currency
)
} }
/** MIB internal: 17-digit account starting with 9. */ companion object {
private fun lookupAccountName(session: MibSession, accountNumber: String): MibIpsAccountInfo { /** reasonCode for a wrong OTP, e.g. one from the previous window. */
val body = FormBody.Builder().add("accountNo", accountNumber).build() const val REASON_INVALID_OTP = "213"
val request = Request.Builder() /** reasonCode for an OTP the server has already accepted once. */
.url("$BASE_WV_URL/ajaxBeneficiary/getAccountName") const val REASON_OTP_ALREADY_USED = "707"
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
// accountName may be at root or inside a "data" object
val name = json.optString("accountName").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("accountName") ?: ""
val currencyCode = json.optString("currencyCode").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("currencyCode") ?: ""
val currency = when (currencyCode) {
"840" -> "USD"
"462" -> "MVR"
else -> ""
}
MibIpsAccountInfo(
accountName = name.trim(),
accountNumber = accountNumber,
bankId = "MADVMVMV", // MIB
currency = currency
)
}
}
/** Favara alias: 7-digit shortcode (7/9 prefix), A-format ID, or email. */
private fun lookupAlias(session: MibSession, aliasName: String): MibIpsAccountInfo {
val body = FormBody.Builder().add("aliasName", aliasName).build()
val request = Request.Builder()
.url("$BASE_WV_URL/AjaxAlias/getAlias")
.post(body)
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (!response.isSuccessful || json == null || !json.optBoolean("success")) {
throw MibLookupException(
json?.optString("reasonText")?.trim()?.takeIf { it.isNotBlank() }
?: "Request failed (${response.code})"
)
}
val data = json.getJSONObject("data")
val cdtrAcct = data.getJSONObject("CdtrAcct")
MibIpsAccountInfo(
accountName = data.optString("BfyNm").trim(),
accountNumber = cdtrAcct.optString("Acct"),
bankId = cdtrAcct.optString("FinInstnId")
)
}
} }
} }
@@ -0,0 +1,90 @@
package sh.sar.basedbank.api.ooredoo
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* Ooredoo prepaid recharge (Raastas) and bill payment through the ooredoo.mv Quick Pay pages,
* paid by card on BML's merchant gateway. Ooredoo only creates the order; the money moves on the
* BML Merchant Services transaction it hands back, which is paid like any card-only BML merchant
* link. See `docs/ooredooapi/02-raastas.md` and `docs/ooredooapi/03-bill-pay.md`.
*
* Every call blocks, so run it on an IO thread.
*/
class OoredooPaymentClient {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Creates the recharge order for [number] (7 digits) and the BML transaction paying for it.
* [amount] is what the number is credited, in whole MVR; the card pays [charged], which is
* that plus GST. Returns the 24-hex BML transaction id. Throws with Ooredoo's wording when
* the order is refused.
*/
fun createRaastasTransaction(number: String, amount: Int, charged: BigDecimal): String =
createOrder(number, charged, amount.toString(), transType = "recharge", serviceType = "prepaid")
/**
* Creates the bill payment order for postpaid [number] (7 digits) and the BML transaction
* paying for it. [amount] is MVR, up to 2 decimal places; no GST. Returns the 24-hex BML
* transaction id. Throws with Ooredoo's wording when the order is refused.
*/
fun createBillPayTransaction(number: String, amount: BigDecimal): String =
createOrder(number, amount, money(amount), transType = "billpay", serviceType = "Mobile")
/** `POST PaymentGateway/bml` — one call makes the order and its BML transaction. */
private fun createOrder(
number: String, charged: BigDecimal, amountWithoutGst: String, transType: String, serviceType: String,
): String {
val msisdn = "960$number"
val body = JSONObject()
.put("msisdn", msisdn)
.put("purchaseAmount", money(charged))
.put("amountWithoutGst", amountWithoutGst)
.put("receiverMsisdn", msisdn)
.put("transType", transType)
.put("serviceType", serviceType)
.put("serviceTypeDisplayName", "Mobile")
val text = client.newCall(
Request.Builder().url("$BASE/ooredoo-prod/PaymentGateway/bml")
.post(body.toString().toRequestBody(JSON))
.header("User-Agent", UA)
.header("Accept", "application/json")
.header("Origin", BASE)
.build()
).execute().use { r ->
if (r.code in 500..599) throw BankServerException("Ooredoo")
r.body?.string().orEmpty()
}
val obj = try { JSONObject(text) } catch (_: Exception) {
throw Exception("Unexpected response from Ooredoo")
}
if (obj.optString("status") != "OK" || obj.optString("code") != "2000") {
throw Exception(obj.optString("msg").ifBlank { "Ooredoo refused the payment" })
}
val data = obj.optJSONObject("data") ?: throw Exception("Ooredoo didn't create the order")
return TXN_URL.find(data.optString("bmlUrl"))?.groupValues?.get(1)
?: throw Exception("BML didn't create the transaction")
}
private fun money(amount: BigDecimal) =
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
companion object {
private const val BASE = "https://www.ooredoo.mv"
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
private val JSON = "application/json".toMediaType()
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
}
}
@@ -294,7 +294,7 @@ class AccountHistoryFragment : Fragment() {
val sess = app.anyMibSession() ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
ContactImageCache.save(requireContext(), hash, bitmap) ContactImageCache.save(requireContext(), hash, bitmap)
@@ -18,11 +18,13 @@ import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -32,6 +34,7 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountValidation import sh.sar.basedbank.api.bml.BmlAccountValidation
import sh.sar.basedbank.api.bml.BmlContactsClient import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlValidateClient import sh.sar.basedbank.api.bml.BmlValidateClient
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
@@ -67,6 +70,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private var selectedCategoryId: String = "0" private var selectedCategoryId: String = "0"
private var categories: List<BankContactCategory> = emptyList() private var categories: List<BankContactCategory> = emptyList()
// Set when editing an existing MIB contact instead of adding a new one
private var editContact: BankContact? = null
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
encodeImage(uri) encodeImage(uri)
@@ -90,7 +96,49 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (selectedDest?.isBml == false) setupCategoryDropdown() if (selectedDest?.isBml == false) setupCategoryDropdown()
} }
applyPrefillArgs() if (!applyEditArgs()) applyPrefillArgs()
}
/** Switches the sheet into edit mode when opened via [forEdit]. Returns true if it did. */
private fun applyEditArgs(): Boolean {
val args = arguments ?: return false
val benefNo = args.getString(ARG_EDIT_BENEF_NO) ?: return false
val profileId = args.getString(ARG_EDIT_PROFILE_ID) ?: return false
val contact = viewModel.contacts.value?.firstOrNull { it.benefNo == benefNo && it.profileId == profileId }
val dest = destinations.firstOrNull { !it.isBml && it.mibProfile?.profileId == profileId }
if (contact == null || dest == null) {
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
dismiss()
return true
}
editContact = contact
selectedCategoryId = contact.benefCategoryId
categories = viewModel.contactCategories.value?.filter { it.id != "BML" } ?: emptyList()
binding.tvTitle.setText(R.string.contact_edit_title)
selectedDest = dest
binding.actvDestination.setText(dest.label, false)
binding.tilDestination.isEnabled = false
binding.actvGroup.setText(
categories.firstOrNull { it.id == contact.benefCategoryId }?.categoryName
?: getString(R.string.contact_no_group), false
)
updateMibOnlyVisibility()
mibLookupAccount = contact.benefAccount
binding.tvResultName.text = contact.benefName
binding.tvResultBank.text = "${contact.benefBankName} · ${contact.benefAccount}"
val photo = contact.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
binding.ivResultAvatar.setImageBitmap(photo ?: makeInitialsBitmap(contact.benefName, contact.bankColor))
if (photo != null) binding.ivContactImage.setImageBitmap(photo)
binding.cardResult.visibility = View.VISIBLE
binding.tilAccount.visibility = View.GONE
binding.btnClearResult.visibility = View.GONE
binding.etAlias.setText(contact.benefNickName)
binding.etCurrency.setText(contact.transferCyDesc)
updateSaveState()
return true
} }
private fun applyPrefillArgs() { private fun applyPrefillArgs() {
@@ -201,6 +249,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.layoutImage.visibility = if (isMib) View.VISIBLE else View.GONE binding.layoutImage.visibility = if (isMib) View.VISIBLE else View.GONE
binding.tilGroup.visibility = if (isMib) View.VISIBLE else View.GONE binding.tilGroup.visibility = if (isMib) View.VISIBLE else View.GONE
if (isMib) setupCategoryDropdown() if (isMib) setupCategoryDropdown()
updateSaveState()
}
/**
* Save is enabled once an account is resolved. MIB rejects nicknames longer than
* [MIB_MAX_ALIAS] characters, so show that under the alias field and keep Save disabled.
*/
private fun updateSaveState() {
val alias = binding.etAlias.text?.toString()?.trim() ?: ""
val tooLong = selectedDest?.isBml == false && alias.length > MIB_MAX_ALIAS
binding.tilAlias.error = if (tooLong) getString(R.string.contact_alias_mib_max, MIB_MAX_ALIAS) else null
binding.btnSave.isEnabled = !tooLong && mibLookupAccount != null
} }
private fun setupCategoryDropdown() { private fun setupCategoryDropdown() {
@@ -307,8 +367,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML) // 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
val mibSess = app.anyMibSession() ?: return null val mibSess = app.anyMibSession() ?: return null
val mibFlow = app.anyMibFlow() ?: return null
return try { return try {
val info = MibTransferClient().lookup(mibSess, input) val info = MibTransferClient(mibFlow).lookup(mibSess, input)
BmlAccountValidation( BmlAccountValidation(
trnType = "DOT", trnType = "DOT",
validationType = "MIB", validationType = "MIB",
@@ -328,8 +389,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val profile = dest.mibProfile ?: return null val profile = dest.mibProfile ?: return null
val mibResult = try { val mibResult = try {
app.mibFlowFor(loginId).switchProfile(mibSess, profile) val flow = app.mibFlowFor(loginId)
val info = MibTransferClient().lookup(mibSess, input) flow.switchProfile(mibSess, profile)
val info = MibTransferClient(flow).lookup(mibSess, input)
BmlAccountValidation( BmlAccountValidation(
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL", trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
validationType = "MIB", validationType = "MIB",
@@ -398,7 +460,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.etCurrency.setText(validation.currency) binding.etCurrency.setText(validation.currency)
binding.btnClearResult.setOnClickListener { clearLookupResult() } binding.btnClearResult.setOnClickListener { clearLookupResult() }
binding.btnSave.isEnabled = true updateSaveState()
} }
private fun clearLookupResult() { private fun clearLookupResult() {
@@ -438,6 +500,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun setupSaveButton() { private fun setupSaveButton() {
binding.btnSave.setOnClickListener { performSave() } binding.btnSave.setOnClickListener { performSave() }
binding.etAlias.addTextChangedListener(afterTextChanged = { updateSaveState() })
} }
private fun performSave() { private fun performSave() {
@@ -447,6 +510,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.tilAlias.error = "Alias is required" binding.tilAlias.error = "Alias is required"
return return
} }
if (!dest.isBml && alias.length > MIB_MAX_ALIAS) {
updateSaveState()
return
}
binding.tilAlias.error = null binding.tilAlias.error = null
binding.btnSave.isEnabled = false binding.btnSave.isEnabled = false
@@ -454,11 +521,16 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) {
if (dest.isBml) saveToBml(alias) else saveToMib(alias) when {
editContact != null -> updateMib(alias)
dest.isBml -> saveToBml(alias)
else -> saveToMib(alias)
}
} }
if (success) { if (success) {
Toast.makeText(requireContext(), R.string.contact_saved, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.contact_saved, Toast.LENGTH_SHORT).show()
reloadSavedProfileContacts(dest) // Same reload as pull-to-refresh on the contacts page
(activity as? HomeActivity)?.loadAllContacts(showIndicator = true)
dismiss() dismiss()
} else { } else {
binding.btnSave.isEnabled = true binding.btnSave.isEnabled = true
@@ -495,15 +567,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462" val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462"
val isMibInternal = account.matches(Regex("^9\\d{16}$")) val isMibInternal = account.matches(Regex("^9\\d{16}$"))
val benefType = if (isMibInternal) "I" else "L"
val bankNo = if (isMibInternal) 2 else 3 val bankNo = if (isMibInternal) 2 else 3
val name = bmlLookup?.name ?: "" val name = bmlLookup?.name ?: ""
return try { return try {
app.mibFlowFor(loginId).switchProfile(mibSess, profile) app.mibFlowFor(loginId).switchProfile(mibSess, profile)
MibContactsClient().createContact( MibContactsClient(app.mibFlowFor(loginId)).createContact(
session = mibSess, session = mibSess,
benefType = benefType,
bankNo = bankNo, bankNo = bankNo,
benefAccount = account, benefAccount = account,
benefName = name, benefName = name,
@@ -515,6 +585,33 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} catch (_: Exception) { false } } catch (_: Exception) { false }
} }
private fun updateMib(alias: String): Boolean {
val contact = editContact ?: return false
val dest = selectedDest ?: return false
val loginId = dest.mibLoginId ?: return false
val mibSess = app.mibSessions[loginId] ?: return false
val profile = dest.mibProfile ?: return false
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462"
val bankNo = if (contact.benefAccount.matches(Regex("^9\\d{16}$"))) 2 else 3
return try {
val flow = app.mibFlowFor(loginId)
flow.switchProfile(mibSess, profile)
MibContactsClient(flow).updateContact(
session = mibSess,
benefNo = contact.benefNo,
bankNo = bankNo,
benefAccount = contact.benefAccount,
benefName = contact.benefName,
nickName = alias,
transferCy = transferCy,
categoryId = selectedCategoryId,
imageBase64 = selectedImageBase64
)
} catch (_: Exception) { false }
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap { private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = (resources.displayMetrics.density * 40).toInt() val sizePx = (resources.displayMetrics.density * 40).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY } val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
@@ -532,35 +629,6 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
return bm return bm
} }
private fun reloadSavedProfileContacts(dest: DestinationOption) {
// Use activity scope so the reload continues after sheet dismisses
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
try {
if (dest.isBml) {
val loginId = dest.bmlLoginId ?: return@launch
val bmlSess = app.bmlSessions[loginId] ?: return@launch
val fresh = BmlContactsClient().fetchContacts(bmlSess, loginId)
val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
viewModel.contacts.postValue(merged)
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
} else {
val profile = dest.mibProfile ?: return@launch
val mibLoginId = dest.mibLoginId ?: return@launch
val mibSess = app.mibSessions[mibLoginId] ?: return@launch
app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile)
val fresh = MibContactsClient().fetchContacts(mibSess)
.map { it.copy(profileId = profile.profileId) }
val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.profileId != profile.profileId } + fresh
viewModel.contacts.postValue(merged)
val allMib = merged.filter { it.benefCategoryId != "BML" }
ContactsCache.save(requireContext(), allMib, viewModel.contactCategories.value ?: emptyList())
}
} catch (_: Exception) { }
}
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
@@ -570,10 +638,23 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
// BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts // BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts
private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A" private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"
// MIB rejects longer nicknames with "invalid nick name"
private const val MIB_MAX_ALIAS = 20
private const val ARG_BML_PROFILE_ID = "bml_profile_id" private const val ARG_BML_PROFILE_ID = "bml_profile_id"
private const val ARG_ACCOUNT_NUMBER = "account_number" private const val ARG_ACCOUNT_NUMBER = "account_number"
private const val ARG_RECIPIENT_NAME = "recipient_name" private const val ARG_RECIPIENT_NAME = "recipient_name"
private const val ARG_CURRENCY = "currency" private const val ARG_CURRENCY = "currency"
private const val ARG_EDIT_BENEF_NO = "edit_benef_no"
private const val ARG_EDIT_PROFILE_ID = "edit_profile_id"
/** Opens the sheet to edit an existing MIB contact (alias, group, photo). */
fun forEdit(contact: ContactDisplay) = AddContactSheetFragment().apply {
arguments = Bundle().apply {
putString(ARG_EDIT_BENEF_NO, contact.id)
putString(ARG_EDIT_PROFILE_ID, contact.profileId)
}
}
fun newInstance( fun newInstance(
bmlProfileId: String? = null, bmlProfileId: String? = null,
@@ -77,7 +77,15 @@ class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank)) activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
} }
binding.btnEdit.setOnClickListener { binding.btnEdit.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() // Only contacts saved in MIB can be edited for now
val isMibContact = contact.network == TransferNetwork.MIB || contact.network == TransferNetwork.LOCAL
val parent = contactsFragment
if (isMibContact && parent != null) {
dismiss()
AddContactSheetFragment.forEdit(contact).show(parent.childFragmentManager, "edit_contact")
} else {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
} }
binding.btnDelete.setOnClickListener { binding.btnDelete.setOnClickListener {
val parent = contactsFragment val parent = contactsFragment
@@ -356,7 +356,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val base64 = if (hash in profileImageHashes) { val base64 = if (hash in profileImageHashes) {
app.anyMibFlow()?.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
@@ -143,6 +143,8 @@ class ContactsFragment : Fragment() {
binding.swipeRefresh.setOnRefreshListener { binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh() (activity as? HomeActivity)?.triggerRefresh()
// triggerRefresh() only covers accounts; reload contacts from the banks too
(activity as? HomeActivity)?.loadAllContacts()
binding.swipeRefresh.isRefreshing = false binding.swipeRefresh.isRefreshing = false
} }
@@ -257,7 +259,7 @@ class ContactsFragment : Fragment() {
return return
} }
val sess = session ?: return val sess = session ?: return
val client = MibContactsClient() val client = MibContactsClient(app.anyMibFlow() ?: return)
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch
@@ -30,8 +30,10 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -61,6 +63,7 @@ import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibCardsClient import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.api.mib.MibFinancingClient import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.api.mib.MibProfileClient
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
@@ -428,10 +431,10 @@ class HomeActivity : AppCompatActivity() {
for (session in sessions) { for (session in sessions) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " + val cookieHeader = "mbmodel=AND-1.0; xxid=${session.xxid}; " +
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597" "IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
val request = Request.Builder() val request = Request.Builder()
.url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive") .url("https://faisamobilex-wv-v2.mib.com.mv/aProfile/keepAlive")
.post(ByteArray(0).toRequestBody()) .post(ByteArray(0).toRequestBody())
.header("Cookie", cookieHeader) .header("Cookie", cookieHeader)
.header("User-Agent", "okhttp/4.11.0") .header("User-Agent", "okhttp/4.11.0")
@@ -819,6 +822,8 @@ fun applyNavLabelVisibility() {
app.mibProfilesMap[loginId] = flow.lastProfiles app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles) store.saveMibProfiles(loginId, flow.lastProfiles)
// Logins saved before v2 have no cached name/email/mobile/ID card yet
try { MibProfileClient(flow).cacheIfMissing(store, loginId, flow.lastSession!!) } catch (_: Exception) { }
accounts accounts
} catch (e: java.io.IOException) { } catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET") refreshErrors.add("NO_INTERNET")
@@ -1210,10 +1215,10 @@ fun applyNavLabelVisibility() {
} }
} }
private fun refreshBmlContacts(app: BasedBankApp) { private fun refreshBmlContacts(app: BasedBankApp): Job? {
if (app.bmlSessions.isEmpty()) return if (app.bmlSessions.isEmpty()) return null
val store = CredentialStore(this) val store = CredentialStore(this)
lifecycleScope.launch { return lifecycleScope.launch {
try { try {
val allBmlContacts = withContext(Dispatchers.IO) { val allBmlContacts = withContext(Dispatchers.IO) {
store.getBmlLoginIds().flatMap { loginId -> store.getBmlLoginIds().flatMap { loginId ->
@@ -1233,7 +1238,8 @@ fun applyNavLabelVisibility() {
} }
} }
fun loadAllContacts() { /** Loads contacts from cache, then refreshes them from every bank. [showIndicator] shows the top refresh bar until done. */
fun loadAllContacts(showIndicator: Boolean = false) {
val app = application as BasedBankApp val app = application as BasedBankApp
val store = sh.sar.basedbank.util.CredentialStore(this) val store = sh.sar.basedbank.util.CredentialStore(this)
// Populate ViewModel from cache immediately if empty // Populate ViewModel from cache immediately if empty
@@ -1249,16 +1255,24 @@ fun applyNavLabelVisibility() {
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
} }
// Refresh all banks in background // Refresh all banks in background
val jobs = mutableListOf<Job>()
for ((loginId, session) in app.mibSessions) { for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList() val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId)) refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))?.let { jobs.add(it) }
}
refreshBmlContacts(app)?.let { jobs.add(it) }
for ((_, session) in app.fahipaySessions) jobs.add(refreshFahipayContacts(session))
if (showIndicator && jobs.isNotEmpty()) {
setRefreshing(true)
lifecycleScope.launch {
jobs.joinAll()
setRefreshing(false)
}
} }
refreshBmlContacts(app)
for ((_, session) in app.fahipaySessions) refreshFahipayContacts(session)
} }
private fun refreshFahipayContacts(session: FahipaySession) { private fun refreshFahipayContacts(session: FahipaySession): Job {
lifecycleScope.launch { return lifecycleScope.launch {
try { try {
val groups = withContext(Dispatchers.IO) { val groups = withContext(Dispatchers.IO) {
FahipayContactsClient().fetchContacts(session) FahipayContactsClient().fetchContacts(session)
@@ -1288,11 +1302,11 @@ fun applyNavLabelVisibility() {
return result return result
} }
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) { private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>): Job? {
if (profiles.isEmpty()) return if (profiles.isEmpty()) return null
val flow = (application as BasedBankApp).mibFlowFor(loginId) val flow = (application as BasedBankApp).mibFlowFor(loginId)
val contactsClient = MibContactsClient() val contactsClient = MibContactsClient(flow)
lifecycleScope.launch { return lifecycleScope.launch {
try { try {
val (allContacts, allCategories) = withContext(Dispatchers.IO) { val (allContacts, allCategories) = withContext(Dispatchers.IO) {
val seenContacts = mutableSetOf<String>() val seenContacts = mutableSetOf<String>()
@@ -1302,10 +1316,12 @@ fun applyNavLabelVisibility() {
for (profile in profiles) { for (profile in profiles) {
try { try {
flow.switchProfile(session, profile) flow.switchProfile(session, profile)
for (cat in contactsClient.fetchCategories(session)) { val (profileContacts, profileCategories) =
contactsClient.fetchContactsAndCategories(session)
for (cat in profileCategories) {
if (seenCategories.add(cat.id)) categories.add(cat) if (seenCategories.add(cat.id)) categories.add(cat)
} }
for (contact in contactsClient.fetchContacts(session)) { for (contact in profileContacts) {
if (seenContacts.add(contact.benefNo)) if (seenContacts.add(contact.benefNo))
contacts.add(contact.copy(profileId = profile.profileId)) contacts.add(contact.copy(profileId = profile.profileId))
} }
@@ -353,15 +353,16 @@ class OtpFragment : Fragment() {
val session = app.mibSessions[loginId] ?: continue val session = app.mibSessions[loginId] ?: continue
val flow = app.mibFlowFor(loginId) val flow = app.mibFlowFor(loginId)
val profile = withContext(Dispatchers.IO) { val profile = withContext(Dispatchers.IO) {
try { MibProfileClient().fetchPersonalProfile(session) } catch (_: Exception) { null } try { MibProfileClient(flow).fetchPersonalProfile(session) } catch (_: Exception) { null }
} }
if (profile != null) { if (profile != null) {
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile( store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
fullName = profile.fullName, fullName = profile.fullName,
username = profile.username, username = loginId,
email = profile.email, email = profile.email,
mobile = profile.mobile, mobile = profile.mobile,
enrolled = profile.enrolled enrolled = "",
idCard = profile.idCard
)) ))
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId } val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true } if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
@@ -668,10 +668,13 @@ class SettingsLoginsFragment : Fragment() {
scroll.addView(container) scroll.addView(container)
// Account info lines // Account info lines
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
listOfNotNull( listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" }, profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" }, profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" } profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
).forEach { line -> ).forEach { line ->
container.addView(TextView(ctx).apply { container.addView(TextView(ctx).apply {
text = line text = line
@@ -52,6 +52,7 @@ import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.databinding.ItemTransferTypeBinding import sh.sar.basedbank.databinding.ItemTransferTypeBinding
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
import sh.sar.basedbank.ui.home.transfer.BmlVerifiedCards import sh.sar.basedbank.ui.home.transfer.BmlVerifiedCards
import sh.sar.basedbank.ui.home.transfer.CardPayoutService
import sh.sar.basedbank.ui.home.transfer.CardPayoutTransferHandler import sh.sar.basedbank.ui.home.transfer.CardPayoutTransferHandler
import sh.sar.basedbank.ui.home.transfer.CarrierLookup import sh.sar.basedbank.ui.home.transfer.CarrierLookup
import sh.sar.basedbank.ui.home.transfer.FahipayService import sh.sar.basedbank.ui.home.transfer.FahipayService
@@ -407,7 +408,7 @@ class TransferFragment : Fragment() {
return@setFragmentResultListener return@setFragmentResultListener
} }
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: "" val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
if (applyFahipayContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) { if (applyServiceContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) {
return@setFragmentResultListener return@setFragmentResultListener
} }
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
@@ -453,7 +454,7 @@ class TransferFragment : Fragment() {
// Pre-select contact if navigated from contacts page or QR scan // Pre-select contact if navigated from contacts page or QR scan
arguments?.getString(ARG_ACCOUNT)?.let { account -> arguments?.getString(ARG_ACCOUNT)?.let { account ->
val name = arguments?.getString(ARG_NAME) ?: account val name = arguments?.getString(ARG_NAME) ?: account
if (applyFahipayContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let if (applyServiceContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let
prefillToDirectly( prefillToDirectly(
accountNumber = account, accountNumber = account,
displayName = name, displayName = name,
@@ -1125,7 +1126,7 @@ class TransferFragment : Fragment() {
binding.etTo.setOnItemClickListener { _, _, position, _ -> binding.etTo.setOnItemClickListener { _, _, position, _ ->
val contact = adapter.getContact(position) ?: return@setOnItemClickListener val contact = adapter.getContact(position) ?: return@setOnItemClickListener
if (applyFahipayContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) { if (applyServiceContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
return@setOnItemClickListener return@setOnItemClickListener
} }
prefillToDirectly( prefillToDirectly(
@@ -1141,15 +1142,27 @@ class TransferFragment : Fragment() {
} }
/** /**
* A saved Fahipay favourite: its list ([categoryId]) already says which service pays it, so * A saved Fahipay favourite, or a recent paid with a Fahipay or card service: its category
* that service is applied as the only transfer type, with no carrier lookup. That switches * ([categoryId]) already says which service pays it, so that service is applied as the only
* the source to the Fahipay wallet and brings in the service's amount rules, the same as a * transfer type, with no carrier lookup. That switches the source to the Fahipay wallet or
* searched number. Returns false, doing nothing, when [categoryId] isn't a Fahipay list. * the default card and brings in the service's amount rules, the same as a searched number.
* Returns false, doing nothing, when [categoryId] isn't one of those.
*/ */
private fun applyFahipayContact(number: String, categoryId: String?, name: String): Boolean { private fun applyServiceContact(number: String, categoryId: String?, name: String): Boolean {
val service = FahipayService.fromContactCategory(categoryId) ?: return false val ownerName = name.takeIf { it.isNotBlank() && it != number }
val type = FahipayService.fromContactCategory(categoryId)?.let { TransferType.Fahipay(it, ownerName) }
?: CardPayoutService.fromContactCategory(categoryId)?.let { service ->
// The cards that could pay it then may not be payable now
val cards = cardPayoutHandler().payableCards()
if (cards.isEmpty()) {
Toast.makeText(requireContext(), R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return true
}
TransferType.Card(service, ownerName, cards)
}
?: return false
clearRecipient() clearRecipient()
offerTransferTypes(number, listOf(TransferType.Fahipay(service, name.takeIf { it.isNotBlank() && it != number }))) offerTransferTypes(number, listOf(type))
return true return true
} }
@@ -302,7 +302,7 @@ class TransferHistoryFragment : Fragment() {
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile) if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
for (state in states) { for (state in states) {
try { try {
val (list, total) = MibHistoryClient().fetchHistory( val (list, total) = MibHistoryClient(app.mibFlowFor(loginId)).fetchHistory(
session = session, session = session,
accountNo = state.account.accountNumber, accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName, accountDisplayName = state.account.accountBriefName,
@@ -382,7 +382,7 @@ class TransferHistoryFragment : Fragment() {
val sess = app.anyMibSession() ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
ContactImageCache.save(requireContext(), hash, bitmap) ContactImageCache.save(requireContext(), hash, bitmap)
@@ -250,7 +250,7 @@ class TransferReceiptFragment : Fragment() {
val base64 = if (isProfile) { val base64 = if (isProfile) {
app.anyMibFlow()?.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
@@ -550,7 +550,14 @@ class BmlTransferHandler(
fragment.viewLifecycleOwner.lifecycleScope.launch { fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
runCatching { runCatching {
BmlMerchantCardPayClient().pay(page, card) { _ -> Totp.generate(otpSeed) } BmlMerchantCardPayClient().pay(page, card) { retry ->
// A code about to roll over can expire before the ACS checks it, and a
// retry in the same window would resend the rejected code: wait for the
// next window in both cases. Runs on IO.
val left = TOTP_WINDOW_MS - System.currentTimeMillis() % TOTP_WINDOW_MS
if (retry || left < TOTP_MIN_LEFT_MS) Thread.sleep(left + 500)
Totp.generate(otpSeed)
}
}.getOrElse { }.getOrElse {
BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed") BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed")
} }
@@ -957,4 +964,11 @@ class BmlTransferHandler(
} }
private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account) private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account)
private companion object {
/** The BML token's TOTP window. */
const val TOTP_WINDOW_MS = 30_000L
/** Don't send a card payment's 3-D Secure code with less than this left in its window. */
const val TOTP_MIN_LEFT_MS = 5_000L
}
} }
@@ -9,7 +9,9 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguReloadClient import sh.sar.basedbank.api.dhiraagu.DhiraaguPaymentClient
import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.api.ooredoo.OoredooPaymentClient
import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeViewModel import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment import sh.sar.basedbank.ui.home.TransferFragment
@@ -20,8 +22,8 @@ import java.math.RoundingMode
* A carrier service a verified BML card can pay, through the carrier's own website and its BML * A carrier service a verified BML card can pay, through the carrier's own website and its BML
* merchant gateway. The limits are the carrier website's, not Fahipay's. * merchant gateway. The limits are the carrier website's, not Fahipay's.
* *
* Only Dhiraagu reload so far. Add the others as constants once their send path lands; the * Add new services as constants; the exhaustive `when`s over this enum point at every site that
* exhaustive `when`s over this enum point at every site that needs updating. * needs updating.
*/ */
enum class CardPayoutService( enum class CardPayoutService(
override val label: String, override val label: String,
@@ -31,13 +33,37 @@ enum class CardPayoutService(
override val maxAmount: Int?, override val maxAmount: Int?,
override val decimalsAllowed: Boolean, override val decimalsAllowed: Boolean,
override val gstPercent: Int?, override val gstPercent: Int?,
/**
* Tags the recents paid with this service (`RecentPick.contactCategory`), so picking one
* again pays the same way. Not a real contact list: card payouts have no favourites.
*/
val contactCategory: String,
override val gstAdded: Boolean = false,
) : PayoutService { ) : PayoutService {
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo, DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8) { minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8,
contactCategory = "CARD_DHIRAAGU_RELOAD") {
// Dhiraagu rounds the GST to 2 places and credits the rest // Dhiraagu rounds the GST to 2 places and credits the rest
override fun creditedAfterGst(amount: BigDecimal): BigDecimal = override fun creditedAfterGst(amount: BigDecimal): BigDecimal =
amount - DhiraaguReloadClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt()) amount - DhiraaguPaymentClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt())
}; },
// Easy Pay sets no limits of its own: any amount with up to 2 decimals, no GST
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
minAmount = 1, maxAmount = null, decimalsAllowed = true, gstPercent = null,
contactCategory = "CARD_DHIRAAGU_BILL"),
// Ooredoo credits the whole amount and charges the card 8% GST on top
OOREDOO_RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
minAmount = 20, maxAmount = null, decimalsAllowed = false, gstPercent = 8,
contactCategory = "CARD_RAASTAS", gstAdded = true),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
minAmount = 10, maxAmount = null, decimalsAllowed = true, gstPercent = null,
contactCategory = "CARD_OOREDOO_BILL");
companion object {
/** The service a recent was paid with, from its [contactCategory], or null when it isn't one. */
fun fromContactCategory(categoryId: String?): CardPayoutService? =
entries.firstOrNull { it.contactCategory == categoryId }
}
} }
/** /**
@@ -78,7 +104,17 @@ class CardPayoutTransferHandler(
fun typesFor(result: CarrierLookup.Result, cards: Set<String>): List<TransferType.Card> { fun typesFor(result: CarrierLookup.Result, cards: Set<String>): List<TransferType.Card> {
if (cards.isEmpty()) return emptyList() if (cards.isEmpty()) return emptyList()
return buildList { return buildList {
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(CardPayoutService.DHIRAAGU_RELOAD) when (result.dhiraagu.type) {
DhiraaguClient.CustType.RELOAD -> add(CardPayoutService.DHIRAAGU_RELOAD)
DhiraaguClient.CustType.BILL_PAY -> add(CardPayoutService.DHIRAAGU_BILL)
DhiraaguClient.CustType.UNSUPPORTED -> {}
}
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) {
add(CardPayoutService.OOREDOO_RAASTAS)
}
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) {
add(CardPayoutService.OOREDOO_BILL)
}
}.map { TransferType.Card(it, result.ownerName, cards) } }.map { TransferType.Card(it, result.ownerName, cards) }
} }
@@ -115,7 +151,8 @@ class CardPayoutTransferHandler(
displayName = displayName, displayName = displayName,
subtitle = "${type.label} · $number", subtitle = "${type.label} · $number",
colorHex = "#E4002B", colorHex = "#E4002B",
imageHash = null imageHash = null,
contactCategory = type.service.contactCategory
) )
fragment.focusAmount() fragment.focusAmount()
} }
@@ -136,6 +173,7 @@ class CardPayoutTransferHandler(
val number = viewModel.transferDraft.transferTypeNumber val number = viewModel.transferDraft.transferTypeNumber
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull() val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
val charged = svc.chargedWithGst(amount)
// Creating the order takes a few round trips; show the payment's processing box meanwhile // Creating the order takes a few round trips; show the payment's processing box meanwhile
val processing = fragment.showProcessingDialog(ctx.getString(R.string.transfer)) val processing = fragment.showProcessingDialog(ctx.getString(R.string.transfer))
@@ -144,7 +182,13 @@ class CardPayoutTransferHandler(
runCatching { runCatching {
val txnId = when (svc) { val txnId = when (svc) {
CardPayoutService.DHIRAAGU_RELOAD -> CardPayoutService.DHIRAAGU_RELOAD ->
DhiraaguReloadClient().createBmlTransaction(number, amount.intValueExact()) DhiraaguPaymentClient().createReloadTransaction(number, amount.intValueExact())
CardPayoutService.DHIRAAGU_BILL ->
DhiraaguPaymentClient().createBillPayTransaction(number, amount)
CardPayoutService.OOREDOO_RAASTAS ->
OoredooPaymentClient().createRaastasTransaction(number, amount.intValueExact(), charged)
CardPayoutService.OOREDOO_BILL ->
OoredooPaymentClient().createBillPayTransaction(number, amount)
} }
BmlMerchantTxnClient().fetchPayPage(txnId) BmlMerchantTxnClient().fetchPayPage(txnId)
} }
@@ -155,8 +199,8 @@ class CardPayoutTransferHandler(
when { when {
!it.supportsCard -> !it.supportsCard ->
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show() Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
// The carrier's order must be for exactly what was typed // The carrier's order must be for exactly what was typed (plus GST, if added)
it.amount.toBigDecimal().compareTo(amount) != 0 -> it.amount.toBigDecimal().compareTo(charged) != 0 ->
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show() Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
else -> bmlHandler().confirmCardMerchant(it, src) else -> bmlHandler().confirmCardMerchant(it, src)
} }
@@ -8,6 +8,7 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibLookupException import sh.sar.basedbank.api.mib.MibLookupException
import sh.sar.basedbank.api.mib.MibSession import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.api.mib.MibTransferClient import sh.sar.basedbank.api.mib.MibTransferClient
@@ -40,6 +41,10 @@ class MibTransferHandler(
val session: MibSession? val session: MibSession?
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession() get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession()
/** The login flow matching [session], used for encrypted API calls. */
private val flow: MibLoginFlow?
get() = currentSource()?.loginTag?.removePrefix("mib_")?.let { app.mibFlowFor(it) } ?: app.anyMibFlow()
/** Any logged-in MIB session, ignoring which source is selected. */ /** Any logged-in MIB session, ignoring which source is selected. */
val anySession: MibSession? get() = app.anyMibSession() val anySession: MibSession? get() = app.anyMibSession()
@@ -54,8 +59,9 @@ class MibTransferHandler(
*/ */
fun lookupDestination(accountNumber: String): LookupOutcome { fun lookupDestination(accountNumber: String): LookupOutcome {
val sess = session ?: return LookupOutcome(null, notFound()) val sess = session ?: return LookupOutcome(null, notFound())
val flow = flow ?: return LookupOutcome(null, notFound())
return try { return try {
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null) LookupOutcome(MibTransferClient(flow).lookup(sess, accountNumber), null)
} catch (e: MibLookupException) { } catch (e: MibLookupException) {
LookupOutcome(null, e.message) LookupOutcome(null, e.message)
} catch (_: Exception) { } catch (_: Exception) {
@@ -69,7 +75,8 @@ class MibTransferHandler(
*/ */
fun lookupCurrency(accountNumber: String): String { fun lookupCurrency(accountNumber: String): String {
val sess = session ?: return "" val sess = session ?: return ""
return try { MibTransferClient().lookup(sess, accountNumber).currency } val flow = flow ?: return ""
return try { MibTransferClient(flow).lookup(sess, accountNumber).currency }
catch (_: Exception) { "" } catch (_: Exception) { "" }
} }
@@ -88,7 +95,7 @@ class MibTransferHandler(
val base64 = if (isProfile) { val base64 = if (isProfile) {
app.anyMibFlow()?.fetchProfileImage(session, hash) app.anyMibFlow()?.fetchProfileImage(session, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(session, hash) app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(session, hash)
} }
if (base64 == null) null else { if (base64 == null) null else {
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
@@ -100,8 +107,9 @@ class MibTransferHandler(
/** /**
* Switches to the profile that owns [src], then runs the transfer with a freshly generated * Switches to the profile that owns [src], then runs the transfer with a freshly generated
* TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is * TOTP. If MIB rejects the OTP as invalid or already used, waits for the next OTP window and
* "CONNECTIVITY" when the network was unreachable. * retries once; a second rejection is returned as a failure. Blocking — call from IO.
* Returns `(ok, message, receipt)`; the message is "CONNECTIVITY" when the network was unreachable.
*/ */
fun doTransfer( fun doTransfer(
src: BankAccount, src: BankAccount,
@@ -114,8 +122,7 @@ class MibTransferHandler(
): Triple<Boolean, String, TransferReceiptData?> { ): Triple<Boolean, String, TransferReceiptData?> {
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null) val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
val loginId = src.loginTag.removePrefix("mib_") val loginId = src.loginTag.removePrefix("mib_")
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed val otpSeed = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null) ?: return Triple(false, "OTP unavailable", null)
val currencyCode = if (src.currencyName == "USD") "840" else "462" val currencyCode = if (src.currencyName == "USD") "840" else "462"
val currency = if (src.currencyName == "USD") "USD" else "MVR" val currency = if (src.currencyName == "USD") "USD" else "MVR"
@@ -145,17 +152,26 @@ class MibTransferHandler(
val profile = profiles.firstOrNull { it.profileId == src.profileId } val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile) if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
} }
val result = MibTransferClient().transfer( val client = MibTransferClient(app.mibFlowFor(loginId))
session = sess, val send = { otp: String ->
fromAccount = src.accountNumber, client.transfer(
toAccount = destAccount, session = sess,
amount = amount, fromAccount = src.accountNumber,
currencyCode = currencyCode, toAccount = destAccount,
benefName = destName.ifBlank { "Recipient" }, amount = amount,
bankNo = bankNo, currencyCode = currencyCode,
purpose = remarks, benefName = destName.ifBlank { "Recipient" },
otp = otp bankNo = bankNo,
) purpose = remarks,
otp = otp
)
}
var result = send(Totp.generate(otpSeed))
if (!result.success && result.reasonCode in OTP_REJECTED_CODES) {
// The code was stale or already spent; the next window's code is always fresh
Thread.sleep(Totp.millisUntilNextPeriod() + OTP_WINDOW_MARGIN_MS)
result = send(Totp.generate(otpSeed))
}
if (result.success) { if (result.success) {
val receipt = TransferReceiptData( val receipt = TransferReceiptData(
bank = "MIB", bank = "MIB",
@@ -181,4 +197,13 @@ class MibTransferHandler(
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null) Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
} }
} }
private companion object {
val OTP_REJECTED_CODES = setOf(
MibTransferClient.REASON_INVALID_OTP,
MibTransferClient.REASON_OTP_ALREADY_USED
)
// Small delay past the window boundary so device and server clocks agree on the new code
const val OTP_WINDOW_MARGIN_MS = 1_000L
}
} }
@@ -25,17 +25,33 @@ interface PayoutService {
val maxAmount: Int? val maxAmount: Int?
/** Whether the amount may have a fractional part (up to 2 decimal places). */ /** Whether the amount may have a fractional part (up to 2 decimal places). */
val decimalsAllowed: Boolean val decimalsAllowed: Boolean
/** GST the carrier takes out of the amount before crediting it, in percent, or null for none. */ /** GST the carrier charges, in percent, or null for none. [gstAdded] says how. */
val gstPercent: Int? val gstPercent: Int?
/**
* False (the usual): GST comes out of the amount, so the number is credited less
* ([creditedAfterGst]). True: the number is credited the whole amount and GST is charged on
* top of it ([chargedWithGst]).
*/
val gstAdded: Boolean get() = false
/** /**
* What the number is credited for a GST-inclusive [amount]: amount / (1 + rate), rounded * What the number is credited for a GST-inclusive [amount]: amount / (1 + rate), rounded
* down. Null when the service charges no GST. * down. Null when the service charges no GST, or adds it on top.
*/ */
fun creditedAfterGst(amount: BigDecimal): BigDecimal? { fun creditedAfterGst(amount: BigDecimal): BigDecimal? {
val gst = gstPercent ?: return null val gst = gstPercent ?: return null
if (gstAdded) return null
return amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN) return amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
} }
/**
* What is paid for [amount]: amount + round2(amount × rate) when GST is added on top,
* otherwise [amount] itself.
*/
fun chargedWithGst(amount: BigDecimal): BigDecimal {
val gst = gstPercent?.takeIf { gstAdded } ?: return amount
return amount + (amount * BigDecimal(gst).movePointLeft(2)).setScale(2, RoundingMode.HALF_UP)
}
} }
/** /**
@@ -81,11 +97,18 @@ class PayoutAmountField(
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
} }
/** What the recipient is credited once GST comes out, for services that charge it. */ /**
* For services that charge GST: what the recipient is credited once it comes out, or what
* is paid once it's added on top.
*/
fun gstNote(svc: PayoutService): String? { fun gstNote(svc: PayoutService): String? {
val gst = svc.gstPercent ?: return null val gst = svc.gstPercent ?: return null
val ctx = context() val ctx = context()
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull() val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (svc.gstAdded) {
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_gst_added_hint, gst)
return ctx.getString(R.string.transfer_gst_added_pay, "%,.2f".format(svc.chargedWithGst(amount)), gst)
}
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst) if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
val credited = svc.creditedAfterGst(amount) ?: return null val credited = svc.creditedAfterGst(amount) ?: return null
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst) return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
@@ -255,17 +255,7 @@ class CredentialsFragment : Fragment() {
store.saveMibCredentials(loginId, username, passwordHash, otpSeed) store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
flow.lastSession?.let { s -> flow.lastSession?.let { s ->
val profile = MibProfileClient().fetchPersonalProfile(s) try { MibProfileClient(flow).cacheIfMissing(store, loginId, s) } catch (_: Exception) { }
if (profile != null) store.saveMibUserProfile(
loginId,
CredentialStore.MibUserProfile(
fullName = profile.fullName,
username = profile.username,
email = profile.email,
mobile = profile.mobile,
enrolled = profile.enrolled
)
)
} }
} }
store.saveMibProfiles(loginId, flow.lastProfiles) store.saveMibProfiles(loginId, flow.lastProfiles)
@@ -27,14 +27,18 @@ object ContactManager {
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean { private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
val sess = app.anyMibSession() ?: return false val sess = app.anyMibSession() ?: return false
return try { return try {
var flow = app.anyMibFlow() ?: return false
if (contact.profileId.isNotBlank()) { if (contact.profileId.isNotBlank()) {
val (loginId, profile) = app.mibProfilesMap.entries val (loginId, profile) = app.mibProfilesMap.entries
.firstNotNullOfOrNull { (id, profiles) -> .firstNotNullOfOrNull { (id, profiles) ->
profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it } profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it }
} ?: (null to null) } ?: (null to null)
if (profile != null && loginId != null) app.mibFlowFor(loginId).switchProfile(sess, profile) if (profile != null && loginId != null) {
flow = app.mibFlowFor(loginId)
flow.switchProfile(sess, profile)
}
} }
MibContactsClient().deleteContact(sess, contact.id) MibContactsClient(flow).deleteContact(sess, contact.id)
} catch (_: Exception) { false } } catch (_: Exception) { false }
} }
} }
@@ -669,7 +669,8 @@ class CredentialStore(context: Context) {
val username: String, val username: String,
val email: String, val email: String,
val mobile: String, val mobile: String,
val enrolled: String val enrolled: String,
val idCard: String? = null // null = not yet fetched from the v2 API
) )
data class BmlUserProfile( data class BmlUserProfile(
@@ -736,6 +737,7 @@ class CredentialStore(context: Context) {
put("email", p.email) put("email", p.email)
put("mobile", p.mobile) put("mobile", p.mobile)
put("enrolled", p.enrolled) put("enrolled", p.enrolled)
if (p.idCard != null) put("idCard", p.idCard)
}.toString() }.toString()
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply() prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply()
@@ -752,7 +754,8 @@ class CredentialStore(context: Context) {
username = o.optString("username"), username = o.optString("username"),
email = o.optString("email"), email = o.optString("email"),
mobile = o.optString("mobile"), mobile = o.optString("mobile"),
enrolled = o.optString("enrolled") enrolled = o.optString("enrolled"),
idCard = if (o.has("idCard")) o.optString("idCard") else null
) )
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
@@ -127,7 +127,7 @@ class HistoryFetcher(private val account: BankAccount) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList() val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == account.profileId } val profile = profiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile) if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory( val (list, total) = MibHistoryClient(app.mibFlowFor(loginId)).fetchHistory(
session = session, session = session,
accountNo = account.accountNumber, accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName, accountDisplayName = account.accountBriefName,
@@ -18,6 +18,12 @@ object Totp {
return otp.toString().padStart(digits, '0') return otp.toString().padStart(digits, '0')
} }
/** Milliseconds until the next code becomes current. */
fun millisUntilNextPeriod(periodSeconds: Long = 30): Long {
val periodMillis = periodSeconds * 1000L
return periodMillis - System.currentTimeMillis() % periodMillis
}
private fun hotp(key: ByteArray, counter: Long, digits: Int): Int { private fun hotp(key: ByteArray, counter: Long, digits: Int): Int {
val msg = ByteArray(8) { i -> ((counter shr ((7 - i) * 8)) and 0xFF).toByte() } val msg = ByteArray(8) { i -> ((counter shr ((7 - i) * 8)) and 0xFF).toByte() }
val mac = Mac.getInstance("HmacSHA1") val mac = Mac.getInstance("HmacSHA1")
@@ -12,6 +12,7 @@
android:padding="16dp"> android:padding="16dp">
<TextView <TextView
android:id="@+id/tvTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
+390 -107
View File
@@ -1,149 +1,432 @@
<?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">ތިޖޫރީ</string> <string name="app_name">ތިޖޫރީ</string>
<!-- Onboarding --> <!-- Onboarding -->
<string name="onboarding_supported_services">ހިދުމަތްތައް</string> <string name="onboarding_supported_services">ޚިދުމަތްތައް</string>
<string name="select_language">ބަސް ހިޔާލު ކުރޭ</string> <string name="select_language">ބަސް އިޚްތިޔާރުކުރޭ</string>
<string name="onboarding_title_1">ތިޔަ ބޭންކްތައް، އެއް އެޕެއްގައި</string> <string name="onboarding_title_1">ހުރިހާ ބޭންކެއް އެއް އެޕަކުން</string>
<string name="onboarding_desc_1">ތިޖޫރީ ގެ ސަބަބުން ތިޔަ ދިވެހި ބޭންކު އެކައުންޓްތައް، ހަމައެއް ތަނަކުން ބެލޭ. ބެލެންސް ބެލޭ، ތަފާތު ތަންތަން ބެލޭ — ތަފާތު އެޕްތަކަށް ބަދަލު ނުވެ.</string> <string name="onboarding_desc_1">ރާއްޖޭގެ ބޭންކުތަކުގައިވާ ތިޔަބޭފުޅާގެ އެކައުންޓްތައް އެއްތަނަކުން ބަލާލެވޭ. އެކި އެޕްތަކަށް ވަންނަންނުޖެހި ބެލެންސް ބެލުމާއި އެކައުންޓްތަކުގެ މަޢުލޫމާތު ފަސޭހަކަމާއެކު ބަލާލެވޭ.</string>
<string name="onboarding_title_2">އިތުރު ބޭންކްތައް ހިމެނެނީ</string> <string name="onboarding_title_2">އިތުރު ބޭންކުތައް ވަރަށް އަވަހަށް</string>
<string name="onboarding_desc_2">އިތުރު ބޭންކްތަކަށް ސަޕޯޓް ލިބޭ ގޮތަށް ތައްޔާރުވަމުން ދަނީ. ދިވެހިރާއްޖޭގެ ބޭންކްތަކަށް ސަޕޯޓް ފަހި ވަމުން ދިޔަ ވަރަކަށް ހިމަނެމުން ދޭ.</string> <string name="onboarding_desc_2">އިތުރު ބޭންކުތަކުގެ ޚިދުމަތްތައް މި އެޕަށް އިތުރުކުރުމުގެ މަސައްކަތް ދަނީ ކުރެވެމުންނެވެ.</string>
<string name="onboarding_title_3">ފެށޭ ގޮތަށް ތައްޔާރު</string> <string name="onboarding_title_3">ސަމާލުކަމަށް</string>
<string name="onboarding_desc_3">ތިޔަ ބޭންކު ކްރެޑެންޝަލް ޖެހި، ތިޔަ އެކައުންޓްތައް ބަލާ. ތިޔަ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.\n\nDhiraagu އާއި Ooredoo ގެ API ބޭނުންކޮށްގެން ފޯން ނަންބަރުގެ ތަފްސީލު ބެލޭ.\n\nމި އެޕް ތިޔައާ ބެހޭ ތަފްސީލެއް ނެހެދޭ، ޑިވެލޮޕަރަށް ވެސް ނުފޮނުވާ. ހުރިހާ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.</string> <string name="onboarding_desc_3">ތިޖޫރީ އަކީ އެއްވެސް ބޭންކަކާ ނުވަތަ މާލީ އިދާރާއަކާ ގުޅުމެއްނެތި އަމިއްލައަށް އުފައްދާފައިވާ އެޕެކެވެ.\n\nމި އެޕް މަސައްކަތްކުރަނީ ތިޔަބޭފުޅާގެ އިންޓަނެޓް ބޭންކިންގ މަޢުލޫމާތު ބޭނުންކޮށްގެން ސީދާ ބޭންކުގެ އޭޕީއައިތަކާ ގުޅިގެންނެވެ. ބޭންކުތަކުގެ ސިސްޓަމްތަކަށް އަންނަ ބަދަލަކުން އެޕްގެ މަސައްކަތަށް ބުރޫއަރާފާނެއެވެ.\n\nފޯނު ނަންބަރުގެ މަޢުލޫމާތު ހޯދުމަށް ދިރާގާއި އުރީދޫގެ އޭޕީއައި ބޭނުންކުރެވެއެވެ.\n\nމި އެޕުން ތިޔަބޭފުޅާގެ އެއްވެސް އަމިއްލަ މަޢުލޫމާތެއް ނުވަތަ އެޕް ބޭނުންކުރާ ގޮތުގެ މަޢުލޫމާތެއް އެއްނުކުރާނެއެވެ. ހުރިހާ މަޢުލޫމާތެއް ރައްކާކުރެވޭނީ ހަމައެކަނި ތިޔަބޭފުޅާގެ ފޯނުގައެވެ.\n\nކުރިއަށް ވަޑައިގަތުމުން އަންނަނިވި ކަންކަމަށް އެއްބަސްވެވޭނެއެވެ:\n\n• ޚިދުމަތަށް މެދުކެނޑުމެއް ނުވަތަ މައްސަލައެއް ދިމާވެދާނެކަން\n• ބޭންކުން ތިޔަބޭފުޅާގެ އެކައުންޓަށް ފިޔަވަޅެއް އަޅާފާނެކަން\n• އެޕް ބޭނުންކުރުމުން ދިމާވެދާނެ އެއްވެސް ގެއްލުމަކަށް ޑިވެލޮޕަރު ޒިންމާނުވާނެކަން\n• މި އެޕް ބޭނުންކުރަނީ އަމިއްލަ ޒިންމާގައިކަން</string>
<string name="coming_soon">ފަހުން ލިބޭ</string> <string name="coming_soon">ވަރަށް އަވަހަށް އަންނީ</string>
<string name="next">ދެން</string> <string name="next">ކުރިއަށް</string>
<string name="get_started">ފަށާ</string> <string name="get_started">ފަށަމާ</string>
<!-- Login --> <!-- Login -->
<string name="select_bank">ބޭންކެއް ހިޔާރު ކުރޭ</string> <string name="select_bank">ބޭންކު އިޚްތިޔާރުކުރޭ</string>
<string name="select_bank_desc">ލޮގިން ވާ ބޭންކު ހިޔާރު ކުރޭ.</string> <string name="select_bank_desc">ލޮގިންވާން ބޭނުންވާ ބޭންކު ނަގާ</string>
<string name="mib_name">Maldives Islamic Bank</string> <string name="mib_name">މޯލްޑިވްސް އިސްލާމިކް ބޭންކް</string>
<string name="mib_desc">ފައިސާނެޓް މޯބައިލް ބޭންކިން</string> <string name="mib_desc">ފައިސާނެޓް މޮބައިލް ބޭންކިންގ</string>
<string name="bml_name">Bank of Maldives</string> <string name="bml_name">ބޭންކް އޮފް މޯލްޑިވްސް</string>
<string name="bml_desc">BML އިންޓަނެޓް ބޭންކިން</string> <string name="bml_desc">ބީއެމްއެލް އިންޓަނެޓް ބޭންކިންގ</string>
<string name="sign_in">ލޮގިން ވޭ</string> <string name="fahipay_name">ފަހިޕޭ</string>
<string name="sign_in_desc">Maldives Islamic Bank ގެ ކްރެޑެންޝަލް ދިވޭ.</string> <string name="fahipay_desc">ޑިޖިޓަލް ވޮލެޓް</string>
<string name="username">ޔޫޒަރ ނޭމް</string> <string name="fahipay_sign_in_desc">އައިޑީ ކާޑު ނަންބަރާއި ޕާސްވޯޑް ޖަހާ</string>
<string name="fahipay_id_card">އައިޑީ ކާޑު ނަންބަރު</string>
<string name="fahipay_totp_code">އޮތެންޓިކޭޓަރ ކޯޑު (6 ނަންބަރު)</string>
<string name="fahipay_totp_hint">އޮތެންޓިކޭޓަރ އެޕުން ލިބޭ ކޯޑު ޖަހާ</string>
<string name="fahipay_verify">ޔަގީން</string>
<string name="ooredoo_name">އޯރިޑޫ އެމް-ފައިސާ</string>
<string name="ooredoo_desc">މޮބައިލް ވޮލެޓް</string>
<string name="ooredoo_sign_in_desc">މޮބައިލް ނަންބަރާއި 4 އަދަދުގެ އެމް-ޕިން ޖަހާ</string>
<string name="ooredoo_phone">މޮބައިލް ނަންބަރު</string>
<string name="ooredoo_pin">އެމް-ޕިން</string>
<string name="sign_in">ލޮގިން</string>
<string name="sign_in_desc">އެމްއައިބީ އިންޓަނެޓް ބޭންކިންގ މަޢުލޫމާތު ޖަހާ</string>
<string name="bml_sign_in_desc">ބީއެމްއެލް އިންޓަނެޓް ބޭންކިންގ މަޢުލޫމާތު ޖަހާ</string>
<string name="username">ޔޫސަރނޭމް</string>
<string name="password">ޕާސްވޯޑް</string> <string name="password">ޕާސްވޯޑް</string>
<string name="otp_seed">OTP ސީޑް (TOTP ސިއްރު)</string> <string name="otp_seed">އޯޓީޕީ ސީޑް (ޓީއޯޓީޕީ ސީކްރެޓް)</string>
<string name="otp_seed_hint">ތިޔަ އޮތެންޓިކޭޓަ ދިން Base32 ސިއްރު</string> <string name="otp_seed_hint">އޮތެންޓިކޭޓަރ ސެޓަޕުން ލިބުނު ސީކްރެޓް ކޯޑު</string>
<string name="scan_otp_qr">އޯޓީޕީ ކިއުއާރު ސުކޭންކޮއްލާ</string>
<string name="login">ލޮގިން</string> <string name="login">ލޮގިން</string>
<!-- Lock screen --> <!-- Lock screen -->
<string name="unlock_app">ތިޖޫރީ ހުޅުވާ</string> <string name="unlock_app">ތިޖޫރީ ހުޅުވާ</string>
<string name="unlock_pin_subtitle">PIN ޖަހާ</string> <string name="unlock_pin_subtitle">ޕިން ނަންބަރު ޖަހާ</string>
<string name="unlock_pattern_subtitle">ހުޅުވާ ޕެޓަން ކަހާ</string> <string name="unlock_pattern_subtitle">ޕެޓަރން ކުރަހާ</string>
<string name="use_biometrics">ބަޔޮމެޓްރިކް ބޭނުން ކުރޭ</string> <string name="use_biometrics">ބަޔޯމެޓްރިކްސް ބޭނުންކުރޭ</string>
<string name="biometric_prompt_subtitle">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ</string> <string name="biometric_prompt_subtitle">އިނގިލީގެ ނިޝާން ނުވަތަ ފޭސް އައިޑީ ބޭނުންކުރޭ</string>
<string name="biometric_negative_btn">PIN / ޕެޓަން ބޭނުން ކުރޭ</string> <string name="biometric_negative_btn">ޕިން / ޕެޓަން ބޭނުންކުރޭ</string>
<string name="unlock_failed">ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string> <string name="unlock_failed">ނުބައި - އަލުން މަސައްކަތްކޮއްލާ</string>
<string name="unlock_attempts_remaining">ދިމައެއް ނުވި — %d ފަހަރު ބާކީ</string> <string name="unlock_attempts_remaining">ނުބައި - ބާކީ %d ފުރުޞަތު</string>
<string name="unlock_locked_out">ވަރަށް ގިނަ ފަހަރު ނުކުރިހުރި. %d ސިކުންތު ފަހުން ލޮކް ހިލޭ.</string> <string name="unlock_locked_out">ގިނަފަހަރު ނުބައިކޮށް ޖެހުމުން ބްލޮކްވެއްޖެ. %d ސިކުންތު ފަހުން އަލުން މަސައްކަތްކޮއްލާ.</string>
<!-- Security setup --> <!-- Security setup -->
<string name="security_setup">އެޕް ރައްކާތެރި ކުރޭ</string> <string name="security_setup">އެޕް ރައްކާތެރިކުރޭ</string>
<string name="security_setup_desc">ތިޖޫރީ ހުޅުވަން ބޭނުންވާ ގޮތެއް ހިޔާރު ކުރޭ.</string> <string name="security_setup_desc">އެޕް ތަޅުލުމަށް ބޭނުންކުރާނެ ގޮތެއް އިޚްތިޔާރުކުރޭ.</string>
<string name="method_pin">PIN ކޯޑް</string> <string name="security_already_configured">އެޕް ލޮކް ކުރެވިފައި</string>
<string name="method_pin_desc">4–8 ރިޔަލެއްގެ ނަންބަރު PIN</string> <string name="security_already_configured_desc">އެޕް ތަޅުލާނެ ގޮތް ވަނީ ސެޓްކުރެވިފައި.</string>
<string name="method_pattern">ޕެޓަން ކަހާ</string>
<string name="method_pattern_desc">4 ނުވަތަ އެއަށްވުރެ ގިނަ ތިކި ގުޅުވާ</string> <string name="method_pin">ޕިން ކޯޑު</string>
<string name="enter_pin">PIN ޖަހާ</string> <string name="method_pin_desc">4 އާއި 8 އަދަދާ ދެމެދުގެ ނަންބަރެއް</string>
<string name="confirm_pin">PIN ކަށަވަރު ކުރޭ</string> <string name="method_pattern">ޕެޓަރން</string>
<string name="pin_min_digits">މަދުވެގެން 4 ރިޔަލ، ގިނަވެގެން 8</string> <string name="method_pattern_desc">މަދުވެގެން 4 ތިކި ގުޅުވައިގެން ޕެޓަރން ކުރަހާ</string>
<string name="pin_no_match">PIN ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string> <string name="enter_pin">ޕިން ކޯޑެއް ޚިޔާރުކުރޭ</string>
<string name="draw_pattern">ޕެޓަން ކަހާ</string> <string name="confirm_pin">ޕިން ކޯޑު ޔަގީންކުރޭ</string>
<string name="confirm_pattern">ޕެޓަން ކަށަވަރު ކުރޭ</string> <string name="pin_min_digits">މަދުވެގެން 4 އަދަދު، ގިނަވެގެން 8 އަދަދު</string>
<string name="pattern_min_dots">މަދުވެގެން 4 ތިކި ގުޅުވާ</string> <string name="pin_no_match">ޕިން ކޯޑު ދިމައެއްނުވޭ - އަލުން ޖަހާ</string>
<string name="pattern_draw_again">އެ ޕެޓަން އަލުން ކަހާ</string> <string name="draw_pattern">ޕެޓަރންއެއް ކުރަހާ</string>
<string name="pattern_no_match">ޕެޓަން ދިމައެއް ނުވި — އަލުން ކަހާ</string> <string name="confirm_pattern">ޕެޓަރން ޔަގީންކުރޭ</string>
<string name="biometric_title">ބަޔޮމެޓްރިކް ބޭނުން ކުރަންތަ؟</string> <string name="pattern_min_dots">މަދުވެގެން 4 ތިކި ގުޅުވާލާ</string>
<string name="biometric_desc">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވޭ.</string> <string name="pattern_draw_again">އަލުން އެ ޕެޓަން ކުރަހާ</string>
<string name="biometric_security_note">ބަޔޮމެޓްރިކް ފަސޭހަ ނަމަވެސް PIN ނުވަތަ ޕެޓަނަށްވުރެ ތަންކޮޅެއް ކަށަވަރެއް ނޫން. ރައްކާތެރިކަން ބޭނުން ނަމަ PIN ނުވަތަ ޕެޓަން ހިޔާރު ކުރޭ.</string> <string name="pattern_no_match">ޕެޓަން ދިމައެއްނުވޭ - އަލުން ކުރަހާ</string>
<string name="enable_biometrics">ބަޔޮމެޓްރިކް ހިންގާ</string> <string name="biometric_title">ބަޔޯމެޓްރިކްސް ބޭނުންކުރަންތޯ؟</string>
<string name="skip_biometrics">ސްކިޕް — PIN/ޕެޓަން ބޭނުން ކުރޭ</string> <string name="biometric_desc">ޕިން ނުވަތަ ޕެޓަންގެ ބަދަލުގައި އިނގިލީގެ ނިޝާން ނުވަތަ ފޭސް އައިޑީއިން އެޕް ހުޅުވާލެވޭނެ.</string>
<string name="biometric_security_note">ބަޔޯމެޓްރިކްސްއަކީ ފަސޭހަ ގޮތެއް ނަމަވެސް، އެންމެ ރައްކާތެރި ގޮތަކީ ޕިން ނުވަތަ ޕެޓަން ބޭނުންކުރުން.</string>
<string name="enable_biometrics">ބަޔޯމެޓްރިކްސް އޮންކުރޭ</string>
<string name="skip_biometrics">ބޭނުމެއްނޫން - ހަމައެކަނި ޕިން/ޕެޓަން</string>
<string name="back">ފަހަތަށް</string> <string name="back">ފަހަތަށް</string>
<!-- Navigation --> <!-- Navigation -->
<string name="nav_dashboard">ޑޭޝްބޯޑް</string> <string name="nav_dashboard">ޑޭޝްބޯޑު</string>
<string name="nav_add_account">އެކައުންޓް އިތުރު ކުރޭ</string> <string name="nav_add_account">އެކައުންޓެއް އިތުރުކުރޭ</string>
<string name="nav_accounts">އެކައުންޓްތައް</string> <string name="nav_accounts">އެކައުންޓްތައް</string>
<string name="nav_contacts">ކޮންޓެކްޓްތައް</string> <string name="nav_contacts">ކޮންޓެކްޓްތައް</string>
<string name="nav_activities">ހަރަކާތްތައް</string> <string name="nav_activities">އެންމެ ފަހުގެ މުޢާމަލާތްތައް</string>
<string name="nav_transfer_history">ޓްރާންސެކްޝަން ތާރީހް</string> <string name="nav_transfer_history">ހިސްޓްރީ</string>
<string name="nav_finances">ފައިނޭންސް</string> <string name="nav_finances">ފައިނޭންސިންގ</string>
<string name="nav_pay_with_card">ކާޑްތައް</string> <string name="nav_card_settings">ކާޑު ސެޓިންގްސް</string>
<string name="nav_desc_pay_with_card">ކާޑް މެނޭޖްކޮށް ފައިސާ ދައްކާ</string> <string name="nav_otp">އޯޓީޕީ ކޯޑު</string>
<string name="nav_settings">ސެޓިންގ</string> <string name="nav_settings">ސެޓިންގްސް</string>
<string name="nav_desc_accounts">ހުރިހާ ބޭންކް އެކައުންޓްތައް ބަލާ</string> <string name="nav_more">އިތުރު</string>
<string name="nav_desc_contacts">ޓްރާންސްފަ ކޮންޓެކްޓްތައް މެނޭޖް ކުރޭ</string> <string name="nav_desc_accounts">ހުރިހާ އެކައުންޓެއްގެ މަޢުލޫމާތު</string>
<string name="nav_desc_transfer">ކޮންޓެކްޓަކަށް ފައިސާ ފޮނުވާ</string> <string name="nav_desc_contacts">ފައިސާ ފޮނުވާ ކޮންޓެކްޓްތައް</string>
<string name="nav_desc_pay_mv_qr">PayMV QR ކޯޑް ސްކޭން ނުވަތަ ތައްޔާރު ކުރޭ</string> <string name="nav_desc_transfer">ފައިސާ ފޮނުއްވުމަށް</string>
<string name="nav_desc_activities">ފަހުގެ ޓްރާންސްފަތައް ބަލާ</string> <string name="nav_desc_pay_mv_qr">ޕޭއެމްވީ ކިއުއާރު ހެދުމަށް ނުވަތަ ސުކޭން ކުރުމަށް</string>
<string name="nav_desc_transfer_history">އެކައުންޓް ތަކުގެ ޓްރާންސެކްޝަން ތާރީހް</string> <string name="nav_desc_activities">އެންމެ ފަހުން ފޮނުވި ފައިސާގެ ތަފްޞީލު</string>
<string name="nav_desc_finances">ލޯން އަދި ފައިނޭންސިންގ</string> <string name="nav_desc_transfer_history">އެކައުންޓްތަކުގެ ފުރިހަމަ ހިސްޓްރީ</string>
<string name="nav_desc_otp">OTP ކޯޑް ތައްޔާރު ކުރޭ</string> <string name="nav_desc_finances">ލޯނާއި ފައިނޭންސިންގ މަޢުލޫމާތު</string>
<string name="nav_desc_settings">އެޕްލިކޭޝަންގެ ތަރުތީބު</string> <string name="nav_desc_pay_with_card">ކާޑުތައް ބަލަހައްޓުމާއި ކާޑުން ފައިސާ ދެއްކުން</string>
<string name="nav_desc_otp">އޮތެންޓިކޭޝަނަށް އޯޓީޕީ ކޯޑު ހޯދުން</string>
<string name="nav_desc_settings">އެޕްގެ ސެޓިންގްސް ބަދަލުކުރުމަށް</string>
<string name="nav_open_drawer">ނެވިގޭޝަން ހުޅުވާ</string> <string name="nav_open_drawer">ނެވިގޭޝަން ހުޅުވާ</string>
<string name="nav_close_drawer">ނެވިގޭޝަން ލައްޕާ</string> <string name="nav_close_drawer">ނެވިގޭޝަން ލައްޕާ</string>
<string name="work_in_progress">ތައްޔާރުވަމުން ދަނީ</string> <string name="work_in_progress">މަސައްކަތް ކުރިއަށްދަނީ</string>
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string> <string name="press_back_to_exit">އެޕުން ނުކުންނެވުމަށް އަނެއްކާވެސް ފަހަތަށް އޮބާލާ</string>
<!-- Dashboard --> <!-- Dashboard -->
<string name="dashboard_quick_actions">ހަލުވި ހަރަކާތްތައް</string> <string name="dashboard_pending_finances">ނުދައްކާ ހުރި ފައިނޭންސް</string>
<string name="balance_mvr">ޖުމްލަ MVR</string> <string name="dashboard_quick_actions">އަވަސް ޚިދުމަތްތައް</string>
<string name="balance_usd">ޖުމްލަ USD</string> <string name="balance_mvr">ޖުމްލަ ދިވެހި ރުފިޔާ</string>
<string name="card_support_wip">ކާޑް ސަޕޯޓް</string> <string name="balance_usd">ޖުމްލަ ޔޫއެސް ޑޮލަރު</string>
<string name="transfer">ޓްރާންސްފަ</string> <string name="balance_mvr_credit">ލިބެންހުރި ކްރެޑިޓް (ރުފިޔާ)</string>
<string name="pay_mv_qr">PayMV QR</string> <string name="balance_usd_credit">ލިބެންހުރި ކްރެޑިޓް (ޑޮލަރު)</string>
<string name="card_support_wip">ކާޑުގެ ޚިދުމަތް</string>
<string name="transfer">ޓްރާންސްފަރ</string>
<string name="pay_mv_qr">ޕޭއެމްވީ ކިއުއާރު</string>
<!-- PayMV QR Generator -->
<string name="paymvqr_select_account">އެކައުންޓް ނަގާ</string>
<string name="paymvqr_amount_hint">އަދަދު (ބޭނުންނަމަ)</string>
<string name="paymvqr_share">ޙިއްޞާކޮއްލާ</string>
<string name="paymvqr_save_image">ފޮޓޯ ސޭވްކޮއްލާ</string>
<string name="paymvqr_saved">ކިއުއާރު ފޮޓޯ ގެލަރީއަށް ސޭވްކުރެވިއްޖެ</string>
<string name="paymvqr_save_failed">ފޮޓޯ ސޭވެއް ނުކުރެވުނު</string>
<string name="paymvqr_include_phone">ފޯނު ނަންބަރު ހިމަނާ</string>
<string name="paymvqr_reference_hint">ރެފަރެންސް (ބޭނުންނަމަ)</string>
<!-- Toolbar -->
<string name="action_lock">އެޕް ތަޅުލާ</string>
<string name="action_hide_amounts">މަޢުލޫމާތު ފޮރުވާ</string>
<string name="action_show_amounts">މަޢުލޫމާތު ދައްކާ</string>
<string name="autolock_warning_title">އެޕް ތަޅުލެވެނީ</string>
<string name="autolock_warning_message">%d ސިކުންތު ތެރޭގައި ތަޅުލެވޭނެ</string>
<string name="autolock_stay">ނުތަޅުލާ މަޑުކުރޭ</string>
<string name="autolock_lock_now">މިހާރު ތަޅުލާ</string>
<!-- Settings --> <!-- Settings -->
<string name="settings_bottom_bar_shortcuts">ތިރި ބާ ޝޯޓްކަޓްތައް</string> <string name="settings_security">ރައްކާތެރިކަން</string>
<string name="settings_bottom_bar_select">ބަޓަން ހިޔާރު ކުރޭ</string> <string name="settings_change_lock">ޕިން / ޕެޓަން ބަދަލުކުރުމަށް</string>
<string name="settings_bottom_bar_slot_1">ތަން 1</string> <string name="settings_biometrics">ބަޔޯމެޓްރިކްސް ބޭނުންކުރުމަށް</string>
<string name="settings_bottom_bar_slot_2">ތަން 2</string> <string name="settings_biometrics_unavailable">މި ފޯނުގައި ބަޔޯމެޓްރިކްސް ސެޓްއަޕްކޮށްފައެއް ނެތް</string>
<string name="settings_bottom_bar_slot_3">ތަން 3</string> <string name="settings_biometrics_unlock">އެޕް ހުޅުވުމަށް</string>
<string name="settings_biometrics_transfer">ފައިސާ ފޮނުވުން ކަށަވަރުކުރުމަށް</string>
<string name="biometric_transfer_title">ޓްރާންސްފަރ ކަށަވަރުކުރުމަށް</string>
<string name="settings_autolock">އަމިއްލައަށް ތަޅުލެވުން</string>
<string name="autolock_off">އަމިއްލައަށް ތަޅެއްނުލެވޭނެ</string>
<string name="autolock_30s">30 ސިކުންތު</string>
<string name="autolock_1m">1 މިނެޓު</string>
<string name="autolock_3m">3 މިނެޓު</string>
<string name="autolock_5m">5 މިނެޓު</string>
<string name="theme">ތީމް</string> <string name="theme">ތީމް</string>
<string name="theme_system">ސިސްޓަމް</string> <string name="theme_system">ފޯނުގެ ސެޓިންގްސްއާ އެއްގޮތަށް</string>
<string name="theme_light">ލައިޓް</string> <string name="theme_light">އަލި</string>
<string name="theme_dark">ޑާކް</string> <string name="theme_dark">އަނދިރި</string>
<string name="settings_pitch_black">ގަދަ އަނދިރި (އޯލެޑް)</string>
<string name="settings_accent_color">މައި ކުލަ</string>
<string name="settings_receipts">ރަސީދު</string>
<string name="settings_always_fullscreen_receipt">މުޅި ސްކްރީނުން ރަސީދު ދައްކާ</string>
<string name="accent_blue">ނޫ</string>
<string name="accent_orange">ރަތް</string>
<string name="accent_green">ފެހި</string>
<string name="accent_custom">ބޭނުން ކުލައެއް</string>
<string name="accent_custom_pick">ބޭނުން ކުލައެއް އިޚްތިޔާރުކުރޭ</string>
<string name="accent_custom_hint">#RRGGBB ކުލައިގެ ކޯޑު</string>
<string name="accent_invalid_color">ނުބައި ކުލަ ކޯޑެއް - ރަނގަޅު ކޯޑެއް ޖަހާ</string>
<string name="language">ބަސް</string> <string name="language">ބަސް</string>
<string name="lang_english">English</string> <string name="lang_english">English</string>
<string name="lang_dhivehi">ދިވެހި</string> <string name="lang_dhivehi">ދިވެހި</string>
<string name="settings_privacy">ޕްރައިވެސީ</string> <string name="settings_privacy">ޕްރައިވަސީ</string>
<string name="settings_auto_unlock_pin">ރަނގަޅު ޕިން އެޅުމުން ހުޅުވޭ</string> <string name="settings_hide_amounts">ފައިސާގެ އަދަދުތައް ނިވާކުރުން</string>
<string name="settings_auto_unlock_pin_desc">ޕިންގެ ދިގުމިނާ އެއްވަރަށް ޑިޖިޓް ލިޔުމުން ހުޅުވިދާ</string> <string name="settings_hide_amounts_desc">އެޕްގެ ހުރިހާ ތަނަކުން އެކައުންޓް ބެލެންސާއި ފައިސާގެ އަދަދުތައް ނިވާކޮށްދޭނެ</string>
<string name="settings_block_screenshots">ސްކްރީންޝޮޓް ބްލޮކްކުރޭ</string> <string name="settings_auto_unlock_pin">ރަނގަޅު ޕިން ޖެހުމުން އަމިއްލައަށް ހުޅުވޭ</string>
<string name="settings_block_screenshots_desc">ރިސެންޓްސް ސްކްރީނުންނާއި ސްކްރީން ކެޕްޗާ ހުއްޓުވައިދޭ</string> <string name="settings_auto_unlock_pin_desc">ޕިން ކޯޑު ހަމަވުމާއެކު އަމިއްލައަށް އެޕް ހުޅުވޭނެ</string>
<string name="settings_block_screenshots">ސުކްރީންޝޮޓް ނެގުން ހުއްޓުވާ</string>
<string name="settings_block_screenshots_desc">އެޕްގެ ސުކްރީންޝޯޓް ނެގުމާއި ރީސެންޓްސްއިން ފެނުން ހުއްޓުވޭނެ</string>
<string name="settings_cache">ކޭޝް</string> <string name="settings_cache">ކޭޝް</string>
<string name="settings_clear_cache">ކޭޝް ސާފުކުރޭ</string> <string name="settings_clear_cache">ކޭޝް ސާފުކޮއްލާ</string>
<string name="settings_cache_cleared">ކޭޝް ސާފުކުރެވިއްޖެ</string> <string name="settings_cache_cleared">ކޭޝް ސާފުކުރެވިއްޖެ</string>
<string name="settings_navigation">ނެވިގޭޝަން</string>
<string name="settings_nav_drawer">ސައިޑް މެނޫ</string>
<string name="settings_nav_bottom">ތިރި މެނޫ</string>
<string name="settings_nav_circular">ބުރު މެނޫ</string>
<string name="settings_appearance">ފެންނަ ގޮތް</string>
<string name="settings_circular_shortcuts">ބުރު މެނޫ ޝޯޓްކަޓް</string>
<string name="settings_bottom_bar_shortcuts">ތިރި މެނޫ ޝޯޓްކަޓް</string>
<string name="settings_bottom_bar_show_labels">ތިރި މެނޫގެ ނަންތައް އަބަދުވެސް ދައްކާ</string>
<string name="settings_bottom_bar_select">ފިއްތައް އިޚްތިޔާރުކުރޭ</string>
<string name="settings_bottom_bar_slot_1">ޖާގަ 1</string>
<string name="settings_bottom_bar_slot_2">ޖާގަ 2</string>
<string name="settings_bottom_bar_slot_3">ޖާގަ 3</string>
<string name="settings_privacy_security">ޕްރައިވަސީ އާއި ރައްކާތެރިކަން</string>
<string name="settings_storage">ސްޓޯރޭޖް</string>
<string name="settings_logins">ލޮގިންތައް</string> <string name="settings_logins">ލޮގިންތައް</string>
<string name="settings_desc_logins">ބޭންކް ލޮގިންތައް މެނޭޖް ކުރޭ</string> <string name="settings_desc_logins">ބޭންކު އެކައުންޓްތަކުގެ ލޮގިން މެނޭޖްކުރުމަށް</string>
<string name="settings_desc_appearance">ތީމް، ބަސް، އަދި ދައްކުވާ ގޮތް</string> <string name="settings_desc_appearance">ތީމް، ބަސް، އަދި ފެންނަ ގޮތުގެ ސެޓިންގްސް</string>
<string name="settings_desc_privacy_security">އެޕް ލޮކް، ޕިން، އަދި ސަލާމަތީ ސެޓިންގ</string> <string name="settings_desc_privacy_security">އެޕް ލޮކް، ޕިން، އަދި ރައްކާތެރިކަމުގެ ސެޓިންގްސް</string>
<string name="settings_desc_storage">ކޭޝް ޑޭޓާ އަދި ސްޓޯރޭޖް</string> <string name="settings_desc_storage">ކޭޝް ކުރެވިފައިވާ މަޢުލޫމާތާއި ސްޓޯރޭޖް މެނޭޖްކުރުމަށް</string>
<string name="settings_notifications">ނޯޓިފިކޭޝަން</string>
<string name="settings_desc_notifications">އަލަށް ހިނގާ ފައިސާގެ މުޢާމަލާތްތަކުގެ ނޯޓިފިކޭޝަން</string>
<string name="settings_notif_section">ބެކްގްރައުންޑް ޗެކް</string>
<string name="settings_notif_enable">ބެކްގްރައުންޑް ނޯޓިފިކޭޝަން އޮންކުރޭ</string>
<string name="settings_notif_enable_desc">އާ މުޢާމަލާތްތައް ހިނގުމުން ވަގުތުން ނޯޓިފިކޭޝަން ލިބޭނެ</string>
<string name="settings_notif_description">އެޕް ބެކްގްރައުންޑްގައި ހިނގަމުންދާނެއެވެ. އަދި އާ މުޢާމަލާތެއް ހިނގައިފިނަމަ ނޮޓިފިކޭޝަން ފޮނުވާނެއެވެ. މިކަން ހިނގާއިރު ސްޓޭޓަސް ބާގައި ނޯޓިފިކޭޝަނެއް އިންނާނެއެވެ.</string>
<string name="settings_notif_open_system">ނޯޓިފިކޭޝަން ސެޓިންގްސް</string>
<string name="settings_notif_channels_desc">އަޑާއި ނޯޓިފިކޭޝަން ސެޓިންގްސް ބަދަލުކުރުމަށް</string>
<string name="notif_service_title">ތިޖޫރީ</string>
<string name="notif_service_desc">އާ މުޢާމަލާތްތައް ޗެކްކުރެވެނީ...</string>
<string name="notif_channel_service">ބެކްގްރައުންޑް ޚިދުމަތް</string>
<string name="settings_about">އެޕާ ބެހޭ</string>
<string name="settings_desc_about">އެޕްގެ މަޢުލޫމާތާއި، ވާޝަން، އަދި ގާނޫނީ</string>
<string name="about_version">ވާޝަން %s</string>
<string name="about_short_desc">ތިޖޫރީ އަކީ ރާއްޖޭގެ ބޭންކިންގ ޚިދުމަތްތަކަށް ޚާއްޞަކޮށްގެން ތައްޔާރުކުރެވިފައިވާ އެންޑްރޮއިޑް އެޕެއް.</string>
<string name="about_terms">ޚިދުމަތުގެ އުޞޫލުތައް</string>
<string name="about_donate_title">އެޕް ކުރިއެރުވުމަށް އެހީތެރިވެދެއްވާ</string>
<string name="about_donate_desc">މި އެޕަކީ ތިޔަބޭފުޅާއަށް ފައިދާހުރި އެޕެއްނަމަ، އެޕް އިތުރަށް ތަރައްޤީކުރުމަށް ކުޑަ އެހީއެއް ވެދެއްވާ.</string>
<string name="about_donate_mvr">ދިވެހި ރުފިޔާއިން އެހީވުމަށް</string>
<string name="about_donate_usd">ޑޮލަރުން އެހީވުމަށް</string>
<string name="about_legal">ތިޖޫރީ އަކީ އެއްވެސް ބޭންކަކާ ނުވަތަ މާލީ އިދާރާއަކާ ގުޅުމެއްނެތި އަމިއްލައަށް އުފައްދާފައިވާ އެޕެކެވެ.\n\nމި އެޕް މަސައްކަތްކުރަނީ ތިޔަބޭފުޅާގެ އިންޓަނެޓް ބޭންކިންގ މަޢުލޫމާތު ބޭނުންކޮށްގެން ސީދާ ބޭންކުގެ އޭޕީއައިތަކާ ގުޅިގެންނެވެ. ބޭންކުތަކުގެ ސިސްޓަމްތަކަށް އަންނަ ބަދަލަކުން އެޕްގެ މަސައްކަތަށް ބުރޫއަރާފާނެއެވެ.\n\nފޯނު ނަންބަރުގެ މަޢުލޫމާތު ހޯދުމަށް ދިރާގާއި އޯރިޑޫގެ އޭޕީއައި ބޭނުންކުރެވެއެވެ.\n\nމި އެޕުން ތިޔަބޭފުޅާގެ އެއްވެސް އަމިއްލަ މަޢުލޫމާތެއް ނުވަތަ އެޕް ބޭނުންކުރާ ގޮތުގެ މަޢުލޫމާތެއް އެއްނުކުރާނެއެވެ. ހުރިހާ މަޢުލޫމާތެއް ރައްކާކުރެވޭނީ ހަމައެކަނި ތިޔަބޭފުޅާގެ ފޯނުގައެވެ.</string>
<string name="settings_logout">ލޮގްއައުޓް</string> <string name="settings_logout">ލޮގްއައުޓް</string>
<string name="settings_logout_confirm_title">%s އިން ލޮގްއައުޓް ވަންތަ؟</string> <string name="settings_logout_confirm_title">%s އިން ލޮގްއައުޓްވާންވީތޯ؟</string>
<string name="settings_logout_confirm_message">ހުރިހާ ކޭޝް ޑޭޓާ ސާފުވެ، ބާކީ ހުރި އެކައުންޓްތައް އަލުން ލޯޑްވާނެ.</string> <string name="settings_logout_confirm_message">ރައްކާކުރެވިފައިވާ ހުރިހާ މަޢުލޫމާތެއް ފުހެވި، ބާކީ ހުރި އެކައުންޓްތައް ރީފްރެޝްކުރެވޭނެ.</string>
<string name="login_detail_name">ނަން</string> <string name="login_detail_name">ނަން</string>
<string name="login_detail_username">ޔޫޒަރ ނޭމް</string> <string name="login_detail_username">ޔޫސަރނޭމް</string>
<string name="login_detail_email">އީމެއިލް</string> <string name="login_detail_email">އީމެއިލް</string>
<string name="login_detail_mobile">މޮބައިލް</string> <string name="login_detail_mobile">މޮބައިލް ނަންބަރު</string>
<string name="login_detail_customer_id">ކަސްޓަމަ ID</string> <string name="login_detail_customer_id">ކަސްޓަމަރ އައިޑީ</string>
<string name="login_detail_id_card">ID ކާޑް</string> <string name="login_detail_id_card">އައިޑީ ކާޑު</string>
<string name="login_detail_profiles">ޕްރޮފައިލްތައް</string> <string name="login_detail_profiles">ޕްރޮފައިލްތައް</string>
<string name="close">ބަންދު</string> <string name="profile_image_title">ޕްރޮފައިލް ފޮޓޯ</string>
<string name="profile_image_select">ފޮޓޯ އިޚްތިޔާރުކުރޭ</string>
<string name="profile_image_camera">ކެމެރާއިން ފޮޓޯއެއް ނަގާ</string>
<string name="profile_image_remove">ފުހެލާ</string>
<string name="profile_image_uploading">ފޮޓޯ އަޕްލޯޑުކުރެވެނީ…</string>
<string name="profile_image_upload_failed">ފޮޓޯ އަޕްލޯޑެއް ނުކުރެވުނު</string>
<string name="profile_image_deleting">ފޮޓޯ ފުހެލެވެނީ…</string>
<string name="close">ލައްޕާ</string>
<string name="save">ސޭވްކުރޭ</string>
<string name="cancel">ކެންސަލް</string> <string name="cancel">ކެންސަލް</string>
<string name="verify">ޔަގީން</string>
<string name="settings_bottom_bar_show_labels">ބޮޓަމް ބާ ލޭބަލް އަބަދުވެސް ދެއްކުން</string> <!-- BML business OTP -->
<string name="bml_business_otp_sent">%s މެދުވެރިކޮށް އޯޓީޕީ ފޮނުވިއްޖެ</string>
<!-- Home --> <!-- Home -->
<string name="transfer_same_account">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string>
<string name="accounts">އެކައުންޓްތައް</string> <string name="accounts">އެކައުންޓްތައް</string>
<string name="available_balance">ލިބެން ހުރި ބެލެންސް</string> <string name="cards">ކާޑުތައް</string>
<string name="available_balance">ބޭނުންކުރެވެން ހުރި ބެލެންސް</string>
<string name="account_blocked_label">%1$s ހިފެހެއްޓިފައި</string>
<string name="dashboard_blocked_mvr">ހިފެހެއްޓިފައިވާ ރުފިޔާ</string>
<string name="dashboard_blocked_usd">ހިފެހެއްޓިފައިވާ ޑޮލަރު</string>
<string name="dashboard_overdue">މުއްދަތު ހަމަވެފައިވާ ފައިނޭންސް</string>
<!-- Transfer -->
<string name="transfer_tab_quick">އަވަސް ޓްރާންސްފަރ</string>
<string name="transfer_tab_contacts">ކޮންޓެކްޓްތައް</string>
<string name="transfer_from">ފައިސާ ފޮނުވާ އެކައުންޓް</string>
<string name="transfer_to">އެކައުންޓް ނަންބަރު ނުވަތަ ފަވާރާ އައިޑީ</string>
<string name="transfer_label_from">ފޮނުވާ އެކައުންޓް</string>
<string name="transfer_label_to">ލިބޭ އެކައުންޓް</string>
<string name="transfer_fahipay_phone_only">ފަހިޕޭއިން ފައިސާ ފޮނުވޭނީ 7 އަދަދުގެ ފޯނު ނަންބަރަކަށް</string>
<string name="transfer_my_accounts">އަމިއްލަ އެކައުންޓްތައް</string>
<string name="transfer_same_as_from">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string>
<string name="transfer_lookup_account">އެކައުންޓް ހޯދާ</string>
<string name="transfer_clear_recipient">ލިބޭ ފަރާތް ފުހެލާ</string>
<string name="transfer_pick_contact">ކޮންޓެކްޓެއް ނަގާ</string>
<string name="transfer_scan_qr">ކިއުއާރުން ފައިސާ ދެއްކުމަށް</string>
<string name="qr_pick_image">ފޮޓޯއެއް ނަގާ</string>
<string name="transfer_qr_invalid">މި ކިއުއާރު ކޯޑު ބޭނުމެއް ނުކުރެވޭނެ</string>
<string name="card_qr_paymv_unsupported">ކާޑުން ޕޭއެމްވީ ކިއުއާރަށް ފައިސާއެއް ނުދެއްކޭނެ - ޓްރާންސްފަރއަށް ބަދަލުކުރެވެނީ</string>
<string name="qr_camera_permission_title">ކެމެރާގެ ހުއްދަ ބޭނުންވޭ</string>
<string name="qr_camera_permission_message">ކިއުއާރު ސުކޭންކުރުމަށް ކެމެރާގެ ހުއްދަ ބޭނުންވެއެވެ. ސެޓިންގްސްއިން ހުއްދަ ދެއްވާ.</string>
<string name="camera_permission_profile_message">ފޮޓޯ ނެގުމަށް ކެމެރާގެ ހުއްދަ ބޭނުންވެއެވެ. ސެޓިންގްސްއިން ހުއްދަ ދެއްވާ.</string>
<string name="go_to_settings">ސެޓިންގްސްއަށް ދޭ</string>
<string name="transfer_select_source_first">ފުރަތަމަ ފައިސާ ފޮނުވާ އެކައުންޓް ނަގާ</string>
<string name="transfer_no_from_account">ފުރަތަމަ ފައިސާ ފޮނުވާނެ އެކައުންޓެއް އިޚްތިޔާރުކުރޭ</string>
<string name="transfer_enter_account_first">ފުރަތަމަ އެކައުންޓް ނަންބަރު ޖަހާ</string>
<string name="transfer_account_not_found">އެކައުންޓެއް ނުފެނުނު</string>
<string name="transfer_session_unavailable">ސެޝަން މުއްދަތު ހަމަވެއްޖެ - އަލުން ލޮގިންވެވަޑައިގަންނަވާ</string>
<string name="transfer_amount">އަދަދު</string>
<string name="transfer_remarks">ތަފްޞީލު / ރިމާކްސް</string>
<string name="transfer_confirm">ޔަގީން</string>
<string name="transfer_success">ފައިސާ ފޮނުވިއްޖެ</string>
<string name="transfer_bml_contact_required_title">ކޮންޓެކްޓް ސޭވްކޮއްލާ</string>
<string name="transfer_bml_contact_required_msg">ލިބޭ ފަރާތުގެ އެކައުންޓް ކަރަންސީ ކަށަވަރެއް ނުކުރެވުނެވެ.\n\nމި ފަރާތް ކޮންޓެކްޓެއްގެ ގޮތުގައި ސޭވްކޮށް، ރަނގަޅު ކަރަންސީ އިޚްތިޔާރުކުރުމަށްފަހު އަލުން މަސައްކަތްކޮއްލާ.</string>
<string name="transfer_bml_contact_required_msg_bml_limit">ބީއެމްއެލް ނޫން އެކައުންޓަކަށް ޑޮލަރު ފޮނުވޭނީ އެ ފަރާތެއް ކޮންޓެކްޓެއްގެ ގޮތުގައި ސޭވްކުރުމަށްފަހުގައެވެ.\n\nމި ފަރާތް ކޮންޓެކްޓެއްގެ ގޮތުގައި ސޭވްކުރުމަށްފަހު އަލުން މަސައްކަތްކޮއްލާ.</string>
<string name="transfer_missing_internal_id">އެކައުންޓް މަޢުލޫމާތު ފުރިހަމައެއް ނޫން - އަލުން ލޮގިންވެވަޑައިގަންނަވާ.</string>
<string name="transfer_verify_payment">ފައިސާ ދެއްކުން ކަށަވަރުކޮއްލާ</string>
<string name="transfer_send_otp_via">ކޯޑު ފޮނުވާނެ ގޮތް</string>
<string name="transfer_otp_code_hint">ކަށަވަރުކުރާ ކޯޑު</string>
<!-- BML QR Pay -->
<string name="bml_qr_looking_up">ވިޔަފާރީގެ މަޢުލޫމާތު ހޯދެނީ…</string>
<string name="bml_qr_lookup_failed">ވިޔަފާރީގެ މަޢުލޫމާތު ލިބޭގޮތެއް ނުވި</string>
<string name="transfer_bml_txn_lookup_failed">މި ޓްރާންސެކްޝަން އައިޑީގެ މަޢުލޫމާތު ލިބޭގޮތެއް ނުވި</string>
<string name="bml_card_pay_no_verified">ކަށަވަރުކުރެވިފައިވާ ކާޑެއް ނެތް. ފުރަތަމަ ކާޑު މެނޭޖްކުރާ ބައިން ބީއެމްއެލް ކާޑެއް ކަށަވަރުކޮއްލާ.</string>
<string name="bml_card_pay_already_paid">މި ފައިސާ ދައްކާ ނިމިފައި</string>
<string name="bml_qr_payment_success">ފައިސާ ދެއްކިއްޖެ</string>
<string name="bml_qr_select_account">ފައިސާ ދައްކާނެ ބީއެމްއެލް އެކައުންޓެއް އިޚްތިޔާރުކުރޭ</string>
<!-- Accounts -->
<string name="accounts_empty">އެކައުންޓެއް ނުފެނުނު</string>
<!-- Contacts -->
<string name="contacts_empty">ކޮންޓެކްޓެއް ނުފެނުނު</string>
<string name="contacts_search_hint">ކޮންޓެކްޓް ހޯދާ</string>
<string name="contacts_tab_recents">އެންމެ ފަސް</string>
<string name="recents_remove">ލިސްޓުން އުނިކުރޭ</string>
<string name="contacts_tab_all">ހުރިހާ</string>
<!-- Add Contact -->
<string name="contact_add">ކޮންޓެކްޓެއް އިތުރުކުރޭ</string>
<string name="contact_save_to">އެކައުންޓް ސޭވްކުރޭ</string>
<string name="contact_alias">ނަން / ވަނަން</string>
<string name="contact_currency">ކަރަންސީ</string>
<string name="contact_group">ގްރޫޕް</string>
<string name="contact_no_group">ގްރޫޕެއް ނެތި</string>
<string name="contact_save">ކޮންޓެކްޓް ސޭވްކުރޭ</string>
<string name="contact_image">ފޮޓޯ އަޕްލޯޑުކޮއްލާ</string>
<string name="contact_saved">ކޮންޓެކްޓް ސޭވްކުރެވިއްޖެ</string>
<string name="contact_save_failed">ކޮންޓެކްޓް ސޭވްއެއް ނުކުރެވުނު</string>
<string name="contact_no_session">ބޭންކް ސެޝަނެއް ނެތް</string>
<string name="contact_lookup_failed">އެކައުންޓެއް ނުފެނުނު</string>
<string name="contact_select_destination">ފުރަތަމަ ފައިސާ ލިބޭނެ އެކައުންޓް ނަގާ</string>
<string name="contact_already_exists">މި ކޮންޓެކްޓް ސޭވް ވެފައެބައިން: %s</string>
<string name="contact_own_account">އަމިއްލަ އެކައުންޓެއް ކޮންޓެކްޓެއްގެ ގޮތުގައި ސޭވްއެއް ނުކުރެވޭނެ</string>
<!-- Contact expand/delete -->
<string name="contact_edit">އުނިއިތުރުގެނޭ</string>
<string name="contact_edit_title">ކޮންޓެކްޓް އުނިއިތުރުގެނޭ</string>
<string name="contact_alias_mib_max">MIB ގައި ލިޔެވޭނީ ގިނަވެގެން %1$d އަކުރު</string>
<string name="contact_delete">ފުހެލާ</string>
<string name="contact_delete_title">ކޮންޓެކްޓް ފުހެލުން</string>
<string name="contact_delete_message">%s ކޮންޓެކްޓް ލިސްޓުން ފުހެލަންވީތޯ؟</string>
<string name="contact_deleted">ކޮންޓެކްޓް ފުހެލެވިއްޖެ</string>
<string name="contact_delete_failed">ކޮންޓެކްޓް ފުހެއެއް ނުލެވުނު</string>
<string name="contact_account_number">އެކައުންޓް ނަންބަރު</string>
<string name="contact_account_name">އެކައުންޓް ނަން</string>
<string name="contact_bank">ބޭންކު</string>
<string name="contact_qr">ކިއުއާރު</string>
<string name="contact_delete_warning">މި ކަން ނިމުމައްފަހު ބަދަލެއް ނުކުރެވޭނެ.</string>
<string name="contact_copy_account">އެކައުންޓް ނަންބަރު ކޮޕީކުރޭ</string>
<string name="contact_share_account">އެކައުންޓް މަޢުލޫމާތު ޙިއްޞާކުރޭ</string>
<string name="contact_account_copied">އެކައުންޓް ނަންބަރު ކޮޕީކުރެވިއްޖެ</string>
<!-- Financing -->
<string name="financing_empty">ފައިނޭންސިންގއެއް ނެތް</string>
<string name="financing_total">ޖުމްލަ</string>
<string name="financing_paid">ދައްކާފައި</string>
<string name="financing_unpaid">ނުދައްކާ</string>
<string name="financing_deal_date">ފެށުނު ތާރީޚު</string>
<string name="financing_installment">މަހުން މަހަށް ދައްކަންޖެހޭ</string>
<string name="financing_num_installments">ޖުމްލަ އިންސްޓޯލްމަންޓް</string>
<string name="financing_last_paid_date">އެންމެ ފަހުން ފައިސާ ދެއްކި ތާރީޚު</string>
<string name="financing_last_pay_amount">އެންމެ ފަހުން ދެއްކި އަދަދު</string>
<string name="financing_overdue">މުއްދަތު ހަމަވެފައި</string>
<string name="financing_completion_done">ފުރިހަމައަށް ދައްކާ ނިމިފައި</string>
<string name="financing_deal_no_fmt">ޑީލް #%s</string>
<string name="financing_completion_fmt">ނިމޭނީ %s</string>
<!-- BML Loans -->
<string name="loan_outstanding">ނުދައްކާ ބާކީ</string>
<string name="loan_monthly_repayment">މަހުން މަހަށް ދައްކަންޖެހޭ</string>
<string name="loan_interest_rate">އިންޓްރެސްޓް ރޭޓް</string>
<string name="loan_start_date">ފެށުނު ތާރީޚު</string>
<string name="loan_end_date">ނިމޭ ތާރީޚު</string>
<string name="loan_overdue_payments">މުއްދަތު ހަމަވެ ނުދައްކާ ހުރި</string>
<string name="loan_rate_fmt">%.2f%%</string>
<!-- Cards -->
<string name="nav_pay_with_card">ކާޑުތައް</string>
<string name="card_pay_qr">ކިއުއާރުން ފައިސާ ދެއްކުމަށް</string>
<string name="card_pay_nfc">ޖައްސާލައިގެން ފައިސާ ދެއްކުމަށް</string>
<string name="mib_qr_nfc_not_supported">އެމްއައިބީން މި ޚިދުމަތެއް ނުލިބޭ</string>
<string name="nfc_unsupported_title">މި ޚިދުމަތް ނުލިބޭ</string>
<string name="nfc_unsupported_message">މި ފޯނުން ޖައްސާލައިގެން ފައިސާ ދެއްކުމުގެ ޚިދުމަތެއް ނުލިބޭ.</string>
<string name="nfc_disabled_title">އެންއެފްސީ ނިއްވާލެވިފައި</string>
<string name="nfc_disabled_message">ޖައްސާލައިގެން ފައިސާ ދެއްކުމަށް އެންއެފްސީ އޮންކުރޭ.</string>
<string name="nfc_open_settings">އެންއެފްސީ ސެޓިންގްސް</string>
<string name="nfc_not_default_title">މައި އެޕްގެ ގޮތުގައި ހަމަޖަހާ</string>
<string name="nfc_not_default_message">ޖައްސާލައިގެން ފައިސާ ދެއްކުމަށް %1$s މައި އެޕްގެ ގޮތުގައި ހަމަޖަހާ.</string>
<string name="nfc_payment_open_settings">ޕޭމަންޓް ސެޓިންގްސް</string>
<string name="card_manage">ކާޑު މެނޭޖްކުރުން</string>
<string name="card_set_as_default">މައި ކާޑުގެ ގޮތުގައި ހަމަޖައްސާ</string>
<string name="card_hide_from_dashboard">ޑޭޝްބޯޑުން ފޮރުވާ</string>
<string name="card_action_change_pin">ޕިން ބަދަލުކުރޭ</string>
<string name="card_action_freeze">ފްރީޒްކުރޭ</string>
<string name="card_action_unfreeze">އަންފްރީޒްކުރޭ</string>
<string name="card_action_block">ބްލޮކްކުރޭ</string>
<string name="card_action_verify">ކަށަވަރުކުރޭ</string>
<string name="card_action_verified">ކަށަވަރުކުރެވިފައި</string>
<string name="card_verify_already">ކާޑު ވަނީ ކަށަވަރުކުރެވިފައި. ބަދަލުކުރުމަށް އޮއްބައިގެން ހިފަހައްޓަވާ.</string>
<string name="card_verify_title">ކާޑު ކަށަވަރުކުރޭ</string>
<string name="card_verify_tap">ކަށަވަރުކުރުމަށް ކާޑު ޖައްސާލާ</string>
<string name="card_verify_reading">ކާޑު ކިޔަނީ… މަޑުކޮށްލައްވާ</string>
<string name="card_verify_matched">ކާޑު ދިމާވެއްޖެ</string>
<string name="card_verify_read_failed">ކާޑު ކިޔައެއް ނުގަނެވުނު، އަލުން މަސައްކަތްކޮއްލާ</string>
<string name="card_verify_mismatch">ނިމޭ %1$s ކާޑާ ދިމައެއްނުވޭ</string>
<string name="card_verify_cancel">ކެންސަލް</string>
<string name="card_verify_manual">އަމިއްލައަށް މަޢުލޫމާތު ޖަހާ</string>
<string name="card_verify_manual_title">ކާޑުގެ މަޢުލޫމާތު ޖަހާ</string>
<string name="card_verify_nfc_disabled_message">ކާޑު ޖައްސާލައިގެން ކަށަވަރުކުރުމަށް އެންއެފްސީ އޮންކުރޭ. ނުވަތަ އަމިއްލައަށް މަޢުލޫމާތު ޖަހާ.</string>
<string name="card_verify_cvv_title">ނިމޭ %1$s ކާޑު</string>
<string name="card_verify_cvv_hint">ސީވީވީ (CVV)</string>
<string name="card_verify_cvv_invalid">3 ނުވަތަ 4 އަދަދުގެ ސީވީވީ ޖަހާ</string>
<string name="card_verify_confirm">ކަށަވަރުކޮއްލާ</string>
<string name="card_verify_name_hint">ކާޑުގައިވާ ނަން</string>
<string name="card_verify_number_hint">ކާޑު ނަންބަރު</string>
<string name="card_verify_expiry_hint">މުއްދަތު ހަމަވާ ތާރީޚު (މަސް/އަހަރު)</string>
<string name="card_verify_number_invalid">ރަނގަޅު ކާޑު ނަންބަރެއް ޖަހާ</string>
<string name="card_verify_number_mismatch">ނަންބަރު ނިމެންވާނީ %1$s އިން</string>
<string name="card_verify_expiry_invalid">ރަނގަޅު ތާރީޚެއް ޖަހާ (މިސާލަކަށް: 08/29)</string>
<string name="card_verify_success">ކާޑު ކަށަވަރުކުރެވިއްޖެ</string>
<string name="card_freeze_confirm_title">ކާޑު ފްރީޒްކުރަންވީތޯ؟</string>
<string name="card_freeze_confirm_message">މިކަމުގެ ސަބަބުން ކާޑުގެ ބޭނުން ވަގުތީގޮތުން މެދުކެނޑޭނެއެވެ. ބޭނުންވާ ކޮންމެ ވަގުތަކު އަލުން އަންފްރީޒް ކުރެވޭނެއެވެ.</string>
<string name="card_unfreeze_confirm_title">ކާޑު އަންފްރީޒްކުރަންވީތޯ؟</string>
<string name="card_unfreeze_confirm_message">މިކަމުގެ ސަބަބުން ކާޑު އަލުން ބޭނުންކުރެވޭނެއެވެ.</string>
<string name="card_freeze_success">ކާޑު ފްރީޒްކުރެވިއްޖެ</string>
<string name="card_unfreeze_success">ކާޑު އަންފްރީޒްކުރެވިއްޖެ</string>
<string name="card_freeze_failed">ކާޑުގެ ހާލަތު ބަދަލެއް ނުކުރެވުނު</string>
<string name="card_freeze_comments_hint">ސަބަބު (ބޭނުންނަމަ)</string>
<string name="card_status_temp_blocked">ވަގުތީގޮތުން ބްލޮކްކުރެވިފައި</string>
<string name="cards_empty">ކާޑެއް ނުފެނުނު</string>
<!-- Connectivity banner --> <!-- Connectivity banner -->
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string> <string name="connectivity_no_internet">އިންޓަނެޓް ޗެކްކުރެއްވުމަށްފަހު އަލުން ތިޖޫރީ ރީލޯޑްކޮއްލާ</string>
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string> <string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް އުޅޭ</string>
<string name="drag_to_reorder">ދަމާ ތަރުތީބު ބަދަލުކުރޭ</string> <string name="drag_to_reorder">ތަރުތީބު ބަދަލުކުރުމަށް ދަމާލާ</string>
</resources> </resources>
+4
View File
@@ -269,6 +269,8 @@
<string name="transfer_fahipay_amount_max">Maximum is MVR %1$s</string> <string name="transfer_fahipay_amount_max">Maximum is MVR %1$s</string>
<string name="transfer_fahipay_gst_hint">%1$d%% GST is deducted from this amount</string> <string name="transfer_fahipay_gst_hint">%1$d%% GST is deducted from this amount</string>
<string name="transfer_fahipay_gst_receive">Recipient receives MVR %1$s after %2$d%% GST</string> <string name="transfer_fahipay_gst_receive">Recipient receives MVR %1$s after %2$d%% GST</string>
<string name="transfer_gst_added_hint">%1$d%% GST is charged on top of this amount</string>
<string name="transfer_gst_added_pay">You pay MVR %1$s with %2$d%% GST</string>
<string name="transfer_fahipay_phone_only">Fahipay transfers require a 7-digit phone number</string> <string name="transfer_fahipay_phone_only">Fahipay transfers require a 7-digit phone number</string>
<string name="transfer_my_accounts">My Accounts</string> <string name="transfer_my_accounts">My Accounts</string>
<string name="transfer_same_as_from">This is the same account as the sender</string> <string name="transfer_same_as_from">This is the same account as the sender</string>
@@ -339,6 +341,8 @@
<!-- Contact expand/delete --> <!-- Contact expand/delete -->
<string name="contact_edit">Edit</string> <string name="contact_edit">Edit</string>
<string name="contact_edit_title">Edit Contact</string>
<string name="contact_alias_mib_max">MIB allows a maximum of %1$d characters</string>
<string name="contact_delete">Delete</string> <string name="contact_delete">Delete</string>
<string name="contact_delete_title">Delete Contact</string> <string name="contact_delete_title">Delete Contact</string>
<string name="contact_delete_message">Remove %s from your contacts?</string> <string name="contact_delete_message">Remove %s from your contacts?</string>
+2 -2
View File
@@ -17,5 +17,5 @@
| [bmlapi/](bmlapi/README.md) | Bank of Maldives — hybrid web/OAuth login, dashboard, transfers, cards, QR payments, tap-to-pay | | [bmlapi/](bmlapi/README.md) | Bank of Maldives — hybrid web/OAuth login, dashboard, transfers, cards, QR payments, tap-to-pay |
| [mibapi/](mibapi/README.md) | MIB Faisanet — Blowfish-encrypted API + WebView session, accounts, transfers, contacts | | [mibapi/](mibapi/README.md) | MIB Faisanet — Blowfish-encrypted API + WebView session, accounts, transfers, contacts |
| [fahipayapi/](fahipayapi/README.md) | Fahipay digital wallet — login, balance, history, contacts | | [fahipayapi/](fahipayapi/README.md) | Fahipay digital wallet — login, balance, history, contacts |
| [dhiraaguapi/](dhiraaguapi/README.md) | Dhiraagu Easy Pay / Easy TopUp — number lookup, reload by BML card | | [dhiraaguapi/](dhiraaguapi/README.md) | Dhiraagu Easy Pay / Easy TopUp — number lookup, reload and bill pay by BML card |
| [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation for Raastas / bill pay | | [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation, Raastas and bill pay by BML card |
+31 -4
View File
@@ -205,8 +205,10 @@ no `action`; their JavaScript posts back to the **same creq URL**. So the creq U
`destValue=token`, `selectChannel=token`, `authMethod=OOB`, `otpDest=`, `formReqType=SUBMIT` `destValue=token`, `selectChannel=token`, `authMethod=OOB`, `otpDest=`, `formReqType=SUBMIT`
(keep the hidden `creq` / `otpChannels`). (keep the hidden `creq` / `otpChannels`).
3. **POST channel** → the **OTP entry** page (`otpValue` input). Submit `otpValue=<BML token TOTP>`, 3. **POST channel** → the **OTP entry** page (`otpValue` input). Submit `otpValue=<BML token TOTP>`,
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page with text containing `formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page (still with `otpValue`) and
*"incorrect"* / *"expired"* — regenerate the TOTP and retry once. *"The OTP code you entered is incorrect Please try again."* — wait for the next TOTP window,
regenerate and retry once. Rejected twice, the payment stops ("The bank rejected the BML token
code"). The app also never sends a code with under 5 s left in its window.
4. On success the ACS returns a form auto-posting **`cres`** to the Mastercard gateway; the gateway 4. On success the ACS returns a form auto-posting **`cres`** to the Mastercard gateway; the gateway
returns a form auto-posting the result (`order.id`, `result=SUCCESS`, …) to returns a form auto-posting the result (`order.id`, `result=SUCCESS`, …) to
**`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded. **`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded.
@@ -225,6 +227,23 @@ Poll `next-action` until the recorded verdict surfaces:
| `TRANSACTION_CONFIRMED` | Success | | `TRANSACTION_CONFIRMED` | Success |
| `TRANSACTION_FAILED` | Declined | | `TRANSACTION_FAILED` | Declined |
**A decline after 3-D Secure doesn't arrive this way.** In the Ooredoo capture, the card passed
3-D Secure (`mpgsNotification` got `result=SUCCESS`, `gatewayRecommendation=PROCEED`) and was then
declined for insufficient funds. The browser's `?wait=1` went to `?error=1` instead of the
merchant, and the transaction stayed payable: `state` still `QR_CODE_GENERATED`, `hasError: true`,
`allowRetry: true`, and a new `paymentErrorHistory` entry:
```json
{"date":"…","vendor":"mpgs","code":"INSUFFICIENT_FUNDS",
"reason":"Transaction declined due to insufficient funds",
"customerVisibleDescription":"Insufficient funds. Please use another card or payment method."}
```
The same link was then paid successfully after topping up the card. So while polling,
`BmlMerchantCardPayClient` also reads the transaction (the load PATCH,
`BmlMerchantTxnClient.paymentErrors`) and stops with `customerVisibleDescription` as soon as an
entry newer than the ones there before the attempt shows up.
## 7. Return to the merchant ## 7. Return to the merchant
The `mpgsNotification` response is a page whose script sends the browser to The `mpgsNotification` response is a page whose script sends the browser to
@@ -235,18 +254,26 @@ merchant's own receipt page:
``` ```
GET transaction…/<id>?wait=1 GET transaction…/<id>?wait=1
→ 302 https://www.dhiraagu.com.mv/api/dhiraagu-bml-response.aspx?transactionId=<id>&state=CONFIRMED&signature=<sha1> → 302 https://www.dhiraagu.com.mv/api/dhiraagu-bml-response.aspx?transactionId=<id>&state=CONFIRMED&signature=<sha1>
→ 302 https://www.dhiraagu.com.mv/services/reload-receipt?pyid=<paymentId> → 302 https://www.dhiraagu.com.mv/services/reload-receipt?pyid=<paymentId> (bill pay: /services/bill-receipt)
``` ```
(FahiPay's is `fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`.) (FahiPay's is `fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`.)
Ooredoo's callback isn't a redirect: `my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED` is a
200 page whose `<body onload="document.forms['wtmpay'].submit()">` posts the result
(`order_id`, `bml_transaction_id`, `bml_response=CONFIRMED`, `payment_status=success`, …) on to
`www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml`, which lands on
`/payment-status?order_id=…&status=1`. `returnToMerchant` submits such auto-posting forms too
(up to 2).
**This hop is required.** It's how at least Dhiraagu learns it was paid: a test reload that **This hop is required.** It's how at least Dhiraagu learns it was paid: a test reload that
stopped at `TRANSACTION_CONFIRMED` charged the card but never topped up, and opening the stopped at `TRANSACTION_CONFIRMED` charged the card but never topped up, and opening the
`?wait=1` URL in a browser afterwards delivered it. The signature is generated by BML, so the `?wait=1` URL in a browser afterwards delivered it. The signature is generated by BML, so the
hop can be replayed later from the transaction id alone. hop can be replayed later from the transaction id alone.
`BmlMerchantCardPayClient` does it after every confirmed payment (`returnToMerchant`): a browser `BmlMerchantCardPayClient` does it after every confirmed payment (`returnToMerchant`): a browser
UA GET that follows the redirects, up to 3 tries, success = the chain ends on a 2xx page. The UA GET that follows the redirects and auto-submitted forms, up to 3 tries, success = the chain
ends on a 2xx page. The
merchant host may be behind Cloudflare: plain `curl` got a 403 on `dhiraagu.com.mv`, okhttp got merchant host may be behind Cloudflare: plain `curl` got a 403 on `dhiraagu.com.mv`, okhttp got
through. through.
+1 -1
View File
@@ -172,4 +172,4 @@ UA, so these calls are made the same way.
**Related:** [Number Lookup](01-number-lookup.md) · [BML Merchant Card Payment](../bmlapi/16-card-payment.md) · **Related:** [Number Lookup](01-number-lookup.md) · [BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card) App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
[← Number Lookup](01-number-lookup.md) [← Number Lookup](01-number-lookup.md) · [Bill Pay →](03-bill-pay.md)
+132
View File
@@ -0,0 +1,132 @@
# Bill Pay (Easy Pay, paid by BML card)
Pay a Dhiraagu postpaid bill through the dhiraagu.com.mv **Easy Pay** page. Like
[Reload](02-reload.md), Dhiraagu only builds the order and the money moves on a **BML Merchant
Services transaction** paid by card + 3-D Secure
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)). From the payment page on,
the two flows are identical; only the first page, the cart call and the form id differ.
Reconstructed from `docs/dhiraaguapi/tmp/dhiraagu_billpay_gateway.har` (a Firefox HAR) and the
Easy Pay page's inline script.
---
## Flow overview
```
GET /services/easy-pay → nonce #1
GET setting&act=bill (nonce #1) → blocked account statuses / customer types
POST dhiraaguIO&act=infoUnlisted (nonce #1) → accountNumber, type, accountStatus, customerType
POST cart&act=easyPay (nonce #1) → cartId
GET /services/payment-v2?cartid=<cartId> → nonce #2
POST merchant&act=form {"formId":1} → BML gateway's merchantId
POST payment&act=create (formId 1) → paymentId, oid (EP…)
POST bml&act=createV2 → BML transaction url
── from here: the BML card-only merchant flow ──
GET transaction.merchants…/<id>?wait=1 → 302 dhiraagu-bml-response.aspx (posts the payment)
→ 302 /services/bill-receipt?pyid=<paymentId>
```
As with reload, the `?wait=1` return hop is what tells Dhiraagu it was paid.
Common headers and the `{"respStatus":"OK","resp":…}` envelope are as in
[Reload → Common](02-reload.md#common).
---
## 1. Settings
`GET …&sub=setting&act=bill` (no body, so a GET) — rules the page checks the lookup against:
```json
{"settingAppJson1":{"accountStatus":{"val":["F"],…},"customerType":{"val":["P"],…}}}
```
A number whose `accountStatus` or `customerType` is in a `val` list is refused before ordering
("Payment for this service could not be accepted… [Account Status: F]" / "The number is not
allowed. [Customer Type: P]"). Thijooree applies the same rules, skipping them if the call fails.
`setting&act=maintenance` has `public.easyPay` — `"Y"` means the page is under maintenance.
Not checked.
---
## 2. Lookup
`sub=dhiraaguIO&act=infoUnlisted`, `{"number":"7XXXXXX"}` — the same call as
[Number Lookup](01-number-lookup.md), but the bill payment needs more of its answer:
```json
{"respStatus":"OK","accountNumber":"1466154","accountStatus":"W","customerType":"S",
"type":"BillPayment","serviceDetails":[{"unlisted":"N","prepaidIndicator":"N"}],
"accountOwnerInfo":{"name":"…"}}
```
Note the fields are at the top level, not under `resp`.
| Field | Use |
|---|---|
| `accountNumber` | the billing account the cart is made out to |
| `type` | `BillPayment`, or `writeOffPayments` for a written-off account — sent as `billType` |
| `prepaidIndicator` | `"Y"` is refused ("Prepaid number is not allowed.") |
The page also accepts the account number itself in place of a service number (then
`serviceNumber` is sent empty); Thijooree only pays by phone number.
---
## 3. Cart
`sub=cart&act=easyPay`, nonce from `GET /services/easy-pay`.
```json
{"formId":1,"serviceNumber":"7XXXXXX","accountNumber":"1466154","amount":"1.05",
"memberId":"","memberName":"","memberNId":"","billRef":"","billType":"BillPayment"}
```
```json
{"cartId":"8fc33fa4-…","formId":1,"cartJson":[{"accountNumber":"1466154","serviceNumber":"7XXXXXX",
"amount":1.05,"billRef":"","billType":"BillPayment"}],"cartAmount":1.05,"cartExpiry":"…(20 min)…",
"paymentUrl":"https://www.dhiraagu.com.mv/services/payment-v2?cartid=8fc33fa4-…", …}
```
| Rule | Value |
|---|---|
| Amount | any positive amount, up to 2 decimal places (the page's only check). No min / max. |
| GST | none |
---
## 4. Payment page
Same as [Reload §3–5](02-reload.md#3-payment-gateway) with `formId: 1`:
- `merchant&act=form` lists DhiraaguPay (3), Bank of Maldives (1) and MIB (2) for "Easy Pay".
The BML `merchantId` is the same as reload's.
- `payment&act=create` returns an `oid` starting `EP` (reload's start `ET`).
- `bml&act=createV2` returns the transaction with `"customerReference":"WebApp - Easy Pay"` and
the same `redirectUrl`. Its page is card-only, no BML Pay.
---
## Bill record
`sub=bill&act=list`, `{"paymentId"}`, nonce from the receipt page — what the receipt shows:
```json
[{"oid":"EP20260006911918","transId":"<BML txn id>","accountNumber":"1466154","serviceNumber":"7XXXXXX",
"amount":1.05,"billStatus":1,"paidStatus":1,"cbsStatus":1,"cbsReceipt":"EP…-130","billType":"BillPayment", …}]
```
Not used by Thijooree yet.
---
&nbsp;
---
**Related:** [Number Lookup](01-number-lookup.md) · [Reload](02-reload.md) ·
[BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
[← Reload](02-reload.md)
+1
View File
@@ -97,6 +97,7 @@ The API only returns a valid result for numbers currently on the Dhiraagu networ
|---|---|---| |---|---|---|
| 1 | [Number Lookup](01-number-lookup.md) | Validate a Dhiraagu number and determine account type | | 1 | [Number Lookup](01-number-lookup.md) | Validate a Dhiraagu number and determine account type |
| 2 | [Reload](02-reload.md) | Easy TopUp order → BML merchant transaction, paid by card | | 2 | [Reload](02-reload.md) | Easy TopUp order → BML merchant transaction, paid by card |
| 3 | [Bill Pay](03-bill-pay.md) | Easy Pay order → BML merchant transaction, paid by card |
--- ---
+11 -1
View File
@@ -57,6 +57,16 @@ def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
## Step-by-Step Reference ## Step-by-Step Reference
**Endpoint**: `POST https://faisamobilex-smvc-v2.mib.com.mv/index/`
**Headers** (on all requests):
```
User-Agent: android/2.0
Accept: application/json
X-Request-ID: {YYYYMMDDHHmmss}{milliseconds}-{16-char-hex}
Content-Type: application/x-www-form-urlencoded; charset=utf-8
```
### [0] Initial Key Exchange — `sfunc=r` ### [0] Initial Key Exchange — `sfunc=r`
**Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678` **Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678`
@@ -67,7 +77,7 @@ def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
"sfunc": "r", "sfunc": "r",
"data": { "data": {
"cmod": "<G^A mod P as decimal string>", "cmod": "<G^A mod P as decimal string>",
"appId": "IOS17.2-<15 random alphanumeric chars>", "appId": "AND34-<15 random alphanumeric chars>",
"routePath": "S40", "routePath": "S40",
"sodium": "<random int in [1_000_000, 16_000_000)>", "sodium": "<random int in [1_000_000, 16_000_000)>",
"xxid": "<random 40-bit int as string>" "xxid": "<random 40-bit int as string>"
+38
View File
@@ -44,6 +44,44 @@ To switch between profiles (personal ↔ business), call `P47` again with the ot
--- ---
## Refresh Accounts — `sfunc=n`, `routePath: A80`
**Key**: session key
Re-fetches the account list and balances for the **currently selected** profile without re-selecting it. No route-specific fields.
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "A80",
"xxid": "<session xxid>"
}
}
```
**Response**:
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "101",
"reasonText": "Accounts retrieval success!",
"data": [ ... ],
"beneficiaryVersion": 0,
"menuVersion": "<hash>"
}
```
`data` has the same shape as the `accountBalance` array below. Some responses carry `responseCode: "3"`, `reasonCode: "90"`, `reasonText: "unknown error"` with `success: true` and a populated `data` array — treat `success` as authoritative.
---
## Profiles (from `A41` response) ## Profiles (from `A41` response)
The `A41` login init response includes `operatingProfiles`: The `A41` login init response includes `operatingProfiles`:
+74 -50
View File
@@ -1,41 +1,46 @@
# Transaction History # Transaction History
Fetch paginated transaction history for a single MIB account. Served from the WebView subdomain. Fetch paginated transaction history for a single MIB account. Served from the encrypted API.
The account must belong to the **currently selected** profile — call `P47` for its profile first (see [03-accounts.md](03-accounts.md)).
--- ---
## Endpoint ## Transaction History — `sfunc=n`, `routePath: A84`
**Key**: session key
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"accountNo": "90101000000001000",
"start": 0,
"end": 19,
"includeCount": true,
"trxType": 0,
"sortTrx": "date",
"sortDir": "desc",
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "A84",
"xxid": "<session xxid>"
}
}
``` ```
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 | | Field | Example | Description |
|---|---|---| |---|---|---|
| `accountNo` | `90101000000001000` | Account number to fetch history for | | `accountNo` | `90101000000001000` | Account number to fetch history for |
| `trxNo` | `` | Transaction number filter (empty = all) | | `start` | `0` | First row (0-based, inclusive) |
| `end` | `19` | Last row (inclusive) |
| `includeCount` | `true` | Include `total_count` in response |
| `trxType` | `0` | Transaction type filter (`0` = all) | | `trxType` | `0` | Transaction type filter (`0` = all) |
| `sortTrx` | `date` | Sort field | | `sortTrx` | `date` | Sort field |
| `sortDir` | `desc` | Sort direction | | `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 ### Pagination
@@ -43,9 +48,9 @@ Page size is 20. Compute `start`/`end` per page:
| Page | `start` | `end` | | Page | `start` | `end` |
|---|---|---| |---|---|---|
| 1 | `1` | `20` | | 1 | `0` | `19` |
| 2 | `21` | `40` | | 2 | `20` | `39` |
| N | `(N-1)*20 + 1` | `N*20` | | N | `(N-1)*20` | `N*20 - 1` |
Stop when total fetched equals `total_count` or `data` is empty. Stop when total fetched equals `total_count` or `data` is empty.
@@ -56,25 +61,36 @@ Stop when total fetched equals `total_count` or `data` is empty.
```json ```json
{ {
"success": true, "success": true,
"responseCode": "1",
"reasonCode": "103",
"reasonText": "Trx History Retrieval Success!",
"total_count": "87", "total_count": "87",
"pos": "0",
"data": [ "data": [
{ {
"trxNumber": "TXN20260516001", "accountNo": "90101000000001000",
"trxDate": "2026-05-16", "curCode": "462",
"descr1": "Transfer Debit",
"baseAmount": "-500.00",
"curCodeDesc": "MVR", "curCodeDesc": "MVR",
"benefName": "Mohamed Ali", "trxNumber": "1-158043140-93434633-1",
"trxNumber2": "FT20260516001" "trxNumber2": "93434633",
}, "trxDate": "2026-05-16 15:10:25",
{ "trxValDate": "2026-05-16 00:00:00",
"trxNumber": "TXN20260515001", "absAmount": "500",
"trxDate": "2026-05-15", "baseAmount": "-500",
"descr1": "Transfer Credit", "foreignAmount": "-500",
"baseAmount": "1000.00", "descr1": "Favara Transfer Web",
"curCodeDesc": "MVR", "descr2": "<reference>",
"benefName": "", "descr3": "<details>",
"trxNumber2": "" "remarks": "<details>",
"trxType": "803",
"AddDate": "2026-05-16 15:10:25",
"bankCode": "1",
"bankName": "Maldives Islamic Bank",
"benefName": "BML - MOHAMED ALI",
"otherAccountNo": "-",
"fromAcc": null,
"imageHash": null,
"bankColor": "#FE860E"
} }
] ]
} }
@@ -84,19 +100,27 @@ Stop when total fetched equals `total_count` or `data` is empty.
|---|---|---| |---|---|---|
| `success` | `bool` | `true` on success | | `success` | `bool` | `true` on success |
| `total_count` | `string` | Total transaction count — parse to `int` | | `total_count` | `string` | Total transaction count — parse to `int` |
| `pos` | `string` | Echo of the requested start row |
| `data` | `array` | Transactions for this page | | `data` | `array` | Transactions for this page |
### Transaction Object ### Transaction Object
| Field | Type | Description | | Field | Consumed | Description |
|---|---|---| |---|---|---|
| `trxNumber` | `string` | Unique transaction ID | | `trxNumber` | yes | Unique transaction ID |
| `trxDate` | `string` | Transaction date (`YYYY-MM-DD`) | | `trxDate` | yes | Transaction timestamp (`yyyy-MM-dd HH:mm:ss`) |
| `descr1` | `string` | Transaction description — trim whitespace | | `descr1` | yes | Transaction description — trim whitespace |
| `baseAmount` | `string` | Decimal string — **negative = debit, positive = credit** | | `foreignAmount` | yes | Amount in the **account's** currency — **negative = debit, positive = credit** |
| `curCodeDesc` | `string` | Currency code (e.g. `"MVR"`, `"USD"`) | | `curCodeDesc` | yes | Account currency (e.g. `"MVR"`, `"USD"`) |
| `benefName` | `string` | Counterparty name — blank or literal `"null"` means none | | `benefName` | yes | Counterparty name — `null`, blank or literal `"null"` means none |
| `trxNumber2` | `string` | Secondary reference; may be blank | | `trxNumber2` | yes | Secondary reference; may be blank |
| `baseAmount` | server-only | Signed amount in MVR. Differs from `foreignAmount` on USD accounts. |
| `absAmount` | server-only | Unsigned MVR amount |
| `descr2` / `descr3` / `remarks` | server-only | Extra reference and narrative lines |
| `trxType` | server-only | Numeric transaction type (e.g. `800` Favara credit, `803` Favara transfer, `205` account-to-account) |
| `imageHash` | server-only | Counterparty image hash, fetchable via `P41` (see [07-profile.md](07-profile.md)) |
> On USD accounts `baseAmount` is the MVR equivalent — always use `foreignAmount` for the amount shown against the account.
--- ---
+31 -18
View File
@@ -7,7 +7,7 @@ Fetch debit card information for the authenticated session.
## Endpoint ## Endpoint
``` ```
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/fetchCardInfos POST https://faisamobilex-wv-v2.mib.com.mv/ajaxDebitCard/fetchCardInfos
``` ```
--- ---
@@ -17,7 +17,7 @@ POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/fetchCardInfos
See [README](README.md) for cookie and AJAX header format. See [README](README.md) for cookie and AJAX header format.
``` ```
Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1 Referer: https://faisamobilex-wv-v2.mib.com.mv/debitCards
``` ```
--- ---
@@ -38,15 +38,21 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
```json ```json
{ {
"success": true, "success": true,
"responseCode": "1",
"reasonCode": "101",
"reasonText": "Single Debit card fetch success!",
"total_count": "1",
"pos": "1",
"data": [ "data": [
{ {
"cardId": "CARD001", "cardId": "100000000001",
"maskedCardNumber": "4111 **** **** 1234", "maskedCardNumber": "4023XXXXXXXX1234",
"cardStatus": "A", "cardStatus": "CHST0",
"cardType": "D", "cardType": "70",
"cardTypeDesc": "Debit Card", "contractId": "552",
"customerId": "C000001", "cardTypeDesc": "VISA EMV Corporate",
"phoneNumber": "9600000001", "customerId": "40000001",
"phoneNumber": "009609600001",
"cardHolderName": "MOHAMED ALI" "cardHolderName": "MOHAMED ALI"
} }
] ]
@@ -64,9 +70,10 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
|---|---|---| |---|---|---|
| `cardId` | `string` | Internal card identifier | | `cardId` | `string` | Internal card identifier |
| `maskedCardNumber` | `string` | Partially masked card number for display | | `maskedCardNumber` | `string` | Partially masked card number for display |
| `cardStatus` | `string` | Card status (`A` = Active) | | `cardStatus` | `string` | Card status code (`CHST0` = valid, `CHST20` = temporarily blocked by client) |
| `cardType` | `string` | Card type code (e.g. `D` = Debit) | | `cardType` | `string` | Numeric card type code (e.g. `"70"`) |
| `cardTypeDesc` | `string` | Human-readable card type (e.g. `"Debit Card"`) | | `contractId` | `string` | Card contract ID (not consumed) |
| `cardTypeDesc` | `string` | Human-readable card type (e.g. `"VISA EMV Corporate"`) |
| `customerId` | `string` | Customer ID | | `customerId` | `string` | Customer ID |
| `phoneNumber` | `string` | Registered phone number | | `phoneNumber` | `string` | Registered phone number |
| `cardHolderName` | `string` | Name on card | | `cardHolderName` | `string` | Name on card |
@@ -84,14 +91,14 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
## Freeze / Unfreeze Card ## Freeze / Unfreeze Card
``` ```
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/freeze POST https://faisamobilex-wv-v2.mib.com.mv/ajaxDebitCard/freeze
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/unfreeze POST https://faisamobilex-wv-v2.mib.com.mv/ajaxDebitCard/unfreeze
``` ```
Two endpoints — same shape. Pick by intent. Source: `MibCardsClient.kt:74-103`. Two endpoints — same shape. Pick by intent. Source: `MibCardsClient.kt:74-103`.
``` ```
Referer: https://faisamobilex-wv.mib.com.mv//debitCards/manage?cardId=<cardId>&dashurl=1 Referer: https://faisamobilex-wv-v2.mib.com.mv/debitCards/manage?cardId=<cardId>
``` ```
### Request Body (form-urlencoded) ### Request Body (form-urlencoded)
@@ -106,16 +113,22 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards/manage?cardId=<cardId>&d
```json ```json
{ {
"success": true, "success": true,
"reasonText": "Card frozen successfully", "responseCode": "1",
"currentStatusCode": "F" "reasonCode": "502",
"reasonText": "Card Frozen",
"currentStatus": "TEMPRORY BLOCKED BY CLIENT",
"currentStatusCode": "CHST20"
} }
``` ```
Unfreeze returns `reasonText: "Card Activated"`, `currentStatus: "VALID CARD"`, `currentStatusCode: "CHST0"`.
| Field | Description | | Field | Description |
|---|---| |---|---|
| `success` | `true` on success | | `success` | `true` on success |
| `reasonText` | Human-readable message (also returned on failure — show to user) | | `reasonText` | Human-readable message (also returned on failure — show to user) |
| `currentStatusCode` | New card status code after the action (e.g. `"F"` = frozen, `"A"` = active) | | `currentStatus` | Human-readable status after the action |
| `currentStatusCode` | New card status code (`"CHST20"` = frozen, `"CHST0"` = active) |
--- ---
+1 -1
View File
@@ -7,7 +7,7 @@ Fetch active financing deals. Unlike other data endpoints, this returns an HTML
## Endpoint ## Endpoint
``` ```
GET https://faisamobilex-wv.mib.com.mv/financing?dashurl=1 GET https://faisamobilex-wv-v2.mib.com.mv/financing?dashurl=1
``` ```
--- ---
+84 -59
View File
@@ -1,92 +1,117 @@
# Personal Profile # Personal Profile
This document covers two unrelated profile-adjacent surfaces: This document covers two profile-adjacent surfaces:
1. **Personal profile** — an HTML page on the WebView host, scraped for display. 1. **Personal details** — full name, email, mobile number and National ID, shown on the login details screen.
2. **Profile image management** — three encrypted-API endpoints (`P40`/`P41`/`P42`) that fetch, upload, and delete the avatar. 2. **Profile image management** — three encrypted-API endpoints (`P40`/`P41`/`P42`) that fetch, upload, and delete the avatar.
--- ---
## 1. Personal Profile (HTML scrape) ## 1. Personal Details
Fetch the user's personal profile details. This endpoint returns an HTML page; data is extracted via HTML scraping. The details come from two sources and are fetched once at login, then cached (`MibProfileClient.kt`).
### Endpoint | Field | Source |
|---|---|
| Full name | `A41` login init response — `fullName` |
| Email | Alias suggestions — type `1` (falls back to `A41` `email`) |
| Mobile number | Alias suggestions — type `0` |
| National ID | Alias suggestions — type `3` |
``` ### Full name — `A41`
GET https://faisamobilex-wv.mib.com.mv/personalProfile
The `A41` login init response (see [02-login.md](02-login.md)) carries the user's details at the root:
```json
{
"fullName": "Mohamed Ali",
"email": "user@example.com",
"lastLoginTime": "2026-05-16 15:10:25",
"customerImgHash": "<image hash>",
"roleName": "Consumer Premium"
}
``` ```
### Authentication ### Alias Suggestions — `getAliasTypes`
Session cookies only — no additional AJAX headers required.
``` ```
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597 POST https://faisamobilex-wv-v2.mib.com.mv/ajaxAlias/getAliasTypes
``` ```
### Response WebView session cookies and AJAX headers (see [README](README.md)). Empty POST body.
**Content-Type:** `text/html; charset=UTF-8` ```
Referer: https://faisamobilex-wv-v2.mib.com.mv/Alias/Alias?dashurl=1
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: The suggestions belong to the **currently selected** profile and differ between personal and business profiles. Select the personal profile (`profileType: "0"`) with `P47` first (see [03-accounts.md](03-accounts.md)).
```kotlin
Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""") #### Response
```json
{
"success": true,
"responseCode": "2",
"reasonCode": "203",
"reasonText": "Alias Types Search Success.",
"data": [
{ "id": 0, "aliasTypeName": "Mobile Number", "suggestedAlias": "+9609600001" },
{ "id": 1, "aliasTypeName": "E-Mail", "suggestedAlias": "user@example.com" },
{ "id": 3, "aliasTypeName": "National ID", "suggestedAlias": "A000001" }
]
}
``` ```
**Labelled fields** — each follows this pattern: | `id` | `aliasTypeName` | Used as |
```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 | | `0` | Mobile Number | Mobile number (with `+960` prefix) |
| `Email:` | `email` | Registered email address | | `1` | E-Mail | Email |
| `Mobile no:` | `mobile` | Registered mobile number | | `3` | National ID | ID card number |
| `Enrolled:` | `enrolled` | Enrollment date or status |
Combined with the `fullName` from the `<h5>`: A type may be missing from `data` when the user has no value for it.
```kotlin ### Registered Aliases — `ajaxAlias/main`
data class MibPersonalProfile(
val fullName: String, ```
val username: String, POST https://faisamobilex-wv-v2.mib.com.mv/ajaxAlias/main
val email: String,
val mobile: String,
val enrolled: String
)
``` ```
### Notes Lists the user's registered Favara aliases. Not consumed by the app.
- Returns `null` if the response cannot be parsed (network error or unexpected HTML structure). Body: `page=1&search=&searchAccount=&searchAliasType=&sort=AlsId&sortDir=asc&start=1&end=20&includeCount=1`
- This endpoint does not have a JSON equivalent — scraping is the only method.
```json
{
"success": true,
"reasonText": "Favara ID Search Success.",
"data": [
{
"AlsBfy": { "Tp": "MSISDN", "Id": "+9609600001" },
"BfyNm": "Mohamed",
"BfySnm": "Ali",
"CdtrAcct": { "Acct": "90100000000000000", "FinInstnId": "MADVMVMV" },
"AcctHldrId": "A000001",
"AcctHldrIdTp": "IDENTITY_NUMBER",
"AcctHldrTp": "INDIVIDUAL",
"AdrLine": "<address>",
"Ctry": "MDV",
"RegDtTm": "2026-05-12T20:07:44",
"AlsSt": "ACTIVE",
"PhneNb": "+9609600001",
"EmailAdr": "user@example.com"
}
],
"Pg": { "Size": 20, "TtlElms": 1, "TtlPgs": 1, "Nmb": 0 }
}
```
`AlsBfy.Tp` is the alias type, e.g. `MSISDN` or `NATIONAL_ID`.
--- ---
## 2. Profile Image Management ## 2. Profile Image Management
The avatar lives on the **encrypted API** (`faisanet.mib.com.mv`), not the WebView host. Three `sfunc=n` route paths cover fetch, upload, and delete. All three follow the standard encrypted-request format (see [01-encryption.md](01-encryption.md) and [02-login.md](02-login.md)) and include the standard `baseData` fields (`nonce`, `appId`, `sodium`, `routePath`, `xxid`). The avatar lives on the **encrypted API** (`faisamobilex-smvc-v2.mib.com.mv`), not the WebView host. Three `sfunc=n` route paths cover fetch, upload, and delete. All three follow the standard encrypted-request format (see [01-encryption.md](01-encryption.md) and [02-login.md](02-login.md)) and include the standard `baseData` fields (`nonce`, `appId`, `sodium`, `routePath`, `xxid`).
| `routePath` | Operation | Source | | `routePath` | Operation | Source |
|---|---|---| |---|---|---|
@@ -101,10 +126,10 @@ The same conceptual value — "the hash that identifies a customer's avatar" —
| Source endpoint | Field name | | Source endpoint | Field name |
|---|---| |---|---|
| `A41` login init (`operatingProfiles[]`) | `customerImage` | | `A41` login init (`operatingProfiles[]`) | `customerImage` |
| `A41` login init (root) | `customerImgHash` |
| `C41` registration init (root) | `customerImgHash` | | `C41` registration init (root) | `customerImgHash` |
| `ajaxBeneficiary/main` (contacts list) | `customerImgHash` |
Both are non-empty hash strings that can be passed straight into `P41` as `imageHash`. Treat them as the same value with two names. Both are non-empty hash strings that can be passed straight into `P41` as `imageHash`. Treat them as the same value with two names. Contact photos use a separate route, `B46` (see [09-contacts.md](09-contacts.md)).
### Fetch — `routePath: P41` ### Fetch — `routePath: P41`
+209 -149
View File
@@ -2,197 +2,244 @@
Two-step process: look up the recipient to validate the account and get the holder name, then execute the 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. All endpoints are on the encrypted API (`sfunc=n`, session key). See [README](README.md) for the request format. The source account must belong to the **currently selected** profile — call `P47` for its profile first (see [03-accounts.md](03-accounts.md)).
```
Referer: https://faisamobilex-wv.mib.com.mv/transfer/quick
```
--- ---
## Step 1 — Account Lookup ## Step 1 — Account Lookup — `routePath: B40`
The lookup endpoint depends on the format of the input: One route resolves every supported input:
| Input format | Endpoint | Body field | | Input format | Example | Resolves to |
|---|---|---| |---|---|---|
| Starts with `7`, exactly 13 digits | `AjaxAlias/getIPSAccount` | `benefAccount` | | Starts with `7`, exactly 13 digits | `7700000000000` | BML / local bank account |
| Starts with `9`, exactly 17 digits | `ajaxBeneficiary/getAccountName` | `accountNo` | | Starts with `9`, exactly 17 digits | `90100000000000000` | MIB account |
| Starts with `7` or `9`, exactly 7 digits | `AjaxAlias/getAlias` | `aliasName` | | Starts with `7` or `9`, exactly 7 digits | `9600001` | Favara mobile alias |
| Starts with `A` followed by 6 digits | `AjaxAlias/getAlias` | `aliasName` | | `A` followed by 6 digits | `A000001` | Favara National ID alias |
| Contains `@` (email) | `AjaxAlias/getAlias` | `aliasName` | | Contains `@` | `user@example.com` | Favara email alias |
--- **Request**:
### 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 ```json
{ {
"success": true, "sfunc": "n",
"responseCode": "2", "xxid": "<session xxid>",
"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.
> **USD cross-bank accounts**: `getIPSAccount` only succeeds for **MVR** cross-bank accounts. A 13-digit `7…` account that is denominated in USD returns `success: false` here and cannot be resolved via IPS at all. The client hardcodes `currency = "MVR"` for IPS results (`MibTransferClient.kt:135-137`). For USD BML→MIB transfers the user must first save a BML contact (see Notes below).
---
### 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",
"currencyCode": "462"
}
```
- `accountName` may be at root level or inside a `data` object — check both (`MibTransferClient.kt:160-161`)
- `currencyCode` may also be at root level or inside `data` (`MibTransferClient.kt:162-163`). `"462"` = MVR, `"840"` = USD. The client maps this into `MibIpsAccountInfo.currency` ∈ `{"MVR", "USD", ""}` — this is the **MIB→MIB USD detection** fix from commit `16fd909`.
- 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": { "data": {
"BfyNm": "Account Holder Name", "search": "7700000000000",
"CdtrAcct": { "nonce": "<computed nonce>",
"Acct": "90100000000000000", "appId": "<appId>",
"FinInstnId": "MADVMVMV" "sodium": "<random 20-bit int>",
} "routePath": "B40",
"xxid": "<session xxid>"
} }
} }
``` ```
- `BfyNm` — beneficiary name (trim whitespace) ### Input Rules
- `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. - **Mobile aliases** must be the bare 7 digits. `+960 9600001` and `+9609600001` return no match.
- **National ID aliases** need an uppercase `A` — `a000001` returns no match, `A000001` resolves. The client uppercases the prefix before sending (`MibTransferClient.kt`).
- Partial input (e.g. `9600`) returns an empty `data` array.
--- ### Response
### 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
**BML account**:
```json ```json
{ {
"success": true, "success": true,
"data": [ "data": [
{ {
"trxId": "TRX20260516001", "benefName": "ACCOUNT HOLDER NAME",
"date": "2026-05-16 15:10:25" "benefAccount": "7700000000000",
"bankCode": "MALBMVMV",
"benefBankName": "Bank of Maldives"
} }
] ]
} }
``` ```
**MIB account**:
```json
{
"success": true,
"data": [
{
"benefName": "ACCOUNT HOLDER NAME",
"benefAccount": "90100000000000000",
"bankCode": "MIB",
"benefBankName": "MIB",
"transferCy": "462",
"transferCyDesc": "MVR"
}
]
}
```
**Favara alias** (resolving to an MIB account):
```json
{
"success": true,
"data": [
{
"benefName": "Account Holder Name ",
"benefAccount": "90100000000000000",
"bankCode": "MADVMVMV",
"benefBankName": "Maldives Islamic Bank PLC."
}
]
}
```
| Field | Description |
|---|---|
| `benefName` | Account holder name — trim whitespace |
| `benefAccount` | Resolved account number to use for the transfer |
| `bankCode` | `"MIB"` or a bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML). Normalize `"MIB"` to `MADVMVMV`. |
| `benefBankName` | Bank display name |
| `transferCy` / `transferCyDesc` | Account currency — present for MIB account lookups only |
### Lookup Errors
No match returns `success: true` with an empty `data` array:
```json
{ "success": true, "data": [] }
```
Show "account not found" in that case.
### Currency
| Lookup | Currency |
|---|---|
| MIB account | From `transferCyDesc` (`"MVR"` / `"USD"`) |
| Favara alias → MIB | Not returned — unknown |
| BML / local | `"MVR"` — cross-bank lookups only resolve MVR accounts |
> **USD cross-bank accounts**: a BML account denominated in USD cannot be resolved here. For USD BML→MIB transfers the user must first save a BML contact (see Notes below).
---
## Step 2 — Execute Transfer
Two routes depending on the destination bank:
| `bankNo` | `routePath` | Destination |
|---|---|---|
| `2` | `T50` | MIB internal account |
| `3` | `T51` | BML or other local bank (via Favara) |
**Request**:
```json
{
"sfunc": "n",
"xxid": "<session xxid>",
"data": {
"benefName": "ACCOUNT HOLDER NAME",
"benefNo": "0",
"fromAccountNo": "90101000000001000",
"benefAccountNo": "7700000000000",
"transferCy": "462",
"benefCurrencyCode": "462",
"amount": "100.00",
"bankNo": "3",
"purpose": "",
"otp": "123456",
"otpType": "3",
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "T51",
"xxid": "<session xxid>"
}
}
```
| 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"`, `".01"`) |
| `bankNo` | `"2"` = MIB internal, `"3"` = local/BML |
| `purpose` | Transfer remarks; may be empty |
| `otp` | Current TOTP code |
| `otpType` | `"3"` (authenticator OTP) |
### Response — Success
**`T50`** (internal):
```json
{
"success": true,
"responseCode": "2",
"reasonCode": "2011",
"reasonText": "Internal transfer success",
"data": [
{ "date": "2026-05-16 15:10:25", "trxId": "97999863", "availableBalance": "37.41" }
],
"accountBalance": [ ... ]
}
```
**`T51`** (local):
```json
{
"success": true,
"responseCode": "2",
"reasonCode": "2011",
"reasonText": "Local transfer completed successfully via Favara",
"data": [
{ "date": "2026-05-16 15:10:25", "trxId": "817054125" }
]
}
```
| Field | Description | | Field | Description |
|---|---| |---|---|
| `trxId` | Transaction ID | | `trxId` | Transaction ID |
| `date` | Completion timestamp | | `date` | Completion timestamp |
| `availableBalance` | `T50` only — source account balance after the transfer |
| `accountBalance` | `T50` only — refreshed balances for the selected profile (same shape as in [03-accounts.md](03-accounts.md)) |
### Response — Failure ### Response — Failure
Failures return HTTP `400` with an encrypted body:
```json ```json
{ {
"success": false, "success": false,
"reasonText": "Insufficient balance" "responseCode": "1",
"reasonCode": "213",
"reasonText": "Invalid OTP",
"data": [ { "otpId": "50487016" } ]
} }
``` ```
`reasonText` contains the error reason. On HTTP `419`, the session has expired — re-login required. | `reasonCode` | `reasonText` | Meaning |
|---|---|---|
| `213` | `Invalid OTP` | Wrong or stale OTP |
| `707` | `That OTP is already used!` | The OTP was already accepted for an earlier request |
| `201` | `Transaction currency does not match From Account currency!` | `transferCy` doesn't match the source account |
Show `reasonText` to the user.
### OTP Retry
On `213` or `707` the client waits for the next 30-second TOTP window (plus 1 second for clock skew), generates a new code and resends the transfer **once**. A second failure of any kind is shown to the user (`MibTransferHandler.kt`).
--- ---
## Transfer Type Summary ## Transfer Type Summary
| Recipient | Lookup endpoint | `bankNo` | Transfer endpoint | | Recipient | Lookup | `bankNo` | Transfer |
|---|---|---|---| |---|---|---|---|
| MIB (17-digit `9…`) | `getAccountName` | `2` | `transferInternal` | | MIB (17-digit `9…`) | `B40` | `2` | `T50` |
| BML (13-digit `7…`) | `getIPSAccount` | `3` | `transferLocal` | | BML (13-digit `7…`) | `B40` | `3` | `T51` |
| Favara alias → MIB | `getAlias` | `2` | `transferInternal` | | Favara alias → MIB | `B40` | `2` | `T50` |
| Favara alias → BML | `getAlias` | `3` | `transferLocal` | | Favara alias → BML | `B40` | `3` | `T51` |
--- ---
@@ -205,23 +252,36 @@ POST https://faisamobilex-wv.mib.com.mv/ajaxTransfer/transferLocal
--- ---
## Other Transfer-Screen Routes
Called by the official app when opening the transfer screen. Not consumed by thijooree.
| `routePath` | Request | Returns |
|---|---|---|
| `T63` | — | Currencies with rates (`numericCode`, `code`, `name`, `rate`) |
| `T66` | `currencyCode` | Sell rate for a currency (`sellRate`) |
| `T70` | — | Upload settings (`numFiles`, `fileSize`) |
| `T71` | optional `transferCy` | AML thresholds (`eddAmount`, `cddAmount`, `currencyCode`) |
---
## `MibIpsAccountInfo` (client model) ## `MibIpsAccountInfo` (client model)
All three lookups return this unified structure (`MibModels.kt:42-47`): `B40` lookups are mapped into this structure (`MibModels.kt`):
| Field | Description | | Field | Description |
|---|---| |---|---|
| `accountName` | Account holder name (trimmed) | | `accountName` | Account holder name (trimmed) |
| `accountNumber` | Resolved account number | | `accountNumber` | Resolved account number |
| `bankId` | Bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML, etc.) | | `bankId` | Bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML, etc.) |
| `currency` | `"MVR"`, `"USD"`, or `""` (unknown). Populated from `currencyCode` for MIB internal lookups; hardcoded `"MVR"` for IPS lookups; default `""` for alias lookups. | | `currency` | `"MVR"`, `"USD"`, or `""` (unknown) — see Currency above |
--- ---
## Notes ## Notes
- **BML → MIB USD transfers** require a saved BML contact first. Because `getIPSAccount` rejects USD accounts (`success: false`), the app cannot validate the BML USD account number directly. The workaround in `TransferFragment.kt` is to call `MibContactsClient.createContact` (see [09-contacts.md](09-contacts.md)) to auto-add the BML account as a beneficiary, then transfer to that beneficiary. Introduced in commit `16fd909`. - **BML → MIB USD transfers** require a saved BML contact first. Because USD BML accounts can't be resolved via `B40`, the app cannot validate the account number directly. The workaround in `TransferFragment.kt` is to add the BML account as a beneficiary (see [09-contacts.md](09-contacts.md)), then transfer to that beneficiary.
- **Session expiry**: HTTP `419` on either lookup or transfer means the session expired. See [README](README.md) for the unified expiry detection rules. - **Session expiry**: see [README](README.md) for the unified expiry detection rules.
--- ---
+187 -132
View File
@@ -1,62 +1,58 @@
# Contacts (Beneficiaries) # Contacts (Beneficiaries)
Manage the user's saved beneficiary list. All endpoints use WebView session auth — see [README](README.md). Manage the user's saved beneficiary list. All endpoints are on the encrypted API (`sfunc=n`, session key) — see [README](README.md) for the request format.
``` Contacts belong to the **currently selected** profile — call `P47` for the profile first (see [03-accounts.md](03-accounts.md)).
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary?dashurl=1
``` Every write returns a `beneficiaryVersion` counter that increments on each change.
--- ---
## List Categories ## List Contacts — `routePath: B41`
```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getCategories
```
Empty POST body.
### Response
**Request**:
```json ```json
{ {
"success": true, "sfunc": "n",
"data": [ "xxid": "<session xxid>",
{ "id": "100001", "categoryName": "Myself", "numBenef": "2" }, "data": {
{ "id": "100002", "categoryName": "Friends", "numBenef": "10" }, "start": 1,
{ "id": "100003", "categoryName": "Business", "numBenef": "8" }, "end": 10,
{ "id": "100004", "categoryName": "Family", "numBenef": "5" } "benefType": "A",
] "search": "",
"includeCount": 1,
"searchCategoryId": 0,
"sortBenef": "",
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random 20-bit int>",
"routePath": "B41",
"xxid": "<session xxid>"
}
} }
``` ```
| 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 | | Field | Example | Description |
|---|---|---| |---|---|---|
| `page` | `1` | Page number (1-based) | | `start` | `1` | First row (`rnum`, 1-based, inclusive) |
| `search` | `` | Search query (empty = all) | | `end` | `10` | Last row (inclusive) |
| `searchCategoryId` | `0` | Category filter (`0` = all) |
| `benefType` | `A` | `A`=All, `L`=Local, `I`=Internal, `S`=Swift | | `benefType` | `A` | `A`=All, `L`=Local, `I`=Internal, `S`=Swift |
| `sortBenef` | `name` | Sort field | | `search` | `` | Search query (empty = all) |
| `sortDir` | `asc` | Sort direction |
| `start` | `1` | Start record index (1-based) |
| `end` | `100` | End record index |
| `includeCount` | `1` | Include `total_count` | | `includeCount` | `1` | Include `total_count` |
| `searchCategoryId` | `0` | Category filter (`0` = all) |
| `sortBenef` | `` | Sort field (empty = default) |
### Pagination
Rows are selected by their 1-based `rnum`, both ends inclusive. The client pages in windows of 10:
| Page | `start` | `end` |
|---|---|---|
| 1 | `1` | `10` |
| 2 | `11` | `20` |
| N | `(N-1)*10 + 1` | `N*10` |
The last row of the final page has `last: "1"`. Stop there, or when total fetched equals `total_count`.
### Beneficiary Types ### Beneficiary Types
@@ -71,24 +67,42 @@ POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
```json ```json
{ {
"success": true, "success": true,
"total_count": "48", "responseCode": "1",
"reasonCode": "103",
"reasonText": "beneficiary retrieval success",
"total_count": "47",
"pos": "0",
"data": [ "data": [
{ {
"benefNo": "100001", "benefNo": "100001",
"benefName": "Person Name", "benefName": "Person Name",
"benefNickName": "Nickname",
"benefAccount": "7700000000001", "benefAccount": "7700000000001",
"benefType": "L", "benefType": "L",
"bankColor": "#AC0000", "bankColor": "#AC0000",
"benefBankName": "Bank of Maldives PLC", "benefBankName": "Bank of Maldives PLC",
"bankCode": "BML", "bankCode": "BML",
"benefSwiftCode": "MALBMVMV",
"benefStatus": "A", "benefStatus": "A",
"benefBankId": "3", "benefBankId": "3",
"benefNickName": "Nickname",
"benefSwiftCode": "MALBMVMV",
"transferCy": "462", "transferCy": "462",
"transferCyDesc": "MVR", "transferCyDesc": "MVR",
"customerImgHash": "abcd1234hash", "customerImgHash": "<image hash>",
"benefCategoryID": "100002" "benefImgHash": "<image hash>",
"benefCategoryID": "100002",
"createdDate": "2025-05-25 10:38:49",
"rnum": "1",
"last": "0"
}
],
"categories": [
{
"id": "100002",
"categoryName": "Friends",
"icon": "f095",
"createdDate": "2023-05-18 14:35:22",
"modifiedDate": "2023-05-19 12:06:05",
"numBenef": "23"
} }
] ]
} }
@@ -96,37 +110,51 @@ POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
| Field | Consumed | Description | | Field | Consumed | Description |
|---|---|---| |---|---|---|
| `benefNo` | yes | Unique beneficiary ID — use for delete | | `benefNo` | yes | Unique beneficiary ID — use for update and delete |
| `benefName` | yes | Legal/full name; used as fallback when nickname is blank | | `benefName` | yes | Legal/full name; used as fallback when nickname is blank |
| `benefNickName` | yes | User-assigned nickname (prefer over `benefName` for display) | | `benefNickName` | yes | User-assigned nickname (prefer over `benefName` for display) |
| `benefAccount` | yes | Account number | | `benefAccount` | yes | Account number |
| `benefType` | yes | `L`, `I`, or `S` | | `benefType` | yes | `L`, `I`, or `S` |
| `bankColor` | yes | Hex color for placeholder avatar background | | `bankColor` | yes | Hex color for placeholder avatar background |
| `benefBankName` | yes | Bank display name. Shown as transfer subtitle and contact detail (`MibContactParser.kt:23, 26`). | | `benefBankName` | yes | Bank display name. Shown as transfer subtitle and contact detail (`MibContactParser.kt`). |
| `bankCode` | yes | Short bank code (e.g. `"BML"`, `"MIB"`) | | `bankCode` | yes | Short bank code (e.g. `"BML"`, `"MIB"`) |
| `benefStatus` | yes | Beneficiary status (`"A"` = active) | | `benefStatus` | yes | Beneficiary status (`"A"` = active) |
| `transferCyDesc` | yes | Currency (e.g. `"MVR"`, `"USD"`) | | `transferCyDesc` | yes | Currency (e.g. `"MVR"`, `"USD"`) |
| `customerImgHash` | yes | Hash for fetching profile photo. May be missing, blank, or the literal **string** `"null"` — the client filters all three (`MibContactsClient.kt:120`) so downstream code only sees a real hash or `null`. | | `customerImgHash` | yes | Hash for fetching the contact photo via `B46`. May be missing, blank, or the literal **string** `"null"` — the client filters all three so downstream code only sees a real hash or `null`. |
| `benefCategoryID` | yes | Category ID — `"0"` means uncategorized | | `benefCategoryID` | yes | Category ID — `"0"` means uncategorized |
| `benefSwiftCode` | server-only | SWIFT BIC of the beneficiary's bank — present in payload, not read by the app | | `benefImgHash` | server-only | Photo set on the beneficiary itself; equals `customerImgHash` when set |
| `benefSwiftCode` | server-only | SWIFT BIC — present in payload, not read by the app |
| `benefBankId` | server-only | Numeric bank ID — not consumed | | `benefBankId` | server-only | Numeric bank ID — not consumed |
| `transferCy` | server-only | Currency numeric code (e.g. `"462"`) — only `transferCyDesc` is consumed | | `transferCy` | server-only | Currency numeric code (e.g. `"462"`) — only `transferCyDesc` is consumed |
| `rnum` / `last` | server-only | Row number and last-row flag, used for paging |
### Categories
Every `B41` response carries the full category list in `categories` — there is no separate categories call.
| Field | Description |
|---|---|
| `id` | Category ID — use as `searchCategoryId` / `categoryId` |
| `categoryName` | Display name |
| `numBenef` | Number of beneficiaries in this category (string) |
| `icon` | Icon code |
--- ---
## Get Profile Image ## Get Contact Photo — `routePath: B46`
**Request** — route fields:
```json
{ "imageHash": "<customerImgHash>", "routePath": "B46" }
``` ```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/getProfileImage
```
Body: `imageHash=<customerImgHash>`
### Response ### Response
```json ```json
{ {
"success": true, "success": true,
"msg": "image found",
"message": "image found",
"profileImage": "<base64-encoded JPEG>" "profileImage": "<base64-encoded JPEG>"
} }
``` ```
@@ -135,98 +163,125 @@ Body: `imageHash=<customerImgHash>`
--- ---
## Create Contact ## Create Contact — `routePath: B43`
**Request** — route fields:
```json
{
"benefName": "RECIPIENT NAME",
"benefAccount": "7700000000001",
"bankNo": "3",
"nickName": "Nickname",
"transferCy": "462",
"categoryId": "0",
"image": "<base64 JPEG, optional>",
"bankName": "Bank of Maldives PLC",
"bankSwift": "MALBMVMV",
"routePath": "B43"
}
``` ```
POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/createLocalBeneficiary
```
```
Referer: https://faisamobilex-wv.mib.com.mv/beneficiary/createNew
```
### Request Body (form-urlencoded)
| Field | Description | | Field | Description |
|---|---| |---|---|
| `benefType` | `"I"` = MIB internal, `"L"` = local/BML | | `benefName` | Full name (from account lookup) |
| `benefAccount` | Beneficiary account number | | `benefAccount` | Beneficiary account number |
| `benefName` | Full name | | `bankNo` | `"2"` = MIB, `"3"` = BML |
| `nickName` | Display nickname | | `nickName` | Display nickname — **max 20 characters** |
| `bankNo` | `2` = MIB, `3` = BML/local | | `transferCy` | Currency numeric code (`"462"` = MVR, `"840"` = USD) |
| `transferCy` | Currency numeric code (`"462"` = MVR) |
| `categoryId` | Category ID (`"0"` = uncategorized) | | `categoryId` | Category ID (`"0"` = uncategorized) |
| `imageSet` | `"1"` if image provided, `"0"` otherwise | | `image` | Base64 JPEG photo. Omit the field when there is no photo. |
| `image` | Base64-encoded image (empty string if none) | | `bankName` | Bank name from the login bank list (`A41` `bankData`) |
| `benefIban` | Leave blank | | `bankSwift` | Bank BIC from the same list |
| `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 | `bankNo` | `bankName` | `bankSwift` |
|---|---|---|
```json | `2` | Maldives Islamic Bank | `MADVMVMV` |
{ "success": true } | `3` | Bank of Maldives PLC | `MALBMVMV` |
```
`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 ### Response
```json ```json
{ {
"success": true, "success": true,
"data": [ "responseCode": "1",
{ "type": "L", "count": "30" }, "reasonCode": "102",
{ "type": "I", "count": "10" }, "reasonText": "beneficiary creation success",
{ "type": "S", "count": "2" } "data": [ { "benefNo": "1336023", "benefNickName": "Nickname", "customerImgHash": "<image hash>", ... } ],
] "beneficiaryVersion": 8
} }
``` ```
Gives counts per beneficiary type. Useful for showing tab badges. ### Errors
A nickname over 20 characters is rejected with HTTP `400`:
```json
{ "success": false, "responseCode": "2", "reasonCode": "201", "reasonText": "invalid nick name", "errorKey": "nickName" }
```
---
## Update Contact — `routePath: B52`
Same fields as `B43`, plus:
| Field | Description |
|---|---|
| `benefNo` | Beneficiary to update |
| `imageChanged` | `"1"` when a new `image` is sent, `"0"` to keep the current photo |
**Request** — route fields:
```json
{
"benefName": "RECIPIENT NAME",
"benefAccount": "7700000000001",
"bankNo": "3",
"nickName": "New nickname",
"transferCy": "462",
"categoryId": "100002",
"benefNo": "240301",
"imageChanged": "0",
"bankName": "Bank of Maldives PLC",
"bankSwift": "MALBMVMV",
"routePath": "B52"
}
```
### Response
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "107",
"reasonText": "Beneficiary Updated Successfully",
"data": [ { "benefNo": "240301", "benefNickName": "New nickname", "customerImgHash": "<image hash>", ... } ],
"beneficiaryVersion": 6
}
```
Reload the list with `B41` afterwards to pick up the change.
---
## Delete Contact — `routePath: B48`
**Request** — route fields:
```json
{ "benefNo": "240301", "routePath": "B48" }
```
### Response
```json
{
"success": true,
"responseCode": "1",
"reasonCode": "108",
"reasonText": "Beneficiary deleted Successfully",
"beneficiaryVersion": 7
}
```
--- ---
+2 -2
View File
@@ -9,7 +9,7 @@ Source: `MibActivityHistoryClient.kt`.
## Endpoint ## Endpoint
``` ```
POST https://faisamobilex-wv.mib.com.mv/aProfile/getPagedActivityHistory POST https://faisamobilex-wv-v2.mib.com.mv/aProfile/getPagedActivityHistory
``` ```
--- ---
@@ -21,7 +21,7 @@ WebView session cookies (see [README](README.md)) plus `X-Requested-With: XMLHtt
Unlike most WebView AJAX calls, this endpoint sends **no `Referer`** and **no `Origin`** header. Unlike most WebView AJAX calls, this endpoint sends **no `Referer`** and **no `Origin`** header.
``` ```
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597 Cookie: mbmodel=AND-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
X-Requested-With: XMLHttpRequest X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Linux; Android <ver>; wv) AppleWebKit/537.36 ... User-Agent: Mozilla/5.0 (Linux; Android <ver>; wv) AppleWebKit/537.36 ...
``` ```
+36 -10
View File
@@ -12,8 +12,8 @@ MIB uses **two completely separate backends**:
| Backend | Base URL | Auth | Used for | | Backend | Base URL | Auth | Used for |
|---|---|---|---| |---|---|---|---|
| Encrypted API | `https://faisanet.mib.com.mv/faisamobilex_smvc/` | Blowfish + DH session key | Login, key exchange | | Encrypted API | `https://faisamobilex-smvc-v2.mib.com.mv/index/` | Blowfish + DH session key | Login, accounts, history, transfers, contacts, profile images |
| WebView host | `https://faisamobilex-wv.mib.com.mv` | Session cookies | Accounts, history, transfers, contacts, cards, financing | | WebView host | `https://faisamobilex-wv-v2.mib.com.mv` | Session cookies | Cards, financing, personal details, activity history, keep-alive |
--- ---
@@ -22,10 +22,24 @@ MIB uses **two completely separate backends**:
All calls to the encrypted API are `POST /` with `Content-Type: application/x-www-form-urlencoded; charset=utf-8` and form body: All calls to the encrypted API are `POST /` with `Content-Type: application/x-www-form-urlencoded; charset=utf-8` and form body:
``` ```
sfunc=<function_code>&data=<url_encoded_base64_blowfish_ciphertext> xxid=<session_xxid>&sfunc=<function_code>&data=<url_encoded_base64_blowfish_ciphertext>
``` ```
The request JSON is encrypted with Blowfish (ECB, PKCS5) before sending. The response body is also base64-encoded Blowfish ciphertext. `xxid` is omitted on the key exchanges (`sfunc=r` / `sfunc=i`); `sfunc=i` sends `key2` instead. The request JSON is encrypted with Blowfish (ECB, PKCS5) before sending. The response body is also base64-encoded Blowfish ciphertext — on failures too, which come back as HTTP `400` with an encrypted `success: false` body.
Every `sfunc=n` call is identified by a `routePath` inside the encrypted payload, alongside the standard fields:
```json
{
"nonce": "<computed nonce>",
"appId": "<appId>",
"sodium": "<random int>",
"routePath": "<route, e.g. A84>",
"xxid": "<session xxid>"
}
```
Route-specific fields are added to the same object. See [02-login.md](02-login.md) for the request headers.
Two keys are used: Two keys are used:
@@ -40,17 +54,17 @@ See [01-encryption.md](01-encryption.md) for full details.
## WebView Session Auth ## WebView Session Auth
After login, all data endpoints use cookie-based auth on `faisamobilex-wv.mib.com.mv`: After login, the WebView endpoints use cookie-based auth on `faisamobilex-wv-v2.mib.com.mv`:
``` ```
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597 Cookie: mbmodel=AND-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
``` ```
These values come from the login flow — `xxid` and `nonceGenerator` from the DH key exchange response. These values come from the login flow — `xxid` and `nonceGenerator` from the DH key exchange response.
**Cookie notes:** **Cookie notes:**
- `time-tracker=597` is a **hardcoded constant** in the client. Every WebView client (transfers, contacts, cards, activity, etc.) sends the literal value `597` — it is not computed or rotated. - `time-tracker=597` is a **hardcoded constant** in the client. Every WebView client (cards, financing, activity, etc.) sends the literal value `597` — it is not computed or rotated.
- `mbnonce` is the **unmodified `nonceGenerator` string** from the key-exchange response. It does **not** carry a freshly-computed per-request nonce. The actual per-request nonce (derived via the algorithm in [01-encryption.md](01-encryption.md)) only appears inside the encrypted payloads of `sfunc=n` calls on the encrypted API — WebView endpoints have no nonce field. - `mbnonce` is the **unmodified `nonceGenerator` string** from the key-exchange response. It does **not** carry a freshly-computed per-request nonce. The actual per-request nonce (derived via the algorithm in [01-encryption.md](01-encryption.md)) only appears inside the encrypted payloads of `sfunc=n` calls on the encrypted API — WebView endpoints have no nonce field.
### WebView AJAX Headers ### WebView AJAX Headers
@@ -60,7 +74,7 @@ All AJAX `POST` calls also require:
``` ```
X-Requested-With: XMLHttpRequest X-Requested-With: XMLHttpRequest
Accept: */* Accept: */*
Origin: https://faisamobilex-wv.mib.com.mv Origin: https://faisamobilex-wv-v2.mib.com.mv
Content-Type: application/x-www-form-urlencoded; charset=UTF-8 Content-Type: application/x-www-form-urlencoded; charset=UTF-8
``` ```
@@ -79,6 +93,18 @@ A MIB session is considered expired when **either** condition is met (`MibLoginF
On detection the client auto-recovers by re-running the login flow using stored credentials, refreshes `xxid` + `nonceGenerator` in the in-flight payload, and retries the original request once. Callers receive the retried response transparently. If the recovery itself hits expiry again it surfaces a `SessionExpiredException`. On detection the client auto-recovers by re-running the login flow using stored credentials, refreshes `xxid` + `nonceGenerator` in the in-flight payload, and retries the original request once. Callers receive the retried response transparently. If the recovery itself hits expiry again it surfaces a `SessionExpiredException`.
### Keep-Alive
```
POST https://faisamobilex-wv-v2.mib.com.mv/aProfile/keepAlive
```
Empty body, session cookies only. Sent periodically to keep the session open. An expired session returns HTTP `419` with:
```json
{ "success": false, "responseCode": "3", "reasonCode": "41", "reasonText": "Session Expired" }
```
### WebView User-Agent ### WebView User-Agent
``` ```
@@ -93,11 +119,11 @@ Mozilla/5.0 (Linux; Android {version}; wv) AppleWebKit/537.36 (KHTML, like Gecko
|---|---|---| |---|---|---|
| 1 | [01-encryption.md](01-encryption.md) | Blowfish encryption, DH key exchange, nonce computation | | 1 | [01-encryption.md](01-encryption.md) | Blowfish encryption, DH key exchange, nonce computation |
| 2 | [02-login.md](02-login.md) | Device registration and regular login flows | | 2 | [02-login.md](02-login.md) | Device registration and regular login flows |
| 3 | [03-accounts.md](03-accounts.md) | Select profile, account balances | | 3 | [03-accounts.md](03-accounts.md) | Select profile, account balances, account refresh |
| 4 | [04-history.md](04-history.md) | Transaction history | | 4 | [04-history.md](04-history.md) | Transaction history |
| 5 | [05-cards.md](05-cards.md) | Debit card list | | 5 | [05-cards.md](05-cards.md) | Debit card list |
| 6 | [06-financing.md](06-financing.md) | Financing deals | | 6 | [06-financing.md](06-financing.md) | Financing deals |
| 7 | [07-profile.md](07-profile.md) | Personal profile (HTML scrape) | | 7 | [07-profile.md](07-profile.md) | Personal details, profile image |
| 8 | [08-transfer.md](08-transfer.md) | Account lookup and fund transfer | | 8 | [08-transfer.md](08-transfer.md) | Account lookup and fund transfer |
| 9 | [09-contacts.md](09-contacts.md) | Beneficiary management | | 9 | [09-contacts.md](09-contacts.md) | Beneficiary management |
| 10 | [10-activity-history.md](10-activity-history.md) | Activity / audit log | | 10 | [10-activity-history.md](10-activity-history.md) | Activity / audit log |
+1 -1
View File
@@ -129,4 +129,4 @@ Always fall back to the other provider's lookup if this API returns `custType: n
--- ---
[← README](README.md) [← README](README.md) · [Raastas →](02-raastas.md)
+107
View File
@@ -0,0 +1,107 @@
# Raastas (Quick Pay recharge, paid by BML card)
Recharge an Ooredoo prepaid number through the ooredoo.mv **Quick Pay** page. Ooredoo creates the
order and hands back a **BML Merchant Services transaction**, which is paid like any card-only
BML merchant link ([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
Reconstructed from `docs/ooredooapi/tmp/ooredoo_raastas_bml_card.har` (a Firefox HAR, which also
holds a rejected OTP and an insufficient-funds decline on the same transaction).
---
## Flow overview
```
GET /ooredoo-prod/QuickPayPackage/v1/numberTypeValidation?… → custType PRE (Number Validation)
POST /ooredoo-prod/PaymentGateway/bml → orderID, bmlUrl ──┐
│
── from here: the BML card-only merchant flow ── │
GET transaction.merchants…/<id> ←──────────────────────────────────────────────────┘
… Pomelo tokenise, next-action, Wibmo 3-D Secure, MPGS … → TRANSACTION_CONFIRMED
GET transaction.merchants…/<id>?wait=1 → 302 my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED
(200, auto-submits) → POST www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml
→ /payment-status?order_id=<orderID>&statusDesc=sucess&status=1
```
Unlike Dhiraagu, there's no nonce or cart: one POST makes the order and the BML transaction.
---
## 1. Order
`POST https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/bml`
| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `Accept` | `application/json` |
| `Origin` | `https://www.ooredoo.mv` |
```json
{"msisdn":"9609XXXXXX","purchaseAmount":"21.60","amountWithoutGst":"20",
"receiverMsisdn":"9609XXXXXX","transType":"recharge","serviceType":"prepaid",
"serviceTypeDisplayName":"Mobile"}
```
```json
{"status":"OK","msg":"Successfully generated order id","code":"2000",
"data":{"orderID":"36447930","purchaseAmount":"2160","hashSignature":"…",
"shortUrl":"https://pay.bml.com.mv/7A86qLZ1QV",
"bmlUrl":"https://transaction.merchants.bankofmaldives.com.mv/6ac009f7bd9b264b80ba6abf", …}}
```
The 24-hex id at the end of `bmlUrl` is the transaction (`shortUrl` 301s to the same page). The
page is card-only, no BML Pay. The transaction's `localId` is the MSISDN, `customerReference` the
order id, and it expires after 7 days.
### GST
**Added on top**, not taken out: the number is credited `amountWithoutGst` and the card pays
`purchaseAmount = amount + toFixed2(amount × 8 / 100)` (MVR 20 → 21.60). The page's payment
method list gives `gstPercent: 8` for BML. This is the opposite of Raastas through Fahipay, where
GST comes out of the amount.
### Limits
Whole MVR, minimum **20** (before GST, so the card pays at least 21.60). The maximum isn't known;
the capture starts on the payment page.
---
## 2. Return to Ooredoo
`?wait=1` 302s to `https://my.ooredoo.mv/bml/response_new.php?transactionId=<id>&state=CONFIRMED&signature=<hash>`.
That page is a 200 that auto-submits:
```html
<body onload="document.forms['wtmpay'].submit()">
<form method="POST" action="https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml" name="wtmpay">
order_id=36447930 amount=21.60000000 msisdn=9609XXXXXX transtype=2
bml_transaction_id=<id> bml_hash=<hash> bml_response=CONFIRMED
error_code=0 payment_status=success ptype=bml
```
which ends on `https://www.ooredoo.mv/payment-status?order_id=36447930&statusDesc=sucess&status=1`
(the receipt: `totalAmount 21.6`, `amountWithoutGst 20`, `gstAmt 1.6`). The card flow submits the
form, see [BML API → Return to the merchant](../bmlapi/16-card-payment.md#7-return-to-the-merchant).
A declined attempt sends `?wait=1` to `transaction…/<id>?error=1` instead, and the same
transaction can be paid again.
---
## Cloudflare
`ooredoo.mv` and `my.ooredoo.mv` are behind Cloudflare. The capture carries a `cf_clearance`
cookie; okhttp from the phone gets through without one, as with
[Number Validation](01-number-validation.md).
---
&nbsp;
---
**Related:** [Number Validation](01-number-validation.md) · [BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
[← Number Validation](01-number-validation.md) · [Bill Pay →](03-bill-pay.md)
+65
View File
@@ -0,0 +1,65 @@
# Bill Pay (Quick Pay, paid by BML card)
Pay an Ooredoo postpaid bill through the ooredoo.mv **Quick Pay** bill-pay page. It is the same
single order call as [Raastas](02-raastas.md) with a different `transType` / `serviceType`, and
no GST. From the order on, it's the BML card-only merchant flow and the same return to Ooredoo.
Reconstructed from `docs/ooredooapi/tmp/ooredoo_billpay_bml_card.har` (a Firefox HAR).
---
## Flow overview
```
GET /bill-pay
GET /ooredoo-prod/QuickPayPackage/v1/numberTypeValidation?… → custType POST (Number Validation)
POST /ooredoo-prod/PaymentGateway/bml → orderID, bmlUrl
── BML card-only merchant flow ── → TRANSACTION_CONFIRMED
GET transaction.merchants…/<id>?wait=1 → 302 my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED
(200, auto-submits, transtype=1) → POST www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml
→ /payment-status?statusDesc=sucess&status=1&order_id=<orderID>
```
The page doesn't look up the outstanding bill: the amount is whatever is typed.
---
## Order
`POST https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/bml`, headers as in
[Raastas → Order](02-raastas.md#1-order).
```json
{"msisdn":"9609XXXXXX","purchaseAmount":"10.01","amountWithoutGst":"10.01",
"receiverMsisdn":"9609XXXXXX","transType":"billpay","serviceType":"Mobile",
"serviceTypeDisplayName":"Mobile"}
```
```json
{"status":"OK","msg":"Successfully generated order id","code":"2000",
"data":{"orderID":"36447962","purchaseAmount":"1001","shortUrl":"https://pay.bml.com.mv/…",
"bmlUrl":"https://transaction.merchants.bankofmaldives.com.mv/6ac011e099f9d890856e2d33", …}}
```
| | Raastas | Bill Pay |
|---|---|---|
| `transType` | `recharge` | `billpay` |
| `serviceType` | `prepaid` | `Mobile` |
| `amountWithoutGst` | amount credited | = `purchaseAmount` |
| GST | 8% added on top | none |
| Return form `transtype` | `2` | `1` |
### Limits
Minimum **MVR 10**, decimals allowed (up to 2 places). The maximum isn't known.
---
&nbsp;
---
**Related:** [Number Validation](01-number-validation.md) · [Raastas](02-raastas.md) ·
[BML Merchant Card Payment](../bmlapi/16-card-payment.md) ·
App side: [Transfer Flows](../thijooree/20-transfer-flows.md#carrier-services-by-bml-card)
[← Raastas](02-raastas.md)
+2
View File
@@ -81,6 +81,8 @@ The API expects the full MSISDN including country code `960` (e.g. `9609654321`)
| # | File | Description | | # | File | Description |
|---|---|---| |---|---|---|
| 1 | [Number Validation](01-number-validation.md) | Validate an Ooredoo number and determine account type | | 1 | [Number Validation](01-number-validation.md) | Validate an Ooredoo number and determine account type |
| 2 | [Raastas](02-raastas.md) | Quick Pay recharge order → BML merchant transaction, paid by card |
| 3 | [Bill Pay](03-bill-pay.md) | Quick Pay bill payment order → BML merchant transaction, paid by card |
--- ---
+5 -5
View File
@@ -77,7 +77,7 @@ A phone number searched with no source yet (or from a BML card that can pay by c
|---|---|---| |---|---|---|
| Favara Transfer | bank account behind the number | MIB or BML account | | Favara Transfer | bank account behind the number | MIB or BML account |
| Fahipay service | Raastas, Ooredoo Bill Pay, Dhiraagu Reload, Dhiraagu Bill Pay | Fahipay wallet | | Fahipay service | Raastas, Ooredoo Bill Pay, Dhiraagu Reload, Dhiraagu Bill Pay | Fahipay wallet |
| Card service | Dhiraagu Reload (BML badge) | Verified BML card | | Card service | Dhiraagu Reload, Dhiraagu Bill Pay, Raastas, Ooredoo Bill Pay (BML badge) | Verified BML card |
One option is applied straight away; with more, a picker opens and Send stays disabled until one is chosen. Picking a type also picks a source that can pay it. Fahipay and card services clear and disable the Remarks field and apply their own amount rules (minimum, maximum, whole amounts, 8% GST note). Details: [Transfer Flows → Transfer Type picker](20-transfer-flows.md#transfer-type-picker). One option is applied straight away; with more, a picker opens and Send stays disabled until one is chosen. Picking a type also picks a source that can pay it. Fahipay and card services clear and disable the Remarks field and apply their own amount rules (minimum, maximum, whole amounts, 8% GST note). Details: [Transfer Flows → Transfer Type picker](20-transfer-flows.md#transfer-type-picker).
@@ -122,11 +122,11 @@ When the source is a BML USD account and the destination is a MIB account but no
See [Transfer Flows → Fahipay source](20-transfer-flows.md#fahipay-source). See [Transfer Flows → Fahipay source](20-transfer-flows.md#fahipay-source).
### Carrier Service by BML Card (Dhiraagu Reload) ### Carrier Service by BML Card (Dhiraagu Reload / Bill Pay, Ooredoo Raastas / Bill Pay)
1. Checks the amount against Dhiraagu's rules (MVR 20–1000, whole amounts, 8% GST included) 1. Checks the amount against the carrier's rules (Dhiraagu reload: MVR 20–1000, whole amounts, 8% GST included; Dhiraagu bill pay: from MVR 1, up to 2 decimals, no GST; Raastas: from MVR 20, whole amounts, 8% GST added on top; Ooredoo bill pay: from MVR 10, up to 2 decimals, no GST)
2. A "Processing..." dialog shows while Dhiraagu creates the order and its BML merchant transaction ([Dhiraagu API → Reload](../dhiraaguapi/02-reload.md)) 2. A "Processing..." dialog shows while the carrier creates the order and its BML merchant transaction ([Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md), [Ooredoo API → Raastas](../ooredooapi/02-raastas.md), [→ Bill Pay](../ooredooapi/03-bill-pay.md))
3. From there it is the card-only merchant flow: the same confirm dialog and warning, biometric gate, card + 3-D Secure payment, and the return to Dhiraagu (`?wait=1`) that tops the number up 3. From there it is the card-only merchant flow: the same confirm dialog and warning, biometric gate, card + 3-D Secure payment, and the return to the carrier (`?wait=1`) that tops the number up or posts the bill payment. A decline (e.g. insufficient funds) or a rejected token code ends it with the bank's message
4. On success, the result shows inside the dialog; if Dhiraagu couldn't be notified, a toast gives the BML transaction id 4. On success, the result shows inside the dialog; if Dhiraagu couldn't be notified, a toast gives the BML transaction id
See [Transfer Flows → Carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card). See [Transfer Flows → Carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card).
+30 -5
View File
@@ -105,7 +105,7 @@ Each Fahipay favourites list is one payout service (`FahipayService.contactCateg
| `FAHIPAY_OOREDOO_BILL` | Ooredoo Bill Pay | | `FAHIPAY_OOREDOO_BILL` | Ooredoo Bill Pay |
| `FAHIPAY_DHIRAAGU_BILL` | Dhiraagu Bill Pay | | `FAHIPAY_DHIRAAGU_BILL` | Dhiraagu Bill Pay |
So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyFahipayContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked: So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyServiceContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked:
- the contact picker sheet (the row's category goes back as `ContactPickerSheetFragment.KEY_CATEGORY`) - the contact picker sheet (the row's category goes back as `ContactPickerSheetFragment.KEY_CATEGORY`)
- the "To" field's search-as-you-type dropdown - the "To" field's search-as-you-type dropdown
@@ -151,8 +151,8 @@ None of the Fahipay services take a reference. Picking one clears the Reference
### Carrier services by BML card ### Carrier services by BML card
A carrier service can also be paid with a verified BML card, through the carrier's own website A carrier service can also be paid with a verified BML card, through the carrier's own website
and its BML merchant gateway, instead of the Fahipay wallet. Only **Dhiraagu Reload** so far and its BML merchant gateway, instead of the Fahipay wallet: **Dhiraagu Reload**, **Dhiraagu
(`CardPayoutService`, `ui/home/transfer/CardPayoutTransferHandler.kt`). Bill Pay**, **Ooredoo Raastas** and **Ooredoo Bill Pay** (`CardPayoutService`, `ui/home/transfer/CardPayoutTransferHandler.kt`).
**Which cards.** A card qualifies when it's verified and its BML login has an OTP seed, the same **Which cards.** A card qualifies when it's verified and its BML login has an OTP seed, the same
rule as card-only merchant links (`BmlVerifiedCards`, see rule as card-only merchant links (`BmlVerifiedCards`, see
@@ -165,6 +165,9 @@ drops the pick, like any other source that can't pay the picked type.
| Carrier result | Service | | Carrier result | Service |
|---|---| |---|---|
| Dhiraagu `RELOAD` | Dhiraagu Reload | | Dhiraagu `RELOAD` | Dhiraagu Reload |
| Dhiraagu `BILL_PAY` | Dhiraagu Bill Pay |
| Ooredoo `PRE` or `HYBRID` | Raastas |
| Ooredoo `POST` or `HYBRID` | Ooredoo Bill Pay |
**Amount rules.** The carrier website's, not Fahipay's. They're checked the same way, through **Amount rules.** The carrier website's, not Fahipay's. They're checked the same way, through
the shared `PayoutAmountField`: the shared `PayoutAmountField`:
@@ -172,14 +175,36 @@ the shared `PayoutAmountField`:
| Service | Min (MVR) | Max (MVR) | Decimals | GST | | Service | Min (MVR) | Max (MVR) | Decimals | GST |
|---|---|---|---|---| |---|---|---|---|---|
| Dhiraagu Reload | 20 | 1,000 | no | 8%, included (credit = amount − round2(amount × 0.08 / 1.08)) | | Dhiraagu Reload | 20 | 1,000 | no | 8%, included (credit = amount − round2(amount × 0.08 / 1.08)) |
| Dhiraagu Bill Pay | 1 | none | up to 2 places | none |
| Raastas | 20 | none | no | 8%, **added** (charged = amount + round2(amount × 0.08)) |
| Ooredoo Bill Pay | 10 | none | up to 2 places | none |
Easy Pay itself sets no minimum or maximum; the MVR 1 floor is Thijooree's. The Ooredoo maximums
aren't known.
Raastas by card is the one service where GST is added on top (`PayoutService.gstAdded`): the
number is credited what's typed, the card pays more, and the note under the amount says what's
paid ("You pay MVR 21.60 with 8% GST"). The order check in step 2 below compares against
`chargedWithGst`.
**Reference.** None. The field is cleared and disabled, as for the Fahipay services. **Reference.** None. The field is cleared and disabled, as for the Fahipay services.
**Recents.** As with Fahipay services, the recent is saved with the service's category
(`CardPayoutService.contactCategory`: `CARD_DHIRAAGU_RELOAD`, `CARD_DHIRAAGU_BILL`,
`CARD_RAASTAS`, `CARD_OOREDOO_BILL`). Picking it again applies that service with no lookup, so
the default card (or another payable card) is selected rather than the default account
(`TransferFragment.applyServiceContact`). With no payable card left, it toasts and stops. A number
keeps one recent, so paying it another way replaces the category.
**Sending.** The only part that differs from paying a card-only BML merchant link is where the **Sending.** The only part that differs from paying a card-only BML merchant link is where the
BML transaction comes from: BML transaction comes from:
1. `CardPayoutTransferHandler.submit()` has the carrier create it for the number and amount 1. `CardPayoutTransferHandler.submit()` has the carrier create it for the number and amount
(`DhiraaguReloadClient.createBmlTransaction`, see [Dhiraagu API → Reload](../dhiraaguapi/02-reload.md)). (`DhiraaguPaymentClient.createReloadTransaction` / `createBillPayTransaction`,
`OoredooPaymentClient.createRaastasTransaction` / `createBillPayTransaction`, see
[Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md)
and [Ooredoo API → Raastas](../ooredooapi/02-raastas.md), [→ Bill Pay](../ooredooapi/03-bill-pay.md)).
Bill pay looks the number up again first, for the billing account the order is made out to.
That takes a few round trips, so the payment's "Processing..." box shows meanwhile That takes a few round trips, so the payment's "Processing..." box shows meanwhile
(`TransferFragment.showProcessingDialog`) and closes before the confirm dialog opens. (`TransferFragment.showProcessingDialog`) and closes before the confirm dialog opens.
2. Its payment page is loaded (`BmlMerchantTxnClient.fetchPayPage`). If it doesn't take cards, or 2. Its payment page is loaded (`BmlMerchantTxnClient.fetchPayPage`). If it doesn't take cards, or
@@ -259,7 +284,7 @@ Source: Fahipay
Transfer type: Card (verified BML card) Transfer type: Card (verified BML card)
└── Carrier creates a BML merchant transaction → card-only merchant flow └── Carrier creates a BML merchant transaction → card-only merchant flow
DHIRAAGU_RELOAD DHIRAAGU_RELOAD, DHIRAAGU_BILL, OOREDOO_RAASTAS, OOREDOO_BILL
``` ```
--- ---
@@ -9,7 +9,7 @@ Two linked features:
whose merchant has **no BML Pay** is paid with a verified card via the Pomelo + 3-D Secure flow whose merchant has **no BML Pay** is paid with a verified card via the Pomelo + 3-D Secure flow
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)). The same flow pays ([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)). The same flow pays
[carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card) (Dhiraagu [carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card) (Dhiraagu
Reload), once the carrier has created the transaction. Reload and Bill Pay, Ooredoo Raastas and Bill Pay), once the carrier has created the transaction.
> ⚠️ The merchant card flow is scraped browser/ACS traffic, not a stable API. Storing the CVV is a > ⚠️ The merchant card flow is scraped browser/ACS traffic, not a stable API. Storing the CVV is a
> security/PCI liability. See the API doc's > security/PCI liability. See the API doc's
@@ -0,0 +1,11 @@
- updated dhivehi transaltions (thank you @quillfires)
- improved fahipay support
- new UI to select transfer type (Favara, Reload, Raastas, Billpay)
- Ooredoo Raastas via Fahipay
- Ooredo Billpay via Fahipay
- Dhiraagu reload via Fahipay
- Dhiraagu billpay via Fahipay
- Ooredoo Raastas via BML verified cards
- Ooredoo Billpay via BML verified cards
- Dhiraagu Reload via BML verifed cards
- Dhiraagu billpay via BML verified cards
@@ -0,0 +1,5 @@
- upgraded MIB to new v2 API (login, history, cards, contacts, transfers)
- edit MIB contacts (alias, group, photo)
- MIB contact alias limited to 20 characters
- MIB transfers retry once with a new OTP if OTP is invalid or already used
- MIB login now shows ID card, mobile and email in settings