Compare commits

..
1 Commits
Author SHA1 Message Date
quillfires e2f884656f feat: add Dhivehi localization
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-02 16:06:00 +00:00
70 changed files with 853 additions and 2952 deletions
+12 -3
View File
@@ -24,11 +24,20 @@ 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
if git tag -l | grep -q "^v${VERSION}$"; then BEFORE="${{ github.event.before }}"
echo "Tag v${VERSION} already exists, skipping" if [ -z "$BEFORE" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
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 version detected: v${VERSION}" echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})"
echo "should_release=true" >> $GITHUB_OUTPUT echo "should_release=true" >> $GITHUB_OUTPUT
fi fi
-5
View File
@@ -1,4 +1,3 @@
.envrc
*.iml *.iml
.gradle .gradle
/local.properties /local.properties
@@ -18,10 +17,6 @@ docs/mibapi/tmp
docs/bmlapi/tmp docs/bmlapi/tmp
docs/fahipayapi/tmp docs/fahipayapi/tmp
docs/mfaisaapi/tmp docs/mfaisaapi/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 = 36 versionCode = 34
versionName = "1.0.36" versionName = "1.0.33"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -10,7 +10,6 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.PAGE_ORIGIN
import java.security.KeyFactory import java.security.KeyFactory
import java.security.spec.MGF1ParameterSpec import java.security.spec.MGF1ParameterSpec
import java.security.spec.X509EncodedKeySpec import java.security.spec.X509EncodedKeySpec
@@ -34,12 +33,7 @@ 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. A decline after 3-D Secure (e.g. insufficient * 5. Poll next-action until TRANSACTION_CONFIRMED.
* 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
* signed `state=CONFIRMED` — the browser's last hop, and how merchants (Dhiraagu, Ooredoo)
* 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.
@@ -55,8 +49,7 @@ class BmlMerchantCardPayClient {
) )
sealed class Result { sealed class Result {
/** Paid. [merchantNotified] is false when the return to the merchant (step 6) failed. */ object Success : Result()
data class Success(val merchantNotified: Boolean) : Result()
data class Failure(val message: String) : Result() data class Failure(val message: String) : Result()
} }
@@ -92,10 +85,7 @@ 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
val txnClient = BmlMerchantTxnClient() runCatching { BmlMerchantTxnClient().announceBrowser(txnId) }
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)
@@ -140,7 +130,7 @@ class BmlMerchantCardPayClient {
var threeDsUrl: String? = null var threeDsUrl: String? = null
for (attempt in 0..MAX_POLLS) { for (attempt in 0..MAX_POLLS) {
when (action.optString("action")) { when (action.optString("action")) {
"TRANSACTION_CONFIRMED" -> return confirmed(txnId) "TRANSACTION_CONFIRMED" -> return Result.Success
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment") "TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
} }
threeDsUrl = action.optString("3dsUrl").ifBlank { null } threeDsUrl = action.optString("3dsUrl").ifBlank { null }
@@ -156,56 +146,14 @@ class BmlMerchantCardPayClient {
// 5. Wait for the gateway's verdict to reach BML // 5. Wait for the gateway's verdict to reach BML
repeat(MAX_POLLS * 2) { repeat(MAX_POLLS * 2) {
when (poll(pk, txnId).optString("action")) { when (poll(pk, txnId).optString("action")) {
"TRANSACTION_CONFIRMED" -> return confirmed(txnId) "TRANSACTION_CONFIRMED" -> return Result.Success
"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")
} }
/** The payment went through: return to the merchant, then report success either way. */
private fun confirmed(txnId: String) = Result.Success(merchantNotified = returnToMerchant(txnId))
/**
* 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=…`)
* and on to its receipt page. Some merchants' callback page instead auto-submits a form on
* 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 {
repeat(RETURN_ATTEMPTS) { attempt ->
if (attempt > 0) Thread.sleep(RETURN_RETRY_MS)
val ok = runCatching {
var request = browserNav(Request.Builder().url("$PAGE_ORIGIN/$txnId?wait=1"))
for (hop in 0..MAX_AUTO_SUBMITS) {
val (code, html, url) = client.newCall(request).execute().use {
Triple(it.code, it.body?.string().orEmpty(), it.request.url.toString())
}
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)
if (ok) return true
}
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
@@ -228,8 +176,6 @@ 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
@@ -237,10 +183,9 @@ 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 (!otpRejected(html)) break if (!html.contains("incorrect", true) && !html.contains("expired", true)) 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) {
@@ -251,9 +196,6 @@ 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 =
@@ -355,11 +297,5 @@ class BmlMerchantCardPayClient {
private val JSON = "application/json".toMediaType() private val JSON = "application/json".toMediaType()
private const val POLL_MS = 5_000L private const val POLL_MS = 5_000L
private const val MAX_POLLS = 10 private const val MAX_POLLS = 10
private const val RETURN_ATTEMPTS = 3
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,36 +113,12 @@ 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. */
* The PATCHes the page sends on load: register this "browser" and clear any FX selection. fun announceBrowser(transactionId: String) {
* Returns the browser id, for [paymentErrors]. patch(transactionId, JSONObject().put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}"))
*/
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")
@@ -1,210 +0,0 @@
package sh.sar.basedbank.api.dhiraagu
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
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
/**
* Dhiraagu prepaid reload ("Easy TopUp") and bill payment ("Easy Pay") through dhiraagu.com.mv,
* paid by card on BML's merchant gateway. Dhiraagu only builds the order; the money moves on the
* BML Merchant Services transaction handed back, which is paid like any card-only BML merchant
* link. See `docs/dhiraaguapi/02-reload.md` and `docs/dhiraaguapi/03-bill-pay.md`.
*
* Every call blocks, so run it on an IO thread.
*/
class DhiraaguPaymentClient {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Creates the reload order for [number] and the BML transaction paying for it: cart →
* merchant (the BML gateway entry) → payment → BML transaction. [amount] is the whole MVR
* amount paid, GST included. Returns the 24-hex BML transaction id. Throws with Dhiraagu's
* wording when a step is refused.
*/
fun createReloadTransaction(number: String, amount: Int): String {
val topupNonce = pageNonce("$BASE/services/easy-topup")
val gst = gstOf(amount)
val cart = api("cart", "recharge", topupNonce, JSONObject()
.put("formId", FORM_RELOAD)
.put("serviceNumber", number)
.put("amount", amount)
.put("amountGST", gst.toDouble())
.put("amountRecharge", (BigDecimal(amount) - gst).toDouble())
.put("gstRate", GST_RATE)
.put("memberId", "").put("memberName", "").put("memberNId", "")
.put("customerId", "").put("customerCode", "")
.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") }
// The payment page carries its own nonce, used for the rest of the order
val paymentNonce = pageNonce("$BASE/services/payment-v2?cartid=$cartId")
val merchants = apiList("merchant", "form", paymentNonce, JSONObject().put("formId", formId))
val bml = (0 until merchants.length()).map { merchants.getJSONObject(it) }
.firstOrNull { it.optInt("gatewayId") == GATEWAY_BML }
?: throw Exception("Dhiraagu isn't taking BML card payments right now")
val payment = api("payment", "create", paymentNonce, JSONObject()
.put("formId", formId)
.put("cartId", cartId)
.put("gatewayId", GATEWAY_BML)
.put("dhiraaguPayNumber", "")
.put("amount", money(amount))
.put("paymentMerchantId", bml.getString("merchantId"))
.put("memberId", "").put("tokenize", "").put("paymentType", "")
.put("recurringFrequency", "").put("bmlTokenId", ""))
val paymentId = payment.optString("paymentId").ifBlank { throw Exception("Dhiraagu didn't create the payment") }
val txn = api("bml", "createV2", paymentNonce, JSONObject().put("paymentId", paymentId))
return TXN_URL.find(txn.optString("url"))?.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))
/** 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 ─────────────────────────────────────────────────────────────────
/** Every page embeds a `var nonce = "…"` that its API calls send as the `nonce` header. */
private fun pageNonce(url: String): String =
NONCE.find(page(url))?.groupValues?.get(1) ?: throw Exception("Dhiraagu page didn't load")
private fun page(url: String): String = client.newCall(
Request.Builder().url(url)
.header("User-Agent", UA)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.build()
).execute().use { r ->
if (r.code in 500..599) throw BankServerException("Dhiraagu")
r.body?.string().orEmpty()
}
private fun api(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
call(sub, act, nonce, body).getJSONObject("resp")
private fun apiList(sub: String, act: String, nonce: String, body: JSONObject): JSONArray =
call(sub, act, nonce, body).getJSONArray("resp")
/** POSTs to `sdk-dhr-webapi.ashx`; throws unless `respStatus` is OK. */
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(
Request.Builder().url("$API?website_id=$WEBSITE_ID&sub=$sub&act=$act")
.apply { if (body != null) post(body) }
.header("User-Agent", UA)
.header("Accept", "application/json, text/javascript, */*; q=0.01")
.header("X-Requested-With", "XMLHttpRequest")
.header("Origin", BASE)
.header("nonce", nonce)
.build()
).execute().use { r ->
if (r.code in 500..599) throw BankServerException("Dhiraagu")
r.body?.string().orEmpty()
}
val obj = try { JSONObject(text) } catch (_: Exception) {
throw Exception("Unexpected response from Dhiraagu")
}
if (obj.optString("respStatus") != "OK") {
throw Exception(obj.optString("respMsg").ifBlank { obj.optString("resp") }.ifBlank { "Dhiraagu refused the payment" })
}
return obj
}
companion object {
private const val BASE = "https://www.dhiraagu.com.mv"
private const val API = "$BASE/api/sdk-dhr-webapi.ashx"
private const val WEBSITE_ID = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"
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 NONCE = Regex("""var nonce = "([^"]+)"""")
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. */
private const val FORM_RELOAD = 2
/** Bank of Maldives in `merchant&act=form` (1 = BML, 2 = MIB, 3 = DhiraaguPay). */
private const val GATEWAY_BML = 1
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 reload [amount], as the page works it out:
* `amount × rate / (1 + rate)`, to 2 places. The number is credited `amount − gst`.
*/
fun gstOf(amount: Int): BigDecimal {
val rate = BigDecimal(GST_RATE.toString())
return (BigDecimal(amount) * rate).divide(BigDecimal.ONE + rate, 2, RoundingMode.HALF_UP)
}
}
}
@@ -1,47 +0,0 @@
package sh.sar.basedbank.api.fahipay
import android.os.Build
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okio.Buffer
/** Form-body helpers shared by the Fahipay POST endpoints (login, OTP, payments). */
internal object FahipayForm {
/** The `device[...]` fields every Fahipay POST carries. */
fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
"device[available]" to "true",
"device[platform]" to "Android",
"device[uuid]" to deviceUuid,
"device[model]" to Build.MODEL,
"device[manufacturer]" to Build.MANUFACTURER,
"device[isVirtual]" to "false",
"device[serial]" to "unknown"
)
/**
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
* which is what the Fahipay server requires.
*/
fun body(vararg parts: Pair<String, String>): RequestBody {
val boundary = java.util.UUID.randomUUID().toString()
val buf = Buffer()
for ((name, value) in parts) {
val valueBytes = value.toByteArray(Charsets.UTF_8)
buf.writeUtf8("--$boundary\r\n")
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
buf.writeUtf8("\r\n")
buf.write(valueBytes)
buf.writeUtf8("\r\n")
}
buf.writeUtf8("--$boundary--\r\n")
val snapshot = buf.readByteString()
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
return object : RequestBody() {
override fun contentType() = mediaType
override fun contentLength() = snapshot.size.toLong()
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
}
}
}
@@ -4,8 +4,11 @@ import android.os.Build
import okhttp3.Cookie import okhttp3.Cookie
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody
import okio.Buffer
import org.json.JSONObject import org.json.JSONObject
import java.security.SecureRandom import java.security.SecureRandom
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -71,14 +74,14 @@ class FahipayLoginFlow {
*/ */
fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep { fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep {
initSession() initSession()
val body = FahipayForm.body( val body = buildFormBody(
"email" to idCard, "email" to idCard,
"password" to password, "password" to password,
"grant_type" to "auth_id", "grant_type" to "auth_id",
"lang" to "en", "lang" to "en",
"version" to "2.0.0", "version" to "2.0.0",
"platform" to "thijooree", "platform" to "BasedBank",
*FahipayForm.deviceParts(deviceUuid) *deviceParts(deviceUuid)
) )
val resp = client.newCall( val resp = client.newCall(
@@ -106,15 +109,15 @@ class FahipayLoginFlow {
* Returns authId. * Returns authId.
*/ */
fun verifyTotp(code: String, deviceUuid: String): String { fun verifyTotp(code: String, deviceUuid: String): String {
val body = FahipayForm.body( val body = buildFormBody(
"code" to code, "code" to code,
"channel" to "totp", "channel" to "totp",
"action" to "login", "action" to "login",
"grant_type" to "auth_id", "grant_type" to "auth_id",
"lang" to "en", "lang" to "en",
"version" to "2.0.0", "version" to "2.0.0",
"platform" to "thijooree", "platform" to "BasedBank",
*FahipayForm.deviceParts(deviceUuid) *deviceParts(deviceUuid)
) )
val resp = client.newCall( val resp = client.newCall(
@@ -135,6 +138,42 @@ class FahipayLoginFlow {
?: throw Exception("No authID in OTP response") ?: throw Exception("No authID in OTP response")
} }
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
"device[available]" to "true",
"device[platform]" to "Android",
"device[uuid]" to deviceUuid,
"device[model]" to Build.MODEL,
"device[manufacturer]" to Build.MANUFACTURER,
"device[isVirtual]" to "false",
"device[serial]" to "unknown"
)
/**
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
* which is what the Fahipay server requires.
*/
private fun buildFormBody(vararg parts: Pair<String, String>): RequestBody {
val boundary = java.util.UUID.randomUUID().toString()
val buf = Buffer()
for ((name, value) in parts) {
val valueBytes = value.toByteArray(Charsets.UTF_8)
buf.writeUtf8("--$boundary\r\n")
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
buf.writeUtf8("\r\n")
buf.write(valueBytes)
buf.writeUtf8("\r\n")
}
buf.writeUtf8("--$boundary--\r\n")
val snapshot = buf.readByteString()
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
return object : RequestBody() {
override fun contentType() = mediaType
override fun contentLength() = snapshot.size.toLong()
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
}
}
companion object { companion object {
fun generateDeviceUuid(): String { fun generateDeviceUuid(): String {
val bytes = ByteArray(8) val bytes = ByteArray(8)
@@ -1,71 +0,0 @@
package sh.sar.basedbank.api.fahipay
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit
/**
* Pays a phone number from the Fahipay wallet: Ooredoo Raastas, Ooredoo bill pay, Dhiraagu
* reload and Dhiraagu bill pay. All four are the same POST, only the path differs — see
* `docs/fahipayapi/09-payments.md`.
*/
class FahipayPaymentClient {
private val BASE_URL = "https://fahipay.mv"
private val UA = "okhttp/4.12.0"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
/**
* A payment the server accepted. [message] is its wording, e.g. "Transaction successful." or,
* for Dhiraagu bill pay, "Transaction will be processed shortly.". [transactionId] (`tid`) is
* only returned by the reload / Raastas endpoints.
*/
data class Result(val message: String, val transactionId: String?)
/**
* POSTs the payment to [path] (e.g. `actions/payment/ooredoo/recharge/`). [amount] is sent as
* typed — the caller checks the service's limits first. Returns on success; throws with the
* server's message when it refuses, [BankServerException] on a 5xx, and IOException when the
* request doesn't get through. Blocking — call from IO.
*/
fun pay(session: FahipaySession, path: String, number: String, amount: String, deviceUuid: String): Result {
val body = FahipayForm.body(
"number" to number,
"amount" to amount,
"lang" to "en",
"version" to "2.0.2",
"build" to "329",
"platform" to "thijooree",
*FahipayForm.deviceParts(deviceUuid)
)
val resp = client.newCall(
Request.Builder().url("$BASE_URL/$path")
.post(body)
.header("authid", session.authId)
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
.header("User-Agent", UA)
.header("accept", "application/json")
.build()
).execute()
val code = resp.code
val json = resp.body?.string().orEmpty()
resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
val obj = try { JSONObject(json) } catch (_: Exception) {
throw Exception("Unexpected response from Fahipay (HTTP $code)")
}
val message = obj.optString("msg").ifBlank { obj.optString("title") }
if (obj.optString("type") != "success") throw Exception(message.ifBlank { "Payment failed" })
return Result(
message = message,
transactionId = obj.optString("tid").takeIf { it.isNotBlank() }
)
}
}
@@ -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-v2.mib.com.mv" private const val MIB_WV_URL = "https://faisamobilex-wv.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=AND-1.0; " + val cookieHeader = "mbmodel=IOS-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-v2.mib.com.mv" private val BASE_WV_URL = "https://faisamobilex-wv.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=AND-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " + "mbmodel=IOS-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,83 +1,135 @@
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(private val flow: MibLoginFlow) { class MibContactsClient {
fun fetchCategories(session: MibSession): List<MibBeneficiaryCategory> = private val TAG = "MibContactsClient"
fetchContactsAndCategories(session).second private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
fun fetchContacts(session: MibSession): List<MibBeneficiary> = private val client = OkHttpClient.Builder()
fetchContactsAndCategories(session).first .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/** private fun cookieHeader(session: MibSession) =
* Pages through B41. Every page also carries the full category list. "mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
* start/end select rows by their 1-based `rnum`, both ends inclusive. "mbnonce=${session.nonceGenerator}; time-tracker=597"
*/
fun fetchContactsAndCategories(session: MibSession): Pair<List<MibBeneficiary>, List<MibBeneficiaryCategory>> {
val all = mutableListOf<MibBeneficiary>()
val seen = mutableSetOf<String>()
var categories = emptyList<MibBeneficiaryCategory>()
val pageSize = 10 // the official app always requests 10-row windows
var start = 1
while (true) {
val resp = flow.request(session, "B41") {
put("start", start)
put("end", start + pageSize - 1)
put("benefType", "A")
put("search", "")
put("includeCount", 1)
put("searchCategoryId", 0)
put("sortBenef", "")
}
if (!resp.optBoolean("success")) break
if (start == 1) categories = parseCategories(resp)
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 Pair(all, categories)
}
private fun parseCategories(resp: JSONObject): List<MibBeneficiaryCategory> { private fun Request.Builder.withSessionHeaders(session: MibSession): Request.Builder = this
val arr = resp.optJSONArray("categories") ?: return emptyList() .header("Cookie", cookieHeader(session))
return (0 until arr.length()).map { i -> .header(
val o = arr.getJSONObject(i) "User-Agent",
MibBeneficiaryCategory( "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"
id = o.getString("id"),
categoryName = o.getString("categoryName"),
numBenef = o.optString("numBenef", "0").toIntOrNull() ?: 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")
) )
.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>()
var page = 1
val pageSize = 100
while (true) {
val start = (page - 1) * pageSize + 1
val end = page * pageSize
val body = FormBody.Builder()
.add("page", page.toString())
.add("search", "")
.add("searchCategoryId", "0")
.add("benefType", "A")
.add("sortBenef", "name")
.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 (all.size >= totalCount || contacts.isEmpty()) break
page++
}
return all
}
private fun parseContacts(json: String): Pair<List<MibBeneficiary>, Int> {
return try {
val obj = JSONObject(json)
val totalCount = obj.optString("total_count", "0").toIntOrNull() ?: 0
val arr = obj.getJSONArray("data")
val contacts = (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
val hash = o.optString("customerImgHash")
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)
}
} }
/** 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,
@@ -86,62 +138,82 @@ class MibContactsClient(private val flow: MibLoginFlow) {
categoryId: String = "0", categoryId: String = "0",
imageBase64: String = "" imageBase64: String = ""
): Boolean { ): Boolean {
val (bankName, bankSwift) = bankInfo(bankNo) val body = FormBody.Builder()
val resp = flow.request(session, "B43") { .add("benefType", benefType)
put("benefName", benefName) .add("imageSet", if (imageBase64.isNotBlank()) "1" else "0")
put("benefAccount", benefAccount) .add("benefAccount", benefAccount)
put("bankNo", bankNo.toString()) .add("benefIban", "")
put("nickName", nickName) .add("benefName", benefName)
put("transferCy", transferCy) .add("nickName", nickName)
put("categoryId", categoryId) .add("transferCy", transferCy)
if (imageBase64.isNotBlank()) put("image", imageBase64) .add("transferCySwift", "840")
put("bankName", bankName) .add("benefAddress", "")
put("bankSwift", bankSwift) .add("benefCity", "")
.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")
} }
/** Updates a beneficiary via B52. A blank [imageBase64] keeps the current photo. */ fun deleteContact(session: MibSession, benefNo: String): Boolean {
fun updateContact( val body = FormBody.Builder().add("benefNo", benefNo).build()
session: MibSession, val request = Request.Builder()
benefNo: String, .url("$BASE_WV_URL/ajaxBeneficiary/deleteBeneficiary")
bankNo: Int, // 2 = MIB, 3 = BML .post(body)
benefAccount: String, .withSessionHeaders(session)
benefName: String, .build()
nickName: String, return client.newCall(request).execute().use { response ->
transferCy: String = "462", val bodyStr = response.body?.string() ?: return@use false
categoryId: String = "0", try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
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 resp = flow.request(session, "B46") { put("imageHash", imageHash) } val body = FormBody.Builder()
if (!resp.optBoolean("success")) return null .add("imageHash", imageHash)
return resp.optString("profileImage").takeIf { it.isNotBlank() } .build()
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-v2.mib.com.mv" private val BASE_WV_URL = "https://faisamobilex-wv.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=AND-1.0; " + val cookieHeader = "mbmodel=IOS-1.0; " +
"xxid=${session.xxid}; " + "xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " + "IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " + "mbnonce=${session.nonceGenerator}; " +
@@ -1,9 +1,28 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
class MibHistoryClient(private val flow: MibLoginFlow) { import android.os.Build
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 via the encrypted `A84` route. * Fetches transaction history for a single MIB account.
* @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)
*/ */
@@ -14,36 +33,58 @@ class MibHistoryClient(private val flow: MibLoginFlow) {
start: Int = 1, start: Int = 1,
pageSize: Int = 20 pageSize: Int = 20
): Pair<List<Transaction>, Int> { ): Pair<List<Transaction>, Int> {
// A84 uses a 0-based inclusive range: first page is start=0, end=19 val body = FormBody.Builder()
val json = flow.request(session, "A84") { .add("accountNo", accountNo)
put("accountNo", accountNo) .add("trxNo", "")
put("start", start - 1) .add("trxType", "0")
put("end", start - 1 + pageSize - 1) .add("sortTrx", "date")
put("includeCount", true) .add("sortDir", "desc")
put("trxType", 0) .add("fromDate", "")
put("sortTrx", "date") .add("toDate", "")
put("sortDir", "desc") .add("start", start.toString())
} .add("end", (start + pageSize - 1).toString())
if (!json.optBoolean("success")) return Pair(emptyList(), 0) .add("includeCount", "1")
val total = json.optString("total_count", "0").toIntOrNull() ?: 0 .build()
val data = json.optJSONArray("data") ?: return Pair(emptyList(), total)
val transactions = (0 until data.length()).map { i -> val request = Request.Builder()
val item = data.getJSONObject(i) .url("$BASE_WV_URL/ajaxAccounts/trxHistory")
Transaction( .post(body)
id = item.optString("trxNumber"), .header("Cookie", cookieHeader(session))
date = item.optString("trxDate"), .header(
description = item.optString("descr1").trim(), "User-Agent",
amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0, "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"
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"
) )
.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)
} }
return Pair(transactions, total)
} }
} }
@@ -7,10 +7,6 @@ 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
@@ -20,7 +16,7 @@ class SessionExpiredException : Exception("MIB session expired")
class MibLoginFlow(private val credentialStore: CredentialStore) { class MibLoginFlow(private val credentialStore: CredentialStore) {
private val BASE_URL = "https://faisamobilex-smvc-v2.mib.com.mv/index/" private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
/** 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
@@ -30,12 +26,6 @@ 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
@@ -52,9 +42,8 @@ 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/2.0") .header("User-Agent", "android/1.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)
@@ -155,8 +144,6 @@ 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
@@ -313,13 +300,6 @@ 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.
@@ -444,16 +424,6 @@ 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,8 +35,7 @@ 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,87 +1,50 @@
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 idCard: String val enrolled: String
) )
class MibProfileClient(private val flow: MibLoginFlow) { class MibProfileClient {
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 personal = flow.lastProfiles.firstOrNull { it.profileType == "0" } val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
if (personal != null) flow.switchProfile(session, personal) "IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
val request = Request.Builder() val request = Request.Builder()
.url("$BASE_WV_URL/ajaxAlias/getAliasTypes") .url("https://faisamobilex-wv.mib.com.mv/personalProfile")
.post(FormBody.Builder().build()) .get()
.header("Cookie", "mbmodel=AND-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}") .header("Cookie", cookieHeader)
.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/Alias/Alias?dashurl=1")
.build() .build()
return try {
val json = client.newCall(request).execute().use { response -> val resp = client.newCall(request).execute()
val body = response.body?.string() ?: return null val html = resp.body?.string() ?: return null
try { JSONObject(body) } catch (_: Exception) { return null } resp.close()
} fun scrape(label: String): String {
if (!json.optBoolean("success")) return null val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
// Alias type ids: 0 = mobile number, 1 = email, 3 = National ID return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
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 "" 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
return MibPersonalProfile( MibPersonalProfile(
fullName = flow.lastFullName.ifBlank { personal?.name ?: "" }, fullName = fullName,
email = alias(1).ifBlank { flow.lastEmail }, username = scrape("Username:"),
mobile = alias(0), email = scrape("Email:"),
idCard = alias(3) mobile = scrape("Mobile no:"),
) enrolled = scrape("Enrolled:")
} )
} catch (_: Exception) { null }
/** 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,12 +1,42 @@
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(private val flow: MibLoginFlow) { class MibTransferClient {
private val TAG = "MibTransferClient"
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
private fun Request.Builder.withWvHeaders(session: MibSession): Request.Builder = this
.header("Cookie", cookieHeader(session))
.header(
"User-Agent",
"Mozilla/5.0 (Linux; Android ${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 (T50), bankNo=3 → local/BML via Favara (T51). * Execute a transfer. bankNo=2 → MIB internal (/transferInternal), bankNo=3 → local/BML (/transferLocal).
* currencyCode: "462"=MVR, "840"=USD. * currencyCode: "462"=MVR, "840"=USD. purpose uses "-" if blank.
*/ */
fun transfer( fun transfer(
session: MibSession, session: MibSession,
@@ -19,79 +49,157 @@ class MibTransferClient(private val flow: MibLoginFlow) {
purpose: String, purpose: String,
otp: String otp: String
): MibTransferResult { ): MibTransferResult {
val json = flow.request(session, if (bankNo == 2) "T50" else "T51") { val endpoint = if (bankNo == 2) "transferInternal" else "transferLocal"
put("benefName", benefName) val body = FormBody.Builder()
put("benefNo", "0") .add("benefName", benefName)
put("fromAccountNo", fromAccount) .add("benefNo", "0")
put("benefAccountNo", toAccount) .add("fromAccountNo", fromAccount)
put("transferCy", currencyCode) .add("benefAccountNo", toAccount)
put("benefCurrencyCode", currencyCode) .add("transferCy", currencyCode)
put("amount", amount) .add("benefCurrencyCode", currencyCode)
put("bankNo", bankNo.toString()) .add("amount", amount)
put("purpose", purpose) .add("bankNo", bankNo.toString())
put("otp", otp) .add("purpose", purpose.ifBlank { "-" })
put("otpType", "3") .add("otp", otp)
.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") ?: ""
)
} }
/** /**
* Resolves a destination via B40, which takes any supported input: * Routes the lookup to the correct endpoint based on input format:
* *
* - 13 digits starting with 7 → BML / local bank account * - 15 digits starting with 7 → IPS account lookup (BML / local bank)
* - 17 digits starting with 9 → MIB account * - 17 digits starting with 9 → MIB internal account name lookup
* - 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 * or email address → Favara alias lookup
*
* 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 {
// B40 only resolves Favara IDs with an uppercase "A" ("a239225" fails, "A239225" works) val trimmed = input.trim()
val trimmed = input.trim().let { if (it.matches(Regex("^a\\d{6}$"))) it.uppercase() else it } return when {
val supported = trimmed.matches(Regex("^7\\d{12}$")) || trimmed.matches(Regex("^7\\d{12}$")) -> lookupIpsAccount(session, trimmed)
trimmed.matches(Regex("^9\\d{16}$")) || trimmed.matches(Regex("^9\\d{16}$")) -> lookupAccountName(session, trimmed)
trimmed.matches(Regex("^[79]\\d{6}$")) || trimmed.matches(Regex("^[79]\\d{6}$")) -> lookupAlias(session, trimmed)
trimmed.matches(Regex("^[Aa]\\d{6}$")) || trimmed.matches(Regex("^[Aa]\\d{6}$")) -> lookupAlias(session, trimmed)
trimmed.contains("@") trimmed.contains("@") -> lookupAlias(session, trimmed)
if (!supported) throw MibLookupException("Unrecognized account number format") else -> throw MibLookupException("Unrecognized account number format")
}
}
val json = flow.request(session, "B40") { put("search", trimmed) } /** BML / local bank: 15-digit account starting with 7. */
val match = json.optJSONArray("data")?.optJSONObject(0) private fun lookupIpsAccount(session: MibSession, accountNumber: String): MibIpsAccountInfo {
if (!json.optBoolean("success") || match == null) { val body = FormBody.Builder().add("benefAccount", accountNumber).build()
throw MibLookupException( val request = Request.Builder()
json.optString("reasonText").trim().ifBlank { "Account not found" } .url("$BASE_WV_URL/AjaxAlias/getIPSAccount")
.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
)
} }
companion object { /** MIB internal: 17-digit account starting with 9. */
/** reasonCode for a wrong OTP, e.g. one from the previous window. */ private fun lookupAccountName(session: MibSession, accountNumber: String): MibIpsAccountInfo {
const val REASON_INVALID_OTP = "213" val body = FormBody.Builder().add("accountNo", accountNumber).build()
/** reasonCode for an OTP the server has already accepted once. */ val request = Request.Builder()
const val REASON_OTP_ALREADY_USED = "707" .url("$BASE_WV_URL/ajaxBeneficiary/getAccountName")
.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")
)
}
} }
} }
@@ -1,90 +0,0 @@
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 = app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = MibContactsClient().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,13 +18,11 @@ 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.ContactDisplay import sh.sar.basedbank.util.ContactsCache
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
@@ -34,7 +32,6 @@ 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
@@ -70,9 +67,6 @@ 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)
@@ -96,49 +90,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (selectedDest?.isBml == false) setupCategoryDropdown() if (selectedDest?.isBml == false) setupCategoryDropdown()
} }
if (!applyEditArgs()) applyPrefillArgs() 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() {
@@ -249,18 +201,6 @@ 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() {
@@ -367,9 +307,8 @@ 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(mibFlow).lookup(mibSess, input) val info = MibTransferClient().lookup(mibSess, input)
BmlAccountValidation( BmlAccountValidation(
trnType = "DOT", trnType = "DOT",
validationType = "MIB", validationType = "MIB",
@@ -389,9 +328,8 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val profile = dest.mibProfile ?: return null val profile = dest.mibProfile ?: return null
val mibResult = try { val mibResult = try {
val flow = app.mibFlowFor(loginId) app.mibFlowFor(loginId).switchProfile(mibSess, profile)
flow.switchProfile(mibSess, profile) val info = MibTransferClient().lookup(mibSess, input)
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",
@@ -460,7 +398,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.etCurrency.setText(validation.currency) binding.etCurrency.setText(validation.currency)
binding.btnClearResult.setOnClickListener { clearLookupResult() } binding.btnClearResult.setOnClickListener { clearLookupResult() }
updateSaveState() binding.btnSave.isEnabled = true
} }
private fun clearLookupResult() { private fun clearLookupResult() {
@@ -500,7 +438,6 @@ 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() {
@@ -510,10 +447,6 @@ 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
@@ -521,16 +454,11 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) {
when { if (dest.isBml) saveToBml(alias) else saveToMib(alias)
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()
// Same reload as pull-to-refresh on the contacts page reloadSavedProfileContacts(dest)
(activity as? HomeActivity)?.loadAllContacts(showIndicator = true)
dismiss() dismiss()
} else { } else {
binding.btnSave.isEnabled = true binding.btnSave.isEnabled = true
@@ -567,13 +495,15 @@ 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(app.mibFlowFor(loginId)).createContact( MibContactsClient().createContact(
session = mibSess, session = mibSess,
benefType = benefType,
bankNo = bankNo, bankNo = bankNo,
benefAccount = account, benefAccount = account,
benefName = name, benefName = name,
@@ -585,33 +515,6 @@ 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 }
@@ -629,6 +532,35 @@ 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
@@ -638,23 +570,10 @@ 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,15 +77,7 @@ 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 {
// Only contacts saved in MIB can be edited for now Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
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 {
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) MibContactsClient().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,8 +143,6 @@ 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
} }
@@ -259,7 +257,7 @@ class ContactsFragment : Fragment() {
return return
} }
val sess = session ?: return val sess = session ?: return
val client = MibContactsClient(app.anyMibFlow() ?: return) val client = MibContactsClient()
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,10 +30,8 @@ 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
@@ -63,7 +61,6 @@ 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
@@ -431,10 +428,10 @@ class HomeActivity : AppCompatActivity() {
for (session in sessions) { for (session in sessions) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val cookieHeader = "mbmodel=AND-1.0; xxid=${session.xxid}; " + val cookieHeader = "mbmodel=IOS-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-v2.mib.com.mv/aProfile/keepAlive") .url("https://faisamobilex-wv.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")
@@ -822,8 +819,6 @@ 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")
@@ -1215,10 +1210,10 @@ fun applyNavLabelVisibility() {
} }
} }
private fun refreshBmlContacts(app: BasedBankApp): Job? { private fun refreshBmlContacts(app: BasedBankApp) {
if (app.bmlSessions.isEmpty()) return null if (app.bmlSessions.isEmpty()) return
val store = CredentialStore(this) val store = CredentialStore(this)
return lifecycleScope.launch { lifecycleScope.launch {
try { try {
val allBmlContacts = withContext(Dispatchers.IO) { val allBmlContacts = withContext(Dispatchers.IO) {
store.getBmlLoginIds().flatMap { loginId -> store.getBmlLoginIds().flatMap { loginId ->
@@ -1238,8 +1233,7 @@ fun applyNavLabelVisibility() {
} }
} }
/** Loads contacts from cache, then refreshes them from every bank. [showIndicator] shows the top refresh bar until done. */ fun loadAllContacts() {
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
@@ -1255,24 +1249,16 @@ 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))?.let { jobs.add(it) } refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))
}
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): Job { private fun refreshFahipayContacts(session: FahipaySession) {
return lifecycleScope.launch { lifecycleScope.launch {
try { try {
val groups = withContext(Dispatchers.IO) { val groups = withContext(Dispatchers.IO) {
FahipayContactsClient().fetchContacts(session) FahipayContactsClient().fetchContacts(session)
@@ -1302,11 +1288,11 @@ fun applyNavLabelVisibility() {
return result return result
} }
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>): Job? { private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return null if (profiles.isEmpty()) return
val flow = (application as BasedBankApp).mibFlowFor(loginId) val flow = (application as BasedBankApp).mibFlowFor(loginId)
val contactsClient = MibContactsClient(flow) val contactsClient = MibContactsClient()
return lifecycleScope.launch { lifecycleScope.launch {
try { try {
val (allContacts, allCategories) = withContext(Dispatchers.IO) { val (allContacts, allCategories) = withContext(Dispatchers.IO) {
val seenContacts = mutableSetOf<String>() val seenContacts = mutableSetOf<String>()
@@ -1316,12 +1302,10 @@ fun applyNavLabelVisibility() {
for (profile in profiles) { for (profile in profiles) {
try { try {
flow.switchProfile(session, profile) flow.switchProfile(session, profile)
val (profileContacts, profileCategories) = for (cat in contactsClient.fetchCategories(session)) {
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 profileContacts) { for (contact in contactsClient.fetchContacts(session)) {
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,16 +353,15 @@ 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(flow).fetchPersonalProfile(session) } catch (_: Exception) { null } try { MibProfileClient().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 = loginId, username = profile.username,
email = profile.email, email = profile.email,
mobile = profile.mobile, mobile = profile.mobile,
enrolled = "", enrolled = profile.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,13 +668,10 @@ 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)}: ${if (hide) masked else it}" }, profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" }, profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $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
@@ -51,10 +51,6 @@ import sh.sar.basedbank.databinding.ItemPickerRowBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding 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.CardPayoutService
import sh.sar.basedbank.ui.home.transfer.CardPayoutTransferHandler
import sh.sar.basedbank.ui.home.transfer.CarrierLookup
import sh.sar.basedbank.ui.home.transfer.FahipayService import sh.sar.basedbank.ui.home.transfer.FahipayService
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
@@ -162,12 +158,6 @@ class TransferFragment : Fragment() {
private fun fahipayHandler(): FahipayTransferHandler = private fun fahipayHandler(): FahipayTransferHandler =
fahipayHandler ?: FahipayTransferHandler(this, binding, viewModel).also { fahipayHandler = it } fahipayHandler ?: FahipayTransferHandler(this, binding, viewModel).also { fahipayHandler = it }
/** Lazy: created the first time a phone lookup offers carrier services by BML card. */
private var cardPayoutHandler: CardPayoutTransferHandler? = null
private fun cardPayoutHandler(): CardPayoutTransferHandler =
cardPayoutHandler ?: CardPayoutTransferHandler(this, binding, viewModel) { bmlHandler() }
.also { cardPayoutHandler = it }
/** Lazy: created the first time the user selects an MFAISA source account. */ /** Lazy: created the first time the user selects an MFAISA source account. */
private var mfaisaHandler: MfaisaTransferHandler? = null private var mfaisaHandler: MfaisaTransferHandler? = null
private fun mfaisaHandler(): MfaisaTransferHandler = private fun mfaisaHandler(): MfaisaTransferHandler =
@@ -408,7 +398,7 @@ class TransferFragment : Fragment() {
return@setFragmentResultListener return@setFragmentResultListener
} }
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: "" val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
if (applyServiceContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) { if (applyFahipayContact(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
@@ -454,7 +444,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 (applyServiceContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let if (applyFahipayContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let
prefillToDirectly( prefillToDirectly(
accountNumber = account, accountNumber = account,
displayName = name, displayName = name,
@@ -635,7 +625,6 @@ class TransferFragment : Fragment() {
draft.transferTypeNumber = "" draft.transferTypeNumber = ""
draft.transferType = null draft.transferType = null
fahipayHandler?.clearState() fahipayHandler?.clearState()
cardPayoutHandler?.clearState()
transferTypeDialog?.dismiss() transferTypeDialog?.dismiss()
updateTransferButton() updateTransferButton()
} }
@@ -659,12 +648,6 @@ class TransferFragment : Fragment() {
// The default account when it can do Favara, otherwise leave the user to choose // The default account when it can do Favara, otherwise leave the user to choose
is TransferType.Favara -> accounts.firstOrNull { it.accountNumber == defaultNum && type.worksFrom(it) } is TransferType.Favara -> accounts.firstOrNull { it.accountNumber == defaultNum && type.worksFrom(it) }
is TransferType.Fahipay -> accounts.firstOrNull(type::worksFrom) is TransferType.Fahipay -> accounts.firstOrNull(type::worksFrom)
// The default card when it can pay, otherwise any card that can
is TransferType.Card -> {
val defaultCard = CredentialStore(requireContext()).getDefaultCardAccountNumber()
accounts.firstOrNull { it.accountNumber == defaultCard && type.worksFrom(it) }
?: accounts.firstOrNull(type::worksFrom)
}
} }
if (pick != null) selectSourceAccount(pick) if (pick != null) selectSourceAccount(pick)
else { else {
@@ -675,17 +658,9 @@ class TransferFragment : Fragment() {
when (type) { when (type) {
is TransferType.Favara -> { is TransferType.Favara -> {
fahipayHandler?.clearState() fahipayHandler?.clearState()
cardPayoutHandler?.clearState()
showFavaraRecipient(type.info) showFavaraRecipient(type.info)
} }
is TransferType.Fahipay -> { is TransferType.Fahipay -> fahipayHandler().applyService(type, number)
cardPayoutHandler?.clearState()
fahipayHandler().applyService(type, number)
}
is TransferType.Card -> {
fahipayHandler?.clearState()
cardPayoutHandler().applyService(type, number)
}
} }
updateTransferButton() updateTransferButton()
} }
@@ -1031,7 +1006,6 @@ class TransferFragment : Fragment() {
resolvedDestCurrency = "" resolvedDestCurrency = ""
resolvedToOwnAccount = null resolvedToOwnAccount = null
fahipayHandler?.clearState() fahipayHandler?.clearState()
cardPayoutHandler?.clearState()
if (!keepTransferTypes) resetTransferTypes() if (!keepTransferTypes) resetTransferTypes()
mfaisaHandler?.clearState() mfaisaHandler?.clearState()
binding.cardToInfo.visibility = View.GONE binding.cardToInfo.visibility = View.GONE
@@ -1126,7 +1100,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 (applyServiceContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) { if (applyFahipayContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
return@setOnItemClickListener return@setOnItemClickListener
} }
prefillToDirectly( prefillToDirectly(
@@ -1142,27 +1116,15 @@ class TransferFragment : Fragment() {
} }
/** /**
* A saved Fahipay favourite, or a recent paid with a Fahipay or card service: its category * A saved Fahipay favourite: its list ([categoryId]) already says which service pays it, so
* ([categoryId]) already says which service pays it, so that service is applied as the only * that service is applied as the only transfer type, with no carrier lookup. That switches
* transfer type, with no carrier lookup. That switches the source to the Fahipay wallet or * the source to the Fahipay wallet and brings in the service's amount rules, the same as a
* the default card and brings in the service's amount rules, the same as a searched number. * searched number. Returns false, doing nothing, when [categoryId] isn't a Fahipay list.
* Returns false, doing nothing, when [categoryId] isn't one of those.
*/ */
private fun applyServiceContact(number: String, categoryId: String?, name: String): Boolean { private fun applyFahipayContact(number: String, categoryId: String?, name: String): Boolean {
val ownerName = name.takeIf { it.isNotBlank() && it != number } val service = FahipayService.fromContactCategory(categoryId) ?: return false
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(type)) offerTransferTypes(number, listOf(TransferType.Fahipay(service, name.takeIf { it.isNotBlank() && it != number })))
return true return true
} }
@@ -1183,12 +1145,6 @@ class TransferFragment : Fragment() {
lookupPhoneForAnySource(accountNumber) lookupPhoneForAnySource(accountNumber)
return return
} }
// A card that can pay by card: the number may be a carrier service it can pay too
if (selectedAccount?.let { BmlVerifiedCards.isPayable(requireContext(), it) } == true &&
AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
lookupPhoneForAnySource(accountNumber)
return
}
if (selectedAccount == null) { if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
@@ -1250,17 +1206,15 @@ class TransferFragment : Fragment() {
} }
/** /**
* A phone number searched before any source is picked (or from a card that can pay by * A phone number searched before any source is picked. Runs the Favara lookup (through MIB
* card). Runs the Favara lookup (through MIB or BML, whichever is logged in) and, when the * or BML, whichever is logged in) and, when the user has a Fahipay wallet, the Dhiraagu /
* user has a Fahipay wallet or a card that can pay by card, the Dhiraagu / Ooredoo carrier * Ooredoo carrier lookup — in parallel — and offers whatever came back as transfer types.
* lookup — in parallel — and offers whatever came back as transfer types.
*/ */
private fun lookupPhoneForAnySource(number: String) { private fun lookupPhoneForAnySource(number: String) {
val accounts = viewModel.accounts.value ?: emptyList() val accounts = viewModel.accounts.value ?: emptyList()
val hasFahipay = accounts.any { it.bank == "FAHIPAY" } val hasFahipay = accounts.any { it.bank == "FAHIPAY" }
val payableCards = cardPayoutHandler().payableCards()
val hasFavara = mibHandler.session != null || bmlSessionFor(null) != null val hasFavara = mibHandler.session != null || bmlSessionFor(null) != null
if (!hasFavara && !hasFahipay && payableCards.isEmpty()) { if (!hasFavara && !hasFahipay) {
Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show()
return return
} }
@@ -1268,22 +1222,19 @@ class TransferFragment : Fragment() {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
val preferBml = accounts.firstOrNull { it.accountNumber == defaultNum }?.bank == "BML" val preferBml = accounts.firstOrNull { it.accountNumber == defaultNum }?.bank == "BML"
val fahipay = fahipayHandler() val fahipay = fahipayHandler()
val cardPayout = cardPayoutHandler()
resetTransferTypes() resetTransferTypes()
startLookupLoading() startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val (favara, carriers) = withContext(Dispatchers.IO) { val (favara, fahipayTypes) = withContext(Dispatchers.IO) {
coroutineScope { coroutineScope {
val favara = async { if (hasFavara) lookupFavara(number, preferBml) else null to null } val favara = async { if (hasFavara) lookupFavara(number, preferBml) else null to null }
val carriers = async { if (hasFahipay || payableCards.isNotEmpty()) CarrierLookup.query(number) else null } val fahipayTypes = async { if (hasFahipay) fahipay.lookupServices(number) else emptyList() }
favara.await() to carriers.await() favara.await() to fahipayTypes.await()
} }
} }
stopLookupLoading() stopLookupLoading()
val fahipayTypes = carriers?.takeIf { hasFahipay }?.let(fahipay::typesFor).orEmpty() val types = listOfNotNull(favara.first?.let { TransferType.Favara(it) }) + fahipayTypes
val cardTypes = carriers?.let { cardPayout.typesFor(it, payableCards) }.orEmpty()
val types = listOfNotNull(favara.first?.let { TransferType.Favara(it) }) + fahipayTypes + cardTypes
if (types.isEmpty()) { if (types.isEmpty()) {
Toast.makeText(requireContext(), favara.second ?: getString(R.string.transfer_account_not_found), Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), favara.second ?: getString(R.string.transfer_account_not_found), Toast.LENGTH_SHORT).show()
return@launch return@launch
@@ -1530,19 +1481,6 @@ class TransferFragment : Fragment() {
return return
} }
// Fahipay source: reload / Raastas / bill pay to the picked service
if (selectedAccount?.bank == "FAHIPAY") {
fahipayHandler().submit()
return
}
// Carrier service by BML card: the carrier creates the BML transaction, then it's paid
// like a card-only merchant link
if (draft.cardPayoutService != null) {
cardPayoutHandler().submit()
return
}
// BML QR merchant payment — uses shared confirm dialog, no receipt // BML QR merchant payment — uses shared confirm dialog, no receipt
if (bmlHandler().hasQrMerchant) { if (bmlHandler().hasQrMerchant) {
bmlHandler().submitQrPayment() bmlHandler().submitQrPayment()
@@ -1835,23 +1773,6 @@ class TransferFragment : Fragment() {
} }
} }
/**
* A dialog showing only the processing spinner, for work that has to finish before the
* confirm dialog can be shown (e.g. a carrier creating the payment). Dismiss it when done.
*/
internal fun showProcessingDialog(title: String): AlertDialog {
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.hideSoftInputFromWindow(requireView().windowToken, 0)
val frame = android.widget.FrameLayout(requireContext())
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle(title)
.setView(frame)
.setCancelable(false)
.show()
showProcessingInDialog(dialog, frame)
return dialog
}
internal fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) { internal fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
@@ -2002,12 +1923,10 @@ class TransferFragment : Fragment() {
// draft — build it so the amount rules still apply after the view is recreated. // draft — build it so the amount rules still apply after the view is recreated.
val fahipay = if (draft.fahipayService != null) fahipayHandler() else null val fahipay = if (draft.fahipayService != null) fahipayHandler() else null
fahipay?.syncAmountField() fahipay?.syncAmountField()
val cardPayout = if (draft.cardPayoutService != null) cardPayoutHandler() else null
cardPayout?.syncAmountField()
if (bmlHandler().isOtpFlowActive) return if (bmlHandler().isOtpFlowActive) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0 val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank() val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
val amountOk = amount > 0 && fahipay?.amountProblem == null && cardPayout?.amountProblem == null val amountOk = amount > 0 && fahipay?.amountProblem == null
val hasAll = selectedAccount != null && recipientReady && amountOk && !transferTypePending val hasAll = selectedAccount != null && recipientReady && amountOk && !transferTypePending
if (!hasAll) { binding.btnTransfer.isEnabled = false; return } if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
val errors = viewModel.connectivityErrors.value ?: emptySet() val errors = viewModel.connectivityErrors.value ?: emptySet()
@@ -2138,7 +2057,6 @@ class TransferFragment : Fragment() {
bmlHandler?.clearState() bmlHandler?.clearState()
bmlHandler = null bmlHandler = null
fahipayHandler = null fahipayHandler = null
cardPayoutHandler = null
mfaisaHandler = null mfaisaHandler = null
// Re-shown from the draft by the next view if still unanswered // Re-shown from the draft by the next view if still unanswered
transferTypeDialog?.setOnDismissListener(null) transferTypeDialog?.setOnDismissListener(null)
@@ -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(app.mibFlowFor(loginId)).fetchHistory( val (list, total) = MibHistoryClient().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 = app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = MibContactsClient().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 {
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) MibContactsClient().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
@@ -425,8 +425,13 @@ class BmlTransferHandler(
private val cardMerchant get() = draft.bmlCardMerchant private val cardMerchant get() = draft.bmlCardMerchant
/** A verified BML card we also hold a login (OTP seed) for — can go through the 3-D Secure step. */ /** A verified BML card we also hold a login (OTP seed) for — can go through the 3-D Secure step. */
private fun verifiedCardCandidates(): List<BankAccount> = private fun verifiedCardCandidates(): List<BankAccount> {
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList()) val store = CredentialStore(ctx)
val verifiedKeys = sh.sar.basedbank.util.VerifiedCardStore.keys(ctx)
return (viewModel.accounts.value ?: emptyList())
.filter { isCard(it) && verifiedKeys.contains("bml:${it.accountNumber}") }
.filter { store.loadBmlCredentials(it.loginTag.removePrefix("bml_"))?.otpSeed != null }
}
/** /**
* Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as * Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as
@@ -455,7 +460,9 @@ class BmlTransferHandler(
} }
} }
private fun isCardVerified(account: BankAccount): Boolean = BmlVerifiedCards.isPayable(ctx, account) private fun isCardVerified(account: BankAccount): Boolean =
isCard(account) && sh.sar.basedbank.util.VerifiedCardStore.isVerified(ctx, "bml:${account.accountNumber}") &&
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed != null
/** Paints the loaded card-only merchant into the "To" card and locks the amount. */ /** Paints the loaded card-only merchant into the "To" card and locks the amount. */
fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) { fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
@@ -496,12 +503,7 @@ class BmlTransferHandler(
confirmCardMerchant(page, src) confirmCardMerchant(page, src)
} }
/** private fun confirmCardMerchant(
* The card payment's confirm dialog (biometric-gated), then the Pomelo + 3-D Secure payment of
* [page] with [src]. Also the send step for carrier services paid by card
* ([CardPayoutTransferHandler]), once the carrier has created the BML transaction.
*/
fun confirmCardMerchant(
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage, page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
src: BankAccount src: BankAccount
) { ) {
@@ -541,45 +543,42 @@ class BmlTransferHandler(
dialog: AlertDialog, dialog: AlertDialog,
frame: android.widget.FrameLayout frame: android.widget.FrameLayout
) { ) {
val (card, otpSeed) = BmlVerifiedCards.load(ctx, src) ?: run { val stored = sh.sar.basedbank.util.VerifiedCardStore.load(ctx, "bml:${src.accountNumber}")
val loginId = src.loginTag.removePrefix("bml_")
val otpSeed = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
val expiry = stored?.expiry?.split("/") // "MM/YY"
if (stored == null || otpSeed == null || expiry?.size != 2) {
dialog.dismiss() dialog.dismiss()
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show() Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return return
} }
val card = sh.sar.basedbank.api.bml.BmlMerchantCardPayClient.Card(
pan = stored.pan,
expiryMonth = expiry[0].padStart(2, '0'),
expiryYear = expiry[1].takeLast(2),
cvv = stored.cvv,
holderName = src.accountBriefName
)
fragment.viewLifecycleOwner.lifecycleScope.launch { fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
runCatching { runCatching {
BmlMerchantCardPayClient().pay(page, card) { retry -> BmlMerchantCardPayClient().pay(page, card) { _ -> Totp.generate(otpSeed) }
// 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")
} }
} }
if (fragment.view == null) return@launch if (fragment.view == null) return@launch
when (result) { when (result) {
is BmlMerchantCardPayClient.Result.Success -> { is BmlMerchantCardPayClient.Result.Success -> fragment.showSuccessInDialog(
fragment.showSuccessInDialog( dialog, frame,
dialog, frame, amountCurrency = page.currency,
amountCurrency = page.currency, amountValue = "%.2f".format(page.amount),
amountValue = "%.2f".format(page.amount), fromName = src.accountBriefName,
fromName = src.accountBriefName, toName = page.merchantName
toName = page.merchantName ) {
) { fragment.clearForm()
fragment.clearForm() host?.triggerRefresh()
host?.triggerRefresh()
}
// Charged, but the merchant may not deliver until it's told
if (!result.merchantNotified) {
Toast.makeText(ctx, ctx.getString(R.string.bml_card_pay_merchant_not_notified,
page.merchantName, page.transactionId), Toast.LENGTH_LONG).show()
}
} }
is BmlMerchantCardPayClient.Result.Failure -> { is BmlMerchantCardPayClient.Result.Failure -> {
dialog.dismiss() dialog.dismiss()
@@ -963,12 +962,6 @@ class BmlTransferHandler(
} }
} }
private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account) private fun isCard(account: BankAccount) =
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
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
}
} }
@@ -1,52 +0,0 @@
package sh.sar.basedbank.ui.home.transfer
import android.content.Context
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.VerifiedCardStore
/**
* BML cards that can pay a merchant by card + 3-D Secure: the card is verified (full details in
* [VerifiedCardStore]) and belongs to a BML login we hold the OTP seed for, since the 3-D Secure
* step is answered with that login's token code.
*/
object BmlVerifiedCards {
/** A payable card's details, ready for [BmlMerchantCardPayClient.pay]. */
data class Payable(val card: BmlMerchantCardPayClient.Card, val otpSeed: String)
fun isCard(account: BankAccount) =
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
fun isPayable(ctx: Context, account: BankAccount): Boolean =
isCard(account) && VerifiedCardStore.isVerified(ctx, key(account)) && otpSeed(ctx, account) != null
fun payable(ctx: Context, accounts: List<BankAccount>): List<BankAccount> {
val verified = VerifiedCardStore.keys(ctx)
return accounts.filter { isCard(it) && key(it) in verified && otpSeed(ctx, it) != null }
}
/** The stored card details and OTP seed for [account], or null when it isn't payable. */
fun load(ctx: Context, account: BankAccount): Payable? {
val stored = VerifiedCardStore.load(ctx, key(account)) ?: return null
val seed = otpSeed(ctx, account) ?: return null
val expiry = stored.expiry.split("/") // "MM/YY"
if (expiry.size != 2) return null
return Payable(
card = BmlMerchantCardPayClient.Card(
pan = stored.pan,
expiryMonth = expiry[0].padStart(2, '0'),
expiryYear = expiry[1].takeLast(2),
cvv = stored.cvv,
holderName = account.accountBriefName
),
otpSeed = seed
)
}
private fun key(account: BankAccount) = "bml:${account.accountNumber}"
private fun otpSeed(ctx: Context, account: BankAccount) =
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed
}
@@ -1,217 +0,0 @@
package sh.sar.basedbank.ui.home.transfer
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
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.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import java.math.BigDecimal
import java.math.RoundingMode
/**
* 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.
*
* Add new services as constants; the exhaustive `when`s over this enum point at every site that
* needs updating.
*/
enum class CardPayoutService(
override val label: String,
override val destinationLabel: String,
@param:DrawableRes override val iconRes: Int,
override val minAmount: Int,
override val maxAmount: Int?,
override val decimalsAllowed: Boolean,
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 {
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8,
contactCategory = "CARD_DHIRAAGU_RELOAD") {
// Dhiraagu rounds the GST to 2 places and credits the rest
override fun creditedAfterGst(amount: BigDecimal): BigDecimal =
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 }
}
}
/**
* Owns the Transfer screen's "carrier service by BML card" parts: which services a looked-up
* number can be paid with by card, and the picked service's amount rules.
*
* Sending only differs from paying a card-only BML merchant link in where the BML transaction
* comes from: the carrier's website creates it for the number and amount. From there it goes
* through [BmlTransferHandler.confirmCardMerchant] — the same confirm dialog, card + 3-D Secure
* payment and result as a pasted link.
*
* Mirrors [FahipayTransferHandler]: the fragment keeps the recipient card and the form state.
* Lifetime is bound to the fragment's view.
*/
class CardPayoutTransferHandler(
private val fragment: TransferFragment,
private val binding: FragmentTransferBinding,
private val viewModel: HomeViewModel,
private val bmlHandler: () -> BmlTransferHandler,
) {
private val ctx get() = fragment.requireContext()
private val amountField = PayoutAmountField(binding) { ctx }
/** The service picked for the current recipient; null when none is. */
var service: CardPayoutService?
get() = viewModel.transferDraft.cardPayoutService
private set(value) { viewModel.transferDraft.cardPayoutService = value }
// ─── Public API the fragment calls ───────────────────────────────────────
/** Account numbers of the cards that can pay by card. Call on the main thread. */
fun payableCards(): Set<String> =
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
.map { it.accountNumber }.toSet()
/** The card transfer types a carrier lookup [result] allows, payable from [cards]. */
fun typesFor(result: CarrierLookup.Result, cards: Set<String>): List<TransferType.Card> {
if (cards.isEmpty()) return emptyList()
return buildList {
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) }
}
/** Forgets the picked service and gives back the amount and reference fields. */
fun clearState() {
if (service == null) return
service = null
amountField.reset()
}
/** Why the typed amount can't be sent with the picked service, or null when it can. */
val amountProblem: String?
get() = service?.let(amountField::problem)
/** Keeps the amount and reference fields in step with the picked service. */
fun syncAmountField() {
service?.let(amountField::sync)
}
/**
* Fills the recipient card for a picked card transfer type. The fragment has already
* switched the source to one of the type's cards.
*/
fun applyService(type: TransferType.Card, number: String) {
service = type.service
// None of the payouts take a reference; syncAmountField() disables the box
binding.etRemarks.setText("")
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = type.ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
fragment.prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "${type.label} · $number",
colorHex = "#E4002B",
imageHash = null,
contactCategory = type.service.contactCategory
)
fragment.focusAmount()
}
// ─── Send ────────────────────────────────────────────────────────────────
/**
* Has the carrier create the BML transaction for the number and amount, then hands its
* payment page to the card merchant flow. Nothing is charged until that flow's confirm.
*/
fun submit() {
val svc = service ?: return
val src = viewModel.transferDraft.selectedAccount
if (src == null || !BmlVerifiedCards.isPayable(ctx, src)) {
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
val number = viewModel.transferDraft.transferTypeNumber
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
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
val processing = fragment.showProcessingDialog(ctx.getString(R.string.transfer))
fragment.viewLifecycleOwner.lifecycleScope.launch {
val page = withContext(Dispatchers.IO) {
runCatching {
val txnId = when (svc) {
CardPayoutService.DHIRAAGU_RELOAD ->
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)
}
}
processing.dismiss()
if (fragment.view == null) return@launch
page.onSuccess {
when {
!it.supportsCard ->
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 (plus GST, if added)
it.amount.toBigDecimal().compareTo(charged) != 0 ->
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
else -> bmlHandler().confirmCardMerchant(it, src)
}
}.onFailure { e ->
val msg = when {
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
!e.message.isNullOrBlank() -> e.message!!
else -> ctx.getString(R.string.transfer_bml_txn_lookup_failed)
}
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
}
}
}
}
@@ -1,46 +0,0 @@
package sh.sar.basedbank.ui.home.transfer
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.OoredooClient
/**
* Which carrier a phone number is on, and how it's billed. Feeds both payout routes: the
* Fahipay services and the BML card services each map this to what they can pay.
*/
object CarrierLookup {
data class Result(
val dhiraagu: DhiraaguClient.Result,
val ooredoo: OoredooClient.CustType,
) {
/** Dhiraagu is the only carrier that hands back an owner name. */
val ownerName: String? get() = dhiraagu.ownerName.takeIf { it.isNotBlank() }
}
/**
* Asks the likelier carrier first based on the leading digit and only falls back to the
* other when the first says it doesn't know the number. Blocking — call from IO.
*/
fun query(number: String): Result =
if (number.startsWith("7")) {
// Dhiraagu first, fall back to Ooredoo
val d = dhiraagu(number)
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
else OoredooClient.CustType.UNSUPPORTED
Result(d, o)
} else {
// Ooredoo first, fall back to Dhiraagu
val o = ooredoo(number)
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
Result(d, o)
}
private fun dhiraagu(number: String) =
try { DhiraaguClient().validateNumber(number) }
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
private fun ooredoo(number: String) =
try { OoredooClient().validateNumber(number) }
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
}
@@ -1,63 +1,58 @@
package sh.sar.basedbank.ui.home.transfer package sh.sar.basedbank.ui.home.transfer
import android.widget.Toast import android.text.InputType
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.FahipayPaymentClient
import sh.sar.basedbank.api.fahipay.OoredooClient import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeActivity
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
import sh.sar.basedbank.util.AccountInputParser import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.CredentialStore import java.math.BigDecimal
import java.math.RoundingMode
/** /**
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to * A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
* a given number. The limits are Fahipay's — the same carrier service paid by card * a given number; [label] names it in the "Transfer Type" picker and the recipient card,
* ([CardPayoutService]) has its own. * [destinationLabel] in the confirm dialog's "To" block.
* *
* Wallet-to-wallet Fahipay transfer is not here yet. Add it as a constant once its send path * Wallet-to-wallet Fahipay transfer is not here yet — there is no send path for it (see the
* lands, and the exhaustive `when`s over this enum will point at every site that needs updating. * class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the
* exhaustive `when`s over this enum will point at every site that needs updating.
*/ */
enum class FahipayService( enum class FahipayService(
override val label: String, val label: String,
override val destinationLabel: String, val destinationLabel: String,
@param:DrawableRes override val iconRes: Int, @param:DrawableRes val iconRes: Int,
/** The endpoint [FahipayPaymentClient.pay] POSTs this service's payments to. */ /** Smallest amount the service accepts, in MVR. */
val paymentPath: String, val minAmount: Int,
override val minAmount: Int, /** Largest amount the service accepts, in MVR, or null for no limit. */
override val maxAmount: Int?, val maxAmount: Int?,
override val decimalsAllowed: Boolean, /** Whether the amount may have a fractional part (up to 2 decimal places). */
val decimalsAllowed: Boolean,
/** /**
* The contact category of this service's Fahipay favourites list (`BankContact.benefCategoryId`, * The contact category of this service's Fahipay favourites list (`BankContact.benefCategoryId`,
* set by `FahipayContactsClient`). Recents of this service are tagged with it too. * set by `FahipayContactsClient`). Recents of this service are tagged with it too.
*/ */
val contactCategory: String, val contactCategory: String,
override val gstPercent: Int? = null, /** GST the carrier takes out of the amount before crediting it, in percent, or null for none. */
) : PayoutService { val gstPercent: Int? = null,
) {
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo, RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
paymentPath = "actions/payment/ooredoo/recharge/",
minAmount = 11, maxAmount = null, decimalsAllowed = false, minAmount = 11, maxAmount = null, decimalsAllowed = false,
contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8), contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo, OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
paymentPath = "actions/payment/ooredoo/billpay/",
minAmount = 10, maxAmount = 50000, decimalsAllowed = true, minAmount = 10, maxAmount = 50000, decimalsAllowed = true,
contactCategory = "FAHIPAY_OOREDOO_BILL"), contactCategory = "FAHIPAY_OOREDOO_BILL"),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo, DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
paymentPath = "actions/payment/dhiraagu/recharge/",
minAmount = 8, maxAmount = 1000, decimalsAllowed = false, minAmount = 8, maxAmount = 1000, decimalsAllowed = false,
contactCategory = "FAHIPAY_RELOAD"), contactCategory = "FAHIPAY_RELOAD"),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo, DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
paymentPath = "actions/payment/dhiraagu/billpay/",
minAmount = 10, maxAmount = 5000, decimalsAllowed = false, minAmount = 10, maxAmount = 5000, decimalsAllowed = false,
contactCategory = "FAHIPAY_DHIRAAGU_BILL"); contactCategory = "FAHIPAY_DHIRAAGU_BILL");
@@ -79,8 +74,10 @@ enum class FahipayService(
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm * Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific. * dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific.
* *
* [submit] sends the payment through [FahipayPaymentClient], with the shared confirm dialog * **There is no send path yet.** A Fahipay source currently falls through to the MIB branch of
* and an in-dialog success screen (no receipt page yet). * `initiateTransfer`, which signs the request with a MIB session. When the real payout API is
* wired up it belongs here, as a `doTransfer(...)` alongside the lookup — same shape as the
* other handlers.
* *
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] * Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment]
* (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated. * (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated.
@@ -98,8 +95,6 @@ class FahipayTransferHandler(
get() = viewModel.transferDraft.fahipayService get() = viewModel.transferDraft.fahipayService
private set(value) { viewModel.transferDraft.fahipayService = value } private set(value) { viewModel.transferDraft.fahipayService = value }
private val amountField = PayoutAmountField(binding) { ctx }
/** How the confirm dialog names the destination, or "" when nothing is selected. */ /** How the confirm dialog names the destination, or "" when nothing is selected. */
val destinationLabel: String get() = service?.destinationLabel.orEmpty() val destinationLabel: String get() = service?.destinationLabel.orEmpty()
@@ -124,22 +119,64 @@ class FahipayTransferHandler(
fun clearState() { fun clearState() {
if (service == null) return if (service == null) return
service = null service = null
amountField.reset() binding.tilAmount.error = null
binding.tilAmount.helperText = null
binding.etAmount.inputType = DECIMAL_INPUT
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
} }
/** /**
* Why the typed amount can't be sent with the selected service, or null when it can (or the * Why the typed amount can't be sent with the selected service, or null when it can (or the
* field is empty, or no service is selected). * field is empty, or no service is selected). Checks the service's minimum, maximum and
* whether it takes decimals.
*/ */
val amountProblem: String? val amountProblem: String?
get() = service?.let(amountField::problem) get() {
val svc = service ?: return null
val text = binding.etAmount.text?.toString()?.trim().orEmpty()
if (text.isEmpty()) return null
val amount = text.toBigDecimalOrNull() ?: return ctx.getString(R.string.transfer_fahipay_amount_invalid)
val fraction = amount.stripTrailingZeros().scale()
return when {
!svc.decimalsAllowed && fraction > 0 -> ctx.getString(R.string.transfer_fahipay_amount_whole)
fraction > 2 -> ctx.getString(R.string.transfer_fahipay_amount_decimals)
amount < BigDecimal(svc.minAmount) -> ctx.getString(R.string.transfer_fahipay_amount_min, svc.minAmount)
svc.maxAmount != null && amount > BigDecimal(svc.maxAmount) ->
ctx.getString(R.string.transfer_fahipay_amount_max, "%,d".format(svc.maxAmount))
else -> null
}
}
/** /**
* Keeps the amount and reference fields in step with the selected service. A no-op when no * Keeps the amount and reference fields in step with the selected service: the amount
* service is selected; [clearState] puts the fields back. * error, the GST note under the amount, and the reference box disabled (none of the
* payouts take one). Idempotent — the fragment calls it on every form change. A no-op
* when no service is selected; [clearState] puts the fields back.
*/ */
fun syncAmountField() { fun syncAmountField() {
service?.let(amountField::sync) val svc = service ?: return
// Whole-number services get a keypad without a decimal point. Only set on change:
// setting inputType restarts the keyboard, and this runs on every keystroke.
val inputType = if (svc.decimalsAllowed) DECIMAL_INPUT else InputType.TYPE_CLASS_NUMBER
if (binding.etAmount.inputType != inputType) binding.etAmount.inputType = inputType
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
val problem = amountProblem
binding.tilAmount.error = problem
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
}
/**
* What the recipient is credited once GST comes out, for services that charge it. The
* amount paid is GST-inclusive, so the credit is amount / (1 + rate), rounded down.
*/
private fun gstNote(svc: FahipayService): String? {
val gst = svc.gstPercent ?: return null
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
val credited = amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
} }
/** /**
@@ -165,118 +202,70 @@ class FahipayTransferHandler(
fragment.focusAmount() fragment.focusAmount()
} }
/** The Fahipay transfer types a carrier lookup [result] allows. */
fun typesFor(result: CarrierLookup.Result): List<TransferType.Fahipay> =
servicesFor(result).map { TransferType.Fahipay(it, result.ownerName) }
// ─── Send ────────────────────────────────────────────────────────────────
/** /**
* Pays the picked service: confirm dialog (with the GST note for services that charge it), * The Fahipay transfer types [number] can be paid with, from the carrier lookup. Dhiraagu is
* biometric gate, then the payment POST. Success shows in the dialog; a refusal closes it * the only carrier that hands back an owner name. Blocking — call from IO.
* and toasts the server's message.
*/ */
fun submit() { fun lookupServices(number: String): List<TransferType.Fahipay> {
val svc = service ?: return val result = queryCarriers(number)
val src = viewModel.transferDraft.selectedAccount?.takeIf { it.bank == "FAHIPAY" } ?: run { val ownerName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() return servicesFor(result).map { TransferType.Fahipay(it, ownerName) }
return
}
val number = viewModel.transferDraft.transferTypeNumber
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
// Whole-number services get "11", never "11.00"
val amountParam = amount.stripTrailingZeros().toPlainString()
val amountDisplay = "%,.2f".format(amount)
val toName = binding.tvToAccountName.text?.toString().orEmpty().ifBlank { number }
val confirmView = fragment.buildTransferConfirmView(
amountCurrency = "MVR",
amountValue = amountDisplay,
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = "Fahipay",
toName = toName,
toNumber = number,
toDetail = svc.destinationLabel,
warningTexts = listOfNotNull(amountField.gstNote(svc))
)
fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "MVR $amountDisplay → ${svc.label} $number",
onConfirmed = { dialog, frame ->
fragment.showProcessingInDialog(dialog, frame)
pay(src, svc, number, amountParam, amountDisplay, toName, dialog, frame)
}
)
}
private fun pay(
src: BankAccount,
svc: FahipayService,
number: String,
amountParam: String,
amountDisplay: String,
toName: String,
dialog: AlertDialog,
frame: android.widget.FrameLayout,
) {
val app = fragment.requireActivity().application as BasedBankApp
val session = app.fahipaySessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val deviceUuid = CredentialStore(ctx).getOrCreateFahipayDeviceUuid()
binding.btnTransfer.isEnabled = false
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { FahipayPaymentClient().pay(session, svc.paymentPath, number, amountParam, deviceUuid) }
}
if (fragment.view == null) return@launch
result.onSuccess {
fragment.showSuccessInDialog(
dialog, frame,
amountCurrency = "MVR",
amountValue = amountDisplay,
fromName = src.accountBriefName,
toName = "$toName · ${svc.label}"
) {
fragment.clearForm()
(fragment.activity as? HomeActivity)?.triggerRefresh()
}
}.onFailure { e ->
dialog.dismiss()
binding.btnTransfer.isEnabled = true
val msg = when {
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
!e.message.isNullOrBlank() -> e.message!!
else -> "Payment failed"
}
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
}
}
} }
// ─── Carrier lookup ────────────────────────────────────────────────────── // ─── Carrier lookup ──────────────────────────────────────────────────────
private data class CarrierResult(
val dhiraagu: DhiraaguClient.Result,
val ooredoo: OoredooClient.CustType
)
private fun lookupCarrier(number: String) { private fun lookupCarrier(number: String) {
fragment.resetTransferTypes() fragment.resetTransferTypes()
fragment.startLookupLoading() fragment.startLookupLoading()
fragment.viewLifecycleOwner.lifecycleScope.launch { fragment.viewLifecycleOwner.lifecycleScope.launch {
val types = withContext(Dispatchers.IO) { typesFor(CarrierLookup.query(number)) } val types = withContext(Dispatchers.IO) { lookupServices(number) }
fragment.stopLookupLoading() fragment.stopLookupLoading()
if (types.isEmpty()) return@launch if (types.isEmpty()) return@launch
fragment.offerTransferTypes(number, types) fragment.offerTransferTypes(number, types)
} }
} }
private fun servicesFor(result: CarrierLookup.Result): List<FahipayService> = buildList { /**
* Asks the likelier carrier first based on the leading digit and only falls back to the
* other when the first says it doesn't know the number. Blocking — call from IO.
*/
private fun queryCarriers(number: String): CarrierResult =
if (number.startsWith("7")) {
// Dhiraagu first, fall back to Ooredoo
val d = dhiraagu(number)
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
else OoredooClient.CustType.UNSUPPORTED
CarrierResult(d, o)
} else {
// Ooredoo first, fall back to Dhiraagu
val o = ooredoo(number)
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
CarrierResult(d, o)
}
private fun dhiraagu(number: String) =
try { DhiraaguClient().validateNumber(number) }
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
private fun ooredoo(number: String) =
try { OoredooClient().validateNumber(number) }
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
private fun servicesFor(result: CarrierResult): List<FahipayService> = buildList {
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD) if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL) if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS) if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL) if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
} }
private companion object {
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
}
} }
@@ -8,7 +8,6 @@ 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
@@ -41,10 +40,6 @@ 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()
@@ -59,9 +54,8 @@ 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(flow).lookup(sess, accountNumber), null) LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
} catch (e: MibLookupException) { } catch (e: MibLookupException) {
LookupOutcome(null, e.message) LookupOutcome(null, e.message)
} catch (_: Exception) { } catch (_: Exception) {
@@ -75,8 +69,7 @@ class MibTransferHandler(
*/ */
fun lookupCurrency(accountNumber: String): String { fun lookupCurrency(accountNumber: String): String {
val sess = session ?: return "" val sess = session ?: return ""
val flow = flow ?: return "" return try { MibTransferClient().lookup(sess, accountNumber).currency }
return try { MibTransferClient(flow).lookup(sess, accountNumber).currency }
catch (_: Exception) { "" } catch (_: Exception) { "" }
} }
@@ -95,7 +88,7 @@ class MibTransferHandler(
val base64 = if (isProfile) { val base64 = if (isProfile) {
app.anyMibFlow()?.fetchProfileImage(session, hash) app.anyMibFlow()?.fetchProfileImage(session, hash)
} else { } else {
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(session, hash) MibContactsClient().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)
@@ -107,9 +100,8 @@ 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. If MIB rejects the OTP as invalid or already used, waits for the next OTP window and * TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is
* retries once; a second rejection is returned as a failure. Blocking — call from IO. * "CONNECTIVITY" when the network was unreachable.
* Returns `(ok, message, receipt)`; the message is "CONNECTIVITY" when the network was unreachable.
*/ */
fun doTransfer( fun doTransfer(
src: BankAccount, src: BankAccount,
@@ -122,7 +114,8 @@ 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 otpSeed = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed val otp = 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"
@@ -152,26 +145,17 @@ 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 client = MibTransferClient(app.mibFlowFor(loginId)) val result = MibTransferClient().transfer(
val send = { otp: String -> session = sess,
client.transfer( fromAccount = src.accountNumber,
session = sess, toAccount = destAccount,
fromAccount = src.accountNumber, amount = amount,
toAccount = destAccount, currencyCode = currencyCode,
amount = amount, benefName = destName.ifBlank { "Recipient" },
currencyCode = currencyCode, bankNo = bankNo,
benefName = destName.ifBlank { "Recipient" }, purpose = remarks,
bankNo = bankNo, otp = otp
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",
@@ -197,13 +181,4 @@ 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
}
} }
@@ -1,130 +0,0 @@
package sh.sar.basedbank.ui.home.transfer
import android.content.Context
import android.text.InputType
import androidx.annotation.DrawableRes
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentTransferBinding
import java.math.BigDecimal
import java.math.RoundingMode
/**
* A carrier service a phone number can be paid with (reload, Raastas, bill pay), whichever
* route pays it — the Fahipay wallet ([FahipayService]) or a verified BML card
* ([CardPayoutService]). Each route has its own limits, so each has its own constants.
*/
interface PayoutService {
/** Names it in the "Transfer Type" picker and the recipient card. */
val label: String
/** Names it in the confirm dialog's "To" block, e.g. "Ooredoo · Raastas". */
val destinationLabel: String
@get:DrawableRes val iconRes: Int
/** Smallest amount the service accepts, in MVR. */
val minAmount: Int
/** Largest amount the service accepts, in MVR, or null for no limit. */
val maxAmount: Int?
/** Whether the amount may have a fractional part (up to 2 decimal places). */
val decimalsAllowed: Boolean
/** GST the carrier charges, in percent, or null for none. [gstAdded] says how. */
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
* down. Null when the service charges no GST, or adds it on top.
*/
fun creditedAfterGst(amount: BigDecimal): BigDecimal? {
val gst = gstPercent ?: return null
if (gstAdded) return null
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)
}
}
/**
* Keeps the Transfer screen's amount and reference fields in step with a picked
* [PayoutService]: the amount error, the GST note under the amount, the keypad, and the
* reference box disabled (none of the payouts take one). Shared by the Fahipay and card routes.
*/
class PayoutAmountField(
private val binding: FragmentTransferBinding,
private val context: () -> Context,
) {
/**
* Why the typed amount can't be sent with [svc], or null when it can (or the field is
* empty). Checks the service's minimum, maximum and whether it takes decimals.
*/
fun problem(svc: PayoutService): String? {
val ctx = context()
val text = binding.etAmount.text?.toString()?.trim().orEmpty()
if (text.isEmpty()) return null
val amount = text.toBigDecimalOrNull() ?: return ctx.getString(R.string.transfer_fahipay_amount_invalid)
val fraction = amount.stripTrailingZeros().scale()
return when {
!svc.decimalsAllowed && fraction > 0 -> ctx.getString(R.string.transfer_fahipay_amount_whole)
fraction > 2 -> ctx.getString(R.string.transfer_fahipay_amount_decimals)
amount < BigDecimal(svc.minAmount) -> ctx.getString(R.string.transfer_fahipay_amount_min, svc.minAmount)
svc.maxAmount != null && amount > BigDecimal(svc.maxAmount!!) ->
ctx.getString(R.string.transfer_fahipay_amount_max, "%,d".format(svc.maxAmount))
else -> null
}
}
/** Idempotent — the fragment calls it on every form change. */
fun sync(svc: PayoutService) {
// Whole-number services get a keypad without a decimal point. Only set on change:
// setting inputType restarts the keyboard, and this runs on every keystroke.
val inputType = if (svc.decimalsAllowed) DECIMAL_INPUT else InputType.TYPE_CLASS_NUMBER
if (binding.etAmount.inputType != inputType) binding.etAmount.inputType = inputType
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
val problem = problem(svc)
binding.tilAmount.error = problem
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
}
/**
* 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? {
val gst = svc.gstPercent ?: return null
val ctx = context()
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)
val credited = svc.creditedAfterGst(amount) ?: return null
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
}
/** Gives back the amount and reference fields once no service is picked. */
fun reset() {
binding.tilAmount.error = null
binding.tilAmount.helperText = null
binding.etAmount.inputType = DECIMAL_INPUT
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
}
private companion object {
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
}
}
@@ -57,7 +57,4 @@ class TransferDraft {
// Fahipay // Fahipay
var fahipayService: FahipayService? = null var fahipayService: FahipayService? = null
// Carrier service paid by verified BML card
var cardPayoutService: CardPayoutService? = null
} }
@@ -9,8 +9,8 @@ import sh.sar.basedbank.api.models.BankAccount
* One option in the Transfer screen's "Transfer Type" picker: a way of paying the number in * One option in the Transfer screen's "Transfer Type" picker: a way of paying the number in
* the "To" field. Picking one also decides the source account — see [worksFrom]. * the "To" field. Picking one also decides the source account — see [worksFrom].
* *
* Add new kinds as subclasses; the exhaustive `when`s over this type point at every site that * Add new kinds (BML verified card, …) as subclasses; the exhaustive `when`s over this type
* needs updating. * point at every site that needs updating.
*/ */
sealed interface TransferType { sealed interface TransferType {
val label: String val label: String
@@ -39,17 +39,4 @@ sealed interface TransferType {
override val badgeRes get() = R.drawable.fahipay_logo override val badgeRes get() = R.drawable.fahipay_logo
override fun worksFrom(account: BankAccount) = account.bank == "FAHIPAY" override fun worksFrom(account: BankAccount) = account.bank == "FAHIPAY"
} }
/**
* A carrier service (reload, …) paid by verified BML card through the carrier's own BML
* merchant gateway. [cards] are the account numbers of the cards that can pay it — verified,
* with an OTP seed for the 3-D Secure step — worked out when the lookup ran.
*/
data class Card(val service: CardPayoutService, val ownerName: String?, val cards: Set<String>) : TransferType {
override val label get() = service.label
override val subtitle get() = listOfNotNull(ownerName, "by BML card").joinToString(" · ")
override val iconRes get() = service.iconRes
override val badgeRes get() = R.drawable.bml_logo_vector
override fun worksFrom(account: BankAccount) = account.bank == "BML" && account.accountNumber in cards
}
} }
@@ -255,7 +255,17 @@ 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 ->
try { MibProfileClient(flow).cacheIfMissing(store, loginId, s) } catch (_: Exception) { } val profile = MibProfileClient().fetchPersonalProfile(s)
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,18 +27,14 @@ 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) { if (profile != null && loginId != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
flow = app.mibFlowFor(loginId)
flow.switchProfile(sess, profile)
}
} }
MibContactsClient(flow).deleteContact(sess, contact.id) MibContactsClient().deleteContact(sess, contact.id)
} catch (_: Exception) { false } } catch (_: Exception) { false }
} }
} }
@@ -669,8 +669,7 @@ 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(
@@ -737,7 +736,6 @@ 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()
@@ -754,8 +752,7 @@ 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(app.mibFlowFor(loginId)).fetchHistory( val (list, total) = MibHistoryClient().fetchHistory(
session = session, session = session,
accountNo = account.accountNumber, accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName, accountDisplayName = account.accountBriefName,
@@ -18,12 +18,6 @@ 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,7 +12,6 @@
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"
+1 -2
View File
@@ -261,6 +261,7 @@
<string name="transfer_to">އެކައުންޓް ނަންބަރު ނުވަތަ ފަވާރާ އައިޑީ</string> <string name="transfer_to">އެކައުންޓް ނަންބަރު ނުވަތަ ފަވާރާ އައިޑީ</string>
<string name="transfer_label_from">ފޮނުވާ އެކައުންޓް</string> <string name="transfer_label_from">ފޮނުވާ އެކައުންޓް</string>
<string name="transfer_label_to">ލިބޭ އެކައުންޓް</string> <string name="transfer_label_to">ލިބޭ އެކައުންޓް</string>
<string name="transfer_select_service">ޚިދުމަތް އިޚްތިޔާރުކުރޭ</string>
<string name="transfer_fahipay_phone_only">ފަހިޕޭއިން ފައިސާ ފޮނުވޭނީ 7 އަދަދުގެ ފޯނު ނަންބަރަކަށް</string> <string name="transfer_fahipay_phone_only">ފަހިޕޭއިން ފައިސާ ފޮނުވޭނީ 7 އަދަދުގެ ފޯނު ނަންބަރަކަށް</string>
<string name="transfer_my_accounts">އަމިއްލަ އެކައުންޓްތައް</string> <string name="transfer_my_accounts">އަމިއްލަ އެކައުންޓްތައް</string>
<string name="transfer_same_as_from">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string> <string name="transfer_same_as_from">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string>
@@ -330,8 +331,6 @@
<!-- Contact expand/delete --> <!-- Contact expand/delete -->
<string name="contact_edit">އުނިއިތުރުގެނޭ</string> <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">ފުހެލާ</string>
<string name="contact_delete_title">ކޮންޓެކްޓް ފުހެލުން</string> <string name="contact_delete_title">ކޮންޓެކްޓް ފުހެލުން</string>
<string name="contact_delete_message">%s ކޮންޓެކްޓް ލިސްޓުން ފުހެލަންވީތޯ؟</string> <string name="contact_delete_message">%s ކޮންޓެކްޓް ލިސްޓުން ފުހެލަންވީތޯ؟</string>
-5
View File
@@ -269,8 +269,6 @@
<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>
@@ -308,7 +306,6 @@
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string> <string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
<string name="bml_card_pay_no_verified">No verified card available. Verify a BML card first in Manage Card.</string> <string name="bml_card_pay_no_verified">No verified card available. Verify a BML card first in Manage Card.</string>
<string name="bml_card_pay_already_paid">This payment has already been completed.</string> <string name="bml_card_pay_already_paid">This payment has already been completed.</string>
<string name="bml_card_pay_merchant_not_notified">Paid, but %1$s couldn\'t be notified. If it isn\'t credited, contact them with BML transaction %2$s.</string>
<string name="bml_qr_payment_success">Payment Successful</string> <string name="bml_qr_payment_success">Payment Successful</string>
<string name="bml_qr_select_account">Select a BML account to pay from</string> <string name="bml_qr_select_account">Select a BML account to pay from</string>
@@ -341,8 +338,6 @@
<!-- 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 and bill pay by BML card | | [dhiraaguapi/](dhiraaguapi/README.md) | Dhiraagu Easy Pay — number lookup for reload / bill pay |
| [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation, Raastas and bill pay by BML card | | [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation for Raastas / bill pay |
+4 -56
View File
@@ -73,8 +73,6 @@ POST <ACS creq url> otpValue=<token TOTP> → auto-POST form (cres → gateway)
POST <gateway callback> cres → auto-POST form (→ mpgsNotification) POST <gateway callback> cres → auto-POST form (→ mpgsNotification)
POST transactions/mpgsNotification/<id> → records the verdict POST transactions/mpgsNotification/<id> → records the verdict
POST …next-action POLL → TRANSACTION_CONFIRMED POST …next-action POLL → TRANSACTION_CONFIRMED
GET transaction…/<id>?wait=1 → 302 merchant redirectUrl (?…&state=CONFIRMED&signature=…)
→ 302 merchant receipt page
``` ```
--- ---
@@ -205,10 +203,8 @@ 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 (still with `otpValue`) and `formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page with text containing
*"The OTP code you entered is incorrect Please try again."* — wait for the next TOTP window, *"incorrect"* / *"expired"* — regenerate the TOTP and retry once.
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.
@@ -227,55 +223,8 @@ 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 The merchant's own backend is also notified out-of-band (e.g.
3-D Secure (`mpgsNotification` got `result=SUCCESS`, `gatewayRecommendation=PROCEED`) and was then `fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`).
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
The `mpgsNotification` response is a page whose script sends the browser to
`https://transaction.merchants.bankofmaldives.com.mv/<id>?wait=1`. Once the transaction is
confirmed, that 302s to the merchant's `redirectUrl` with a BML-signed result, then on to the
merchant's own receipt page:
```
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/services/reload-receipt?pyid=<paymentId> (bill pay: /services/bill-receipt)
```
(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
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
hop can be replayed later from the transaction id alone.
`BmlMerchantCardPayClient` does it after every confirmed payment (`returnToMerchant`): a browser
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
through.
--- ---
@@ -294,7 +243,6 @@ learn of breakage from a failed live payment.
| **Merchant detection** | BML adds other card providers (UnionPay, Apple/Google Pay); non-`mpgs` card provider | Misroute to the wrong flow | | **Merchant detection** | BML adds other card providers (UnionPay, Apple/Google Pay); non-`mpgs` card provider | Misroute to the wrong flow |
| **`window.appData` parsing** | Key moved/obfuscated or made dynamically signed | No `pomeloJsKey` | | **`window.appData` parsing** | Key moved/obfuscated or made dynamically signed | No `pomeloJsKey` |
| **Double-charge** | Confirm poll times out but the charge went through | Retry risks paying twice | | **Double-charge** | Confirm poll times out but the charge went through | Retry risks paying twice |
| **Return to merchant** | The merchant's `redirectUrl` host blocks the client (Cloudflare) or is down | Charged but not delivered — `Success(merchantNotified = false)`, the app toasts the BML transaction id; opening `…/<id>?wait=1` in a browser delivers it |
**Maintenance:** re-capture a HAR whenever any party updates; expect to touch the ACS form parser **Maintenance:** re-capture a HAR whenever any party updates; expect to touch the ACS form parser
most often; the flow is effectively untestable in CI (no deterministic 3-D Secure double). Keep the most often; the flow is effectively untestable in CI (no deterministic 3-D Secure double). Keep the
-175
View File
@@ -1,175 +0,0 @@
# Reload (Easy TopUp, paid by BML card)
Top up a Dhiraagu prepaid number through the dhiraagu.com.mv **Easy TopUp** page. Dhiraagu only
builds the order: the money moves on a **BML Merchant Services transaction** that Dhiraagu creates
for it, which is then paid exactly like any card-only BML merchant link
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
Reconstructed from `docs/dhiraaguapi/tmp/dhiraagu_reload_gateway.md` (a Firefox HAR).
---
## Flow overview
```
GET /services/easy-topup → nonce #1
POST cart&act=recharge (nonce #1) → cartId
GET /services/payment-v2?cartid=<cartId> → nonce #2
POST merchant&act=form (nonce #2) → BML gateway's merchantId
POST payment&act=create (nonce #2) → paymentId, oid
POST bml&act=createV2 (nonce #2) → BML transaction url ──┐
│
── from here: the BML card-only merchant flow ── │
GET transaction.merchants…/<id>/paynow ←─────────────────────────────┘
… Pomelo tokenise, next-action, Wibmo 3-D Secure, MPGS … → TRANSACTION_CONFIRMED
GET transaction.merchants…/<id>?wait=1 → 302 dhiraagu-bml-response.aspx (tops up)
→ 302 /services/reload-receipt
```
After the payment the browser is sent `transaction…/<id>?wait=1` →
`dhiraagu-bml-response.aspx?transactionId=<id>&state=CONFIRMED&signature=…` →
`/services/reload-receipt?pyid=<paymentId>`. **This is what makes Dhiraagu top up the number** —
a payment that stopped at BML's `TRANSACTION_CONFIRMED` was charged but not delivered until that
URL was opened. The card flow follows it for every merchant, see
[BML API → Return to the merchant](../bmlapi/16-card-payment.md#7-return-to-the-merchant).
**Recovering a stuck reload:** open `https://transaction.merchants.bankofmaldives.com.mv/<id>?wait=1`
in a browser. BML signs the callback, so the transaction id is all that's needed. (`curl` gets a
Cloudflare 403 on the Dhiraagu hop; a browser works.)
---
## Common
All API calls are `POST https://www.dhiraagu.com.mv/api/sdk-dhr-webapi.ashx?website_id=CA2BB809-3A22-485B-A518-DA6B6DE653A5&sub=<sub>&act=<act>`
with a JSON body and these headers:
| Header | Value |
|---|---|
| `User-Agent` | a browser UA (same as [Number Lookup](01-number-lookup.md)) |
| `Content-Type` | `application/json` |
| `X-Requested-With` | `XMLHttpRequest` |
| `Origin` | `https://www.dhiraagu.com.mv` |
| `nonce` | `var nonce = "…"` from the page that makes the call |
Every response is `{"respStatus":"OK","resp":…}` on success.
Each page has its own nonce: the cart call uses the Easy TopUp page's, the rest use the payment
page's.
---
## 1. Settings (optional)
`GET …&sub=setting&act=reload` — the page reads its limits from here. Thijooree hardcodes them.
```json
{"gstRate":0.08,"dailyLimit":3000,
"amountLimit":{"min":20,"max":1080,"message":"Enter a whole number amount between MVR 20 and 1000"},
"reloadPerDay":{"easyTopUp":4,"myAccount":6}, …}
```
| Rule | Value |
|---|---|
| Amount | whole MVR, **20 – 1000** (the message says 1000; `max` says 1080 — Thijooree uses 1000) |
| GST | 8%, **included** in the amount |
| Per day | MVR 3000, 4 Easy TopUps |
GST, as the page works it out: `gst = round2(amount × 0.08 / 1.08)`, credited `amount − gst`
(MVR 20 → GST 1.48, credited 18.52).
---
## 2. Cart
`sub=cart&act=recharge`, nonce from `GET /services/easy-topup`.
```json
{"formId":2,"serviceNumber":"7XXXXXX","amount":20,"amountGST":1.48,"amountRecharge":18.52,
"gstRate":0.08,"memberId":"","memberName":"","memberNId":"","customerId":"","customerCode":"","version":2}
```
```json
{"cartId":"002773ed-…","formId":2,"cartAmount":20.00,"cartExpiry":"…",
"paymentUrl":"https://www.dhiraagu.com.mv/services/payment-v2?cartid=002773ed-…", …}
```
The page also calls `sub=dhiraaguIO&act=infoSubscriberStatus` (`{"number"}`) before this, to show
the number's status and balance. Thijooree skips it — [Number Lookup](01-number-lookup.md) has
already confirmed a prepaid number.
---
## 3. Payment gateway
`sub=merchant&act=form`, `{"formId":2}`, nonce from `GET /services/payment-v2?cartid=<cartId>`.
Lists the gateways; **`gatewayId: 1` is Bank of Maldives** (2 = MIB, 3 = DhiraaguPay).
```json
[{"merchantId":"98de333c-…","merchantId2":"3f5cf6b7-…","formId":2,"gatewayId":1,
"gatewayName":"Bank of Maldives", …}, …]
```
---
## 4. Payment
`sub=payment&act=create`
```json
{"formId":2,"cartId":"<cartId>","gatewayId":1,"dhiraaguPayNumber":"","amount":"20.00",
"paymentMerchantId":"<BML merchantId>","memberId":"","tokenize":"","paymentType":"",
"recurringFrequency":"","bmlTokenId":""}
```
```json
{"paymentId":"3ea4351b-…","oid":"ET20260006911381","gatewayId":1,"amount":20.00,"paymentStatus":0, …}
```
---
## 5. BML transaction
`sub=bml&act=createV2`, `{"paymentId":"<paymentId>"}`. Returns the BML Merchant Services
transaction (amounts in cents):
```json
{"state":"INITIATED","amount":2000,"currency":"MVR","localId":"ET20260006911381",
"url":"https://transaction.merchants.bankofmaldives.com.mv/6abfec7afd7f4a4360fc1df4",
"redirectUrl":"https://www.dhiraagu.com.mv/api/dhiraagu-bml-response.aspx",
"expires":"…(10 min)…","customerReference":"WebApp - Topup", …}
```
The 24-hex id at the end of `url` is the transaction. Its `/paynow` page offers **UnionPay +
MPGS cards only, no BML Pay**, so it's paid by card + 3-D Secure.
---
## Reload record
`sub=reload&act=list`, `{"paymentId"}`, nonce from the receipt page — what the receipt page shows:
```json
{"oid":"ET20260006911381","transId":"<BML txn id>","serviceNumber":"7XXXXXX",
"amountPay":20.00,"amountTopup":18.52,"amountGST":1.48,
"paidStatus":1,"topupStatus":1,"reloadStatusDesc":"Successful", …}
```
Not used by Thijooree yet.
---
## Cloudflare
`www.dhiraagu.com.mv` is behind Cloudflare. The browser capture carries a `cf_clearance` cookie,
but [Number Lookup](01-number-lookup.md) already works from the app with plain okhttp and a browser
UA, so these calls are made the same way.
---
&nbsp;
---
**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)
[← Number Lookup](01-number-lookup.md) · [Bill Pay →](03-bill-pay.md)
-132
View File
@@ -1,132 +0,0 @@
# 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)
-2
View File
@@ -96,8 +96,6 @@ The API only returns a valid result for numbers currently on the Dhiraagu networ
| # | File | Description | | # | File | Description |
|---|---|---| |---|---|---|
| 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 |
| 3 | [Bill Pay](03-bill-pay.md) | Easy Pay order → BML merchant transaction, paid by card |
--- ---
+2 -2
View File
@@ -25,7 +25,7 @@ POST https://fahipay.mv/api/app/login/
| `grant_type` | `auth_id` | Always `auth_id` | | `grant_type` | `auth_id` | Always `auth_id` |
| `lang` | `en` | Always `en` | | `lang` | `en` | Always `en` |
| `version` | `2.0.0` | App version string | | `version` | `2.0.0` | App version string |
| `platform` | `thijooree` | Client identifier (`app` in the original Fahipay app) | | `platform` | `BasedBank` | Client identifier (`app` in the original Fahipay app) |
| `device[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) | | `device[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) |
| `device[platform]` | `Android` | | | `device[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install | | `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install |
@@ -53,7 +53,7 @@ curl --request POST \
--form 'grant_type=auth_id' \ --form 'grant_type=auth_id' \
--form 'lang=en' \ --form 'lang=en' \
--form 'version=2.0.0' \ --form 'version=2.0.0' \
--form 'platform=thijooree' \ --form 'platform=BasedBank' \
--form 'device[available]=true' \ --form 'device[available]=true' \
--form 'device[platform]=Android' \ --form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \ --form 'device[uuid]=a1b2c3d4e5f60718' \
+2 -2
View File
@@ -34,7 +34,7 @@ POST https://fahipay.mv/api/app/otp/
| `grant_type` | `auth_id` | Always `auth_id` | | `grant_type` | `auth_id` | Always `auth_id` |
| `lang` | `en` | Always `en` | | `lang` | `en` | Always `en` |
| `version` | `2.0.0` | App version string | | `version` | `2.0.0` | App version string |
| `platform` | `thijooree` | Client identifier (`app` in the original Fahipay app) | | `platform` | `BasedBank` | Client identifier (`app` in the original Fahipay app) |
| `device[available]` | `true` | Same device fields as login — must match | | `device[available]` | `true` | Same device fields as login — must match |
| `device[platform]` | `Android` | | | `device[platform]` | `Android` | |
| `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request | | `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request |
@@ -64,7 +64,7 @@ curl --request POST \
--form 'grant_type=auth_id' \ --form 'grant_type=auth_id' \
--form 'lang=en' \ --form 'lang=en' \
--form 'version=2.0.0' \ --form 'version=2.0.0' \
--form 'platform=thijooree' \ --form 'platform=BasedBank' \
--form 'device[available]=true' \ --form 'device[available]=true' \
--form 'device[platform]=Android' \ --form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \ --form 'device[uuid]=a1b2c3d4e5f60718' \
+1 -1
View File
@@ -37,4 +37,4 @@ Server-issued payload fields that differ from a plain PayMV QR: `60` = `LD` + 4
--- ---
[← Saved Favourites](07-contacts.md) | [Payments →](09-payments.md) [← Saved Favourites](07-contacts.md)
-157
View File
@@ -1,157 +0,0 @@
# Payments: Reload, Raastas & Bill Pay
Pay a Dhiraagu or Ooredoo number from the Fahipay wallet. All four services use the same request; only the path differs.
---
## Endpoints
| Service | Endpoint | Activity `subtype` |
|---|---|---|
| Ooredoo Raastas (prepaid top-up) | `POST https://fahipay.mv/actions/payment/ooredoo/recharge/` | `OORCH` |
| Ooredoo Bill Pay | `POST https://fahipay.mv/actions/payment/ooredoo/billpay/` | `OOBPY` |
| Dhiraagu Reload | `POST https://fahipay.mv/actions/payment/dhiraagu/recharge/` | `DHRCH` |
| Dhiraagu Bill Pay | `POST https://fahipay.mv/actions/payment/dhiraagu/billpay/` | `DHBPY` |
Which services a number supports comes from the carrier lookups: [Dhiraagu](../dhiraaguapi/01-number-lookup.md) and [Ooredoo](../ooredooapi/01-number-validation.md).
---
## Prerequisites
- Valid `authID` from [login](01-login.md) or [OTP](02-otp.md)
- Valid `__Secure-sess` session cookie
There's no OTP or PIN step. The single POST moves the money.
---
## Request
### Headers
| Header | Value |
|---|---|
| `authid` | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
| `Content-Type` | `multipart/form-data; boundary=<boundary>` |
| `Cookie` | `__Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
The official app also sends a set of `x-app-*` / `x-device-*` headers and a `FahiPay-App/2.0.2 (...)` user agent. Thijooree sends `okhttp/4.12.0` like its other data calls.
### Body (`multipart/form-data`)
Thijooree builds it with lowercase `content-disposition` part headers, the way the app sends them (`FahipayForm.body`).
| Field | Example | Notes |
|---|---|---|
| `number` | `9198026` | 7-digit phone number |
| `amount` | `11` | MVR. Whole number for Raastas, Dhiraagu Reload and Dhiraagu Bill Pay. Ooredoo Bill Pay takes decimals (`10.1` seen) |
| `lang` | `en` | |
| `version` | `2.0.2` | App version |
| `build` | `329` | App build |
| `platform` | `app` | The official app sends `app`. Thijooree sends `thijooree` |
| `device[...]` | | The standard [device fields](README.md#common-form-fields-device-info) |
### Amount limits
These are enforced in Thijooree before sending (see [Transfer Flows](../thijooree/20-transfer-flows.md#amount-rules)):
| Service | Min | Max | Decimals |
|---|---|---|---|
| Raastas | 11 | none | no |
| Ooredoo Bill Pay | 10 | 50,000 | yes |
| Dhiraagu Reload | 8 | 1,000 | no |
| Dhiraagu Bill Pay | 10 | 5,000 | no |
The full `amount` is taken from the wallet. Raastas then has 8% GST taken out by Ooredoo, so the number is credited less than `amount`.
---
## curl Example
```bash
curl --request POST \
--url 'https://fahipay.mv/actions/payment/ooredoo/recharge/' \
--compressed \
--header 'authid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
--header 'Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
--form 'number=9198026' \
--form 'amount=11' \
--form 'lang=en' \
--form 'version=2.0.2' \
--form 'build=329' \
--form 'platform=thijooree' \
--form 'device[available]=true' \
--form 'device[platform]=Android' \
--form 'device[uuid]=a1b2c3d4e5f60718' \
--form 'device[model]={model}' \
--form 'device[manufacturer]={manufacturer}' \
--form 'device[isVirtual]=false' \
--form 'device[serial]=unknown'
```
---
## Response
`200 OK`, `application/json`.
### Success: reload / Raastas
```json
{"title":"Success!","msg":"Transaction successful.","type":"success","tid":"FP202610021957143XKQ"}
```
### Success: Ooredoo Bill Pay
```json
{"title":"Success!","msg":"Transaction successful.","type":"success"}
```
### Success: Dhiraagu Bill Pay
```json
{"title":"Success!","msg":"Transaction will be processed shortly.","type":"success"}
```
| Field | Description |
|---|---|
| `type` | `success` when the payment went through |
| `title` / `msg` | Human-readable outcome |
| `tid` | Fahipay transaction ID. Only returned by the recharge endpoints. Bill pays have one too, but it's only visible in [history](05-history.md) |
### Failure
Not captured yet. Thijooree treats any `type` other than `success` as a refusal and shows `msg` (or `title`).
---
## In history
The payment shows up in [`actions/activity/`](05-history.md) straight away, with a negative `amount`:
```json
{
"date": "2026-10-02 19:57:14",
"name": "Ooredoo Raastas",
"details": "Mobile Recharge - 9198026",
"icon": "https://fahipay.mv/images/app/icons/services/oorch.png",
"transaction": "FP202610021957143XKQ",
"type": "payment",
"subtype": "OORCH",
"number": "9198026",
"amount": -11,
"success": 1,
"status": "Success"
}
```
`name` / `details` per service: `Ooredoo Raastas` / `Mobile Recharge - <number>`, `Ooredoo BillPay` / `BillPay - <number>`, `Dhiraagu Reload` / `Mobile Recharge - <number>`, `Dhiraagu BillPay` / `BillPay - <number>`.
---
&nbsp;
---
[← PayMV QR](08-paymv-qr.md)
-1
View File
@@ -128,7 +128,6 @@ Client Server
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) | | 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service | | 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
| 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress | | 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress |
| 9 | [Payments](09-payments.md) | Dhiraagu reload / bill pay, Ooredoo Raastas / bill pay |
--- ---
+1 -11
View File
@@ -57,16 +57,6 @@ 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`
@@ -77,7 +67,7 @@ Content-Type: application/x-www-form-urlencoded; charset=utf-8
"sfunc": "r", "sfunc": "r",
"data": { "data": {
"cmod": "<G^A mod P as decimal string>", "cmod": "<G^A mod P as decimal string>",
"appId": "AND34-<15 random alphanumeric chars>", "appId": "IOS17.2-<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>"
+1 -1
View File
@@ -12,7 +12,7 @@ MIB uses **two completely separate backends**:
| Backend | Base URL | Auth | Used for | | Backend | Base URL | Auth | Used for |
|---|---|---|---| |---|---|---|---|
| Encrypted API | `https://faisamobilex-smvc-v2.mib.com.mv/index/` | Blowfish + DH session key | Login, key exchange | | Encrypted API | `https://faisanet.mib.com.mv/faisamobilex_smvc/` | Blowfish + DH session key | Login, key exchange |
| WebView host | `https://faisamobilex-wv.mib.com.mv` | Session cookies | Accounts, history, transfers, contacts, cards, financing | | WebView host | `https://faisamobilex-wv.mib.com.mv` | Session cookies | Accounts, history, transfers, contacts, cards, financing |
--- ---
-237
View File
@@ -1,237 +0,0 @@
# MIB API v1 to v2 Upgrade Tracking
This document tracks all MIB API calls used by thijooree and their upgrade status from v1 to v2.
---
## Summary
**Total API Endpoints:** 28+
**Categories:** 8
**Last Updated:** 2026-10-22
---
## API Endpoints by Category
### 1. Authentication & Login (Encrypted API)
| API Code | Endpoint | Purpose | Used By | Status |
|----------|----------|---------|---------|--------|
| `sfunc=r` + `S40` | Key Exchange (Init) | First-time device registration | `MibLoginFlow.kt` | ✅ |
| `sfunc=i` + `S40` | Key Exchange (Login) | Regular login session setup | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `C41` | Registration Init | Initialize device registration flow | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `C42` | OTP Verification | Verify one-time password after registration | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `A41` | Main Login | Submit username/password credentials | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `A44` | Get User Salt | Retrieve user salt for password hashing | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `P47` | Switch Profile | Activate different user profile | `MibLoginFlow.kt` | ✅ |
| `sfunc=n` + `A80` | Accounts | Refresh account list/balances for active profile (same shape as P47 `accountBalance`) | — (not used yet) | ℹ️ |
| `sfunc=n` + `A83` | GIA Accounts | `{start:1,end:30,statusType:0,includeCount:0}` | — (not used) | ℹ️ |
| `sfunc=n` + `A47` | Pending approvals | `{status:"P",start:0,end:30,includeCount:1}` (business profiles) | — (not used) | ℹ️ |
| `sfunc=n` + `P41` | Profile image | `{imageHash}` → base64 JPEG in `profileImage` | — (not used yet) | ℹ️ |
| `sfunc=n` + `B46` | Beneficiary image | `{imageHash}` → base64 JPEG in `profileImage` | — (not used yet) | ℹ️ |
| `sfunc=i` + `P80` | Promo banners | Encrypted with **key1**, not session key | — (not used) | ℹ️ |
---
### 2. Beneficiary & Contact Management
v2 moved all contact calls to the encrypted API (`sfunc=n`). Bank IDs/names/SWIFT come from `B49` (login).
| v1 endpoint | v2 route | Request | Used By | Status |
|-------------|----------|---------|---------|--------|
| `/ajaxBeneficiary/getCategories` | `B41` | Categories come back in every `B41` response as `categories[]` — no separate call | `MibContactsClient.kt`, `HomeActivity.kt` | 🔧 implemented — needs device test |
| `/ajaxBeneficiary/main` | `B41` | `{start,end,benefType:"A",search:"",includeCount:1,searchCategoryId:0,sortBenef:""}` — selects 1-based `rnum` between start/end inclusive; last row has `last:"1"` | `MibContactsClient.kt`, `AddContactSheetFragment.kt` | 🔧 implemented — needs device test |
| `/ajaxBeneficiary/createLocalBeneficiary` | `B43` | `{benefName,benefAccount,bankNo,nickName,transferCy,categoryId,image?,bankName,bankSwift}` — nickname > ~20 chars → `invalid nick name` | `MibContactsClient.kt`, `AddContactSheetFragment.kt` | 🔧 implemented (BML captured; MIB-internal bankName/swift from B49, not captured) |
| — (new) | `B52` | Update: B43 fields + `benefNo`, `imageChanged:"0"/"1"`, `image` when changed | `MibContactsClient.kt`, `AddContactSheetFragment.kt` (edit mode), `ContactDetailsSheetFragment.kt` | 🔧 implemented — needs device test |
| `/ajaxBeneficiary/deleteBeneficiary` | `B48` | `{benefNo}` | `MibContactsClient.kt`, `ContactManager.kt` | 🔧 implemented — needs device test |
| `/ajaxBeneficiary/getProfileImage` | `B46` | `{imageHash}` → `profileImage` base64 JPEG | `MibContactsClient.kt` + image loaders | 🔧 implemented — needs device test |
| — | `B40` | Account name lookup `{search}` → `[{benefName,benefAccount,bankCode,benefBankName}]` | — (check against transfer lookups) | ℹ️ |
| — | `T63` | Currencies + rates | — (not used) | ℹ️ |
----------|---------|---------|--------|
| `/ajaxBeneficiary/getCategories` | Fetch beneficiary category list | `MibContactsClient.kt` | ☐ |
| `/ajaxBeneficiary/main` | List all contacts (paginated) | `MibContactsClient.kt`, `AddContactSheetFragment.kt` | ☐ |
| `/ajaxBeneficiary/createLocalBeneficiary` | Add new beneficiary | `MibContactsClient.kt`, `AddContactSheetFragment.kt` | ☐ |
| `/ajaxBeneficiary/deleteBeneficiary` | Remove beneficiary | `MibContactsClient.kt`, `ContactsFragment.kt` | ☐ |
| `/ajaxBeneficiary/getProfileImage` | Get contact profile picture | `MibContactsClient.kt` | ☐ |
---
### 3. Fund Transfers
v2 moved transfers and all destination lookups to the encrypted API (`sfunc=n`). Failures come back as HTTP 400 with an encrypted body.
| v1 endpoint | v2 route | Request / notes | Used By | Status |
|-------------|----------|-----------------|---------|--------|
| `/ajaxTransfer/transferInternal` | `T50` | `{benefName,benefNo:"0",fromAccountNo,benefAccountNo,transferCy,benefCurrencyCode,amount,bankNo:"2",purpose,otp,otpType:"3"}` → `data[0].{date,trxId,availableBalance}` + `accountBalance[]` | `MibTransferClient.kt`, `MibTransferHandler.kt` | 🔧 implemented — needs device test |
| `/ajaxTransfer/transferLocal` | `T51` | Same as T50 with `bankNo:"3"` → `data[0].{date,trxId}` ("Local transfer completed successfully via Favara") | `MibTransferClient.kt`, `MibTransferHandler.kt` | 🔧 implemented — needs device test |
| `/ajaxBeneficiary/getAccountName` | `B40` | `{search}` → `data[0].{benefName,benefAccount,bankCode,benefBankName,transferCy?,transferCyDesc?}`; `bankCode` is `"MIB"` or a BIC (`MADVMVMV`, `MALBMVMV`); empty `data` = not found | `MibTransferClient.kt` | 🔧 implemented — needs device test |
| `/AjaxAlias/getIPSAccount` | `B40` | as above | `MibTransferClient.kt` | 🔧 implemented — needs device test |
| `/AjaxAlias/getAlias` | `B40` | as above; phone must be bare 7 digits (`+960 …` returns empty); Favara ID needs uppercase `A` (`a239225` fails) | `MibTransferClient.kt` | 🔧 implemented — needs device test |
| — | `T71` / `T66` / `T63` / `T70` | AML limits / USD sell rate / currencies / upload settings | — (not used) | ℹ️ |
**Transfer errors seen:**
- `reasonCode 213` "Invalid OTP" (`data[0].otpId`) and `reasonCode 707` "That OTP is already used!" → thijooree waits for the next TOTP window and retries once, then shows the error.
- `reasonCode 201` "Transaction currency does not match From Account currency!"
----------|---------|---------|--------|
| `/ajaxTransfer/transferInternal` | MIB-to-MIB internal transfer (Bank code 2) | `MibTransferClient.kt`, `TransferFragment.kt` | ☐ |
| `/ajaxTransfer/transferLocal` | Transfer to BML account (Bank code 3) | `MibTransferClient.kt`, `TransferFragment.kt` | ☐ |
| `/ajaxBeneficiary/getAccountName` | Lookup MIB account (17-digit format) | `MibTransferClient.kt`, `MibTransferHandler.kt` | ☐ |
| `/AjaxAlias/getIPSAccount` | Lookup IPS/BML account (15-digit format) | `MibTransferClient.kt`, `MibTransferHandler.kt` | ☐ |
| `/AjaxAlias/getAlias` | Lookup Favara alias (shortcodes, IDs, email) | `MibTransferClient.kt`, `MibTransferHandler.kt` | ☐ |
---
### 4. Debit Card Operations
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `/ajaxDebitCard/fetchCardInfos` | Retrieve list of debit cards | `MibCardsClient.kt`, `PayWithCardFragment.kt` | ✅ host switched, tested |
| `/ajaxDebitCard/freeze` | Freeze/block debit card | `MibCardsClient.kt` | ✅ host switched, tested |
| `/ajaxDebitCard/unfreeze` | Unfreeze/unblock debit card | `MibCardsClient.kt` | ✅ host switched, tested |
---
### 5. Transaction History
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| ~~`/ajaxAccounts/trxHistory`~~ → `sfunc=n` + `A84` | Fetch account transaction history (paginated). v2 moved this to the encrypted API: `{accountNo,start:0,end:19,includeCount:true,trxType:0,sortTrx:"date",sortDir:"desc"}` (0-based inclusive), response fields unchanged, `total_count` present | `MibHistoryClient.kt`, `HistoryFetcher.kt`, `TransferHistoryFragment.kt` | ✅ implemented, tested incl. paging |
---
### 6. Activity & Audit Logs
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `/aProfile/getPagedActivityHistory` | Fetch activity alerts and audit logs (paginated) | `MibActivityHistoryClient.kt` | ☐ |
---
### 7. User Profile
| v1 endpoint | v2 source | Notes | Used By | Status |
|-------------|-----------|-------|---------|--------|
| `GET /personalProfile` (HTML scrape) | `A41` login response | `fullName`, `email` | `MibLoginFlow.kt` | 🔧 implemented — needs device test |
| — | `POST /ajaxAlias/getAliasTypes` (`-wv-v2`, empty body) | `data[].{id,aliasTypeName,suggestedAlias}`: `0` mobile, `1` email, `3` National ID. Tied to the active profile, so the personal profile (`profileType 0`) is selected first. Fetched at login and cached. | `MibProfileClient.kt`, `SettingsLoginsFragment.kt` | 🔧 implemented — needs device test |
| — | `POST /ajaxAlias/main` | Registered Favara aliases (`AlsBfy`, `CdtrAcct`, `AdrLine`, …) | — (not used) | ℹ️ |
----------|---------|---------|--------|
| `GET /personalProfile` | HTML scrape: fullName, username, email, mobile, enrolled date | `MibProfileClient.kt` | ☐ |
---
### 8. Financing & Loans
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `GET /financing?dashurl=1` | Fetch financing/loan deals (HTML parsing) | `MibFinancingClient.kt` | ✅ works on `-wv-v2` |
---
### 9. Session Management
| Endpoint | Purpose | Used By | Status |
|----------|---------|---------|--------|
| `POST /aProfile/keepAlive` | Periodic session keep-alive (empty body) | `HomeActivity.kt` | 🔧 host switched (seen on `-wv-v2` in HAR, 419 after expiry) — needs device test |
---
## Implementation Files Reference
### Core MIB API Clients
- `app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt` - Authentication
- `app/src/main/java/sh/sar/basedbank/api/mib/MibTransferClient.kt` - Transfers & account lookup
- `app/src/main/java/sh/sar/basedbank/api/mib/MibContactsClient.kt` - Contacts/beneficiaries
- `app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt` - Card operations
- `app/src/main/java/sh/sar/basedbank/api/mib/MibHistoryClient.kt` - Transaction history
- `app/src/main/java/sh/sar/basedbank/api/mib/MibActivityHistoryClient.kt` - Activity logs
- `app/src/main/java/sh/sar/basedbank/api/mib/MibProfileClient.kt` - User profile
- `app/src/main/java/sh/sar/basedbank/api/mib/MibFinancingClient.kt` - Financing
### Security & Utilities
- `app/src/main/java/sh/sar/basedbank/api/mib/MibCrypto.kt` - AES encryption/decryption
- `app/src/main/java/sh/sar/basedbank/api/mib/MibNonce.kt` - Nonce generation (anti-replay)
- `app/src/main/java/sh/sar/basedbank/api/mib/MibModels.kt` - Data models
- `app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt` - Credential storage
### Parsers & Handlers
- `app/src/main/java/sh/sar/basedbank/util/mibapi/MibHistoryParser.kt`
- `app/src/main/java/sh/sar/basedbank/util/mibapi/MibAccountParser.kt`
- `app/src/main/java/sh/sar/basedbank/util/mibapi/MibContactParser.kt`
- `app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt`
---
## Base URLs
| Type | URL |
|------|-----|
| **Encrypted API** | `https://faisamobilex-smvc-v2.mib.com.mv/index/` |
| **WebView/Data** | `https://faisamobilex-wv-v2.mib.com.mv` (v1: `faisamobilex-wv`) |
---
## Authentication & Security
### Session Headers
```
Cookie: mbmodel=AND-1.0; xxid=<session_id>; IBSID=<session_id>; mbnonce=<nonce>; time-tracker=597
X-Requested-With: XMLHttpRequest
Accept: */*
Origin: https://faisamobilex-wv-v2.mib.com.mv
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
```
### Session Expiry Detection
- HTTP `419` status code
- JSON `reasonCode == "505"` in encrypted response
- Auto-recovery: re-login and retry (once)
### Password Hashing (PGF03)
```
pgf03 = SHA256(clientSalt + SHA256(SHA256(password) + userSalt))
```
---
## Upgrade Checklist
### Core Implementation
- [x] Update encrypted API endpoint URL
- [x] Update User-Agent header
- [x] Add X-Request-ID header
- [x] Verify Blowfish encryption compatibility
- [x] Test device registration flow
- [x] Test regular login flow
- [x] Test OTP verification
- [x] Test profile switching
### WebView Endpoints (Pending)
- [x] Switch all WebView clients to `faisamobilex-wv-v2` host and `mbmodel=AND-1.0` cookie
- [x] Capture beneficiary management — moved to encrypted `B4x`/`B52`, implemented
- [ ] Test contacts on device (list, add with/without photo, edit, delete, images)
- [x] Capture transfer endpoints — moved to encrypted `T50`/`T51`/`B40`, implemented with OTP retry
- [ ] Test transfers on device (MIB→MIB, MIB→BML, lookups incl. Favara alias)
- [x] Capture debit card endpoints (`/ajaxDebitCard/*`) — unchanged apart from host
- [x] Test debit card endpoints on device
- [x] Capture transaction history — moved to encrypted `A84`, implemented
- [x] Test transaction history on device (incl. page 2)
- [ ] Test activity history (`/aProfile/getPagedActivityHistory`)
- [x] Capture user profile — `/personalProfile` scrape replaced by A41 + `getAliasTypes`
- [ ] Test login details (name, email, mobile, ID card) on device
- [x] Test financing (`/financing?dashurl=1`) — works on `-wv-v2`
- [ ] Test session keep-alive (`/aProfile/keepAlive`)
### Validation
- [ ] Verify all account balances display correctly
- [ ] Verify error handling (419, 505) and auto-recovery
- [ ] Verify all data formats match expectations
- [ ] Full end-to-end user flow test
---
**Implementation:** 2026-10-22 ✅
**WebView Testing:** In progress
+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) · [Raastas →](02-raastas.md) [← README](README.md)
-107
View File
@@ -1,107 +0,0 @@
# 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
@@ -1,65 +0,0 @@
# 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,8 +81,6 @@ 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 |
--- ---
+11 -39
View File
@@ -1,6 +1,6 @@
# Transfer # Transfer
The transfer screen initiates account-to-account fund transfers and phone payments. It supports MIB, BML, Fahipay and M-Faisa as sources and handles all bank-specific authentication and OTP steps. A phone number can also be paid as a carrier service (reload, Raastas, bill pay) from the Fahipay wallet or, for Dhiraagu Reload, a verified BML card. The transfer screen initiates account-to-account fund transfers. It supports MIB, BML, and Fahipay as source banks and handles all bank-specific authentication and OTP steps.
--- ---
@@ -38,12 +38,11 @@ A dropdown lists all visible accounts parsed via `AccountListParser.from(acc)?.b
## Recipient Entry ## Recipient Entry
The user can specify a recipient in these ways: The user can specify a recipient in three ways:
1. **Manual entry** — type an account number or phone number directly 1. **Manual entry** — type an account number directly
2. **Contact picker** — opens `ContactPickerSheetFragment` to select a saved contact. A Fahipay favourite opens as its payout service straight away 2. **Contact picker** — opens `ContactPickerSheetFragment` to select a saved contact
3. **QR scan** — launches [QrScannerActivity](25-qr-scanner.md); a PayMV QR result pre-fills the account number, amount, and remarks; a BML ebanking / pay.bml URL switches the form into [BML QR merchant payment](20-transfer-flows.md#bml-qr-merchant-payment-flow) mode 3. **QR scan** — launches [QrScannerActivity](25-qr-scanner.md); a PayMV QR result pre-fills the account number, amount, and remarks; a BML ebanking / pay.bml URL switches the form into [BML QR merchant payment](20-transfer-flows.md#bml-qr-merchant-payment-flow) mode
4. **BML Merchant Services transaction ID or link** — paid through BML Pay (QR flow) or, for card-only merchants, a verified card ([Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md))
--- ---
@@ -65,22 +64,10 @@ After the user finishes entering a recipient account number, the app calls the s
- **MIB**: account name lookup via MIB API - **MIB**: account name lookup via MIB API
- **BML**: beneficiary lookup via BML API - **BML**: beneficiary lookup via BML API
- **Fahipay**: phone numbers only — the Dhiraagu / Ooredoo carrier lookup decides which payout services apply - **Fahipay**: account name resolution via Fahipay API
The resolved name is displayed below the account number field for the user to confirm. The resolved name is displayed below the account number field for the user to confirm.
### Phone numbers — Transfer Type picker
A phone number searched with no source yet (or from a BML card that can pay by card) is looked up every way it can be paid, in parallel: Favara (MIB / BML), and the carrier lookup when the user has a Fahipay wallet or a verified BML card. Each result is a **transfer type**:
| Type | Example | Pays from |
|---|---|---|
| Favara Transfer | bank account behind the number | MIB or BML account |
| Fahipay service | Raastas, Ooredoo Bill Pay, Dhiraagu Reload, Dhiraagu Bill Pay | Fahipay wallet |
| 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).
--- ---
## Biometric Gate ## Biometric Gate
@@ -113,33 +100,18 @@ When the source is a BML USD account and the destination is a MIB account but no
5. Re-submits with OTP 5. Re-submits with OTP
6. On success, shows `TransferReceiptFragment` 6. On success, shows `TransferReceiptFragment`
### Fahipay Payout (reload, Raastas, bill pay) ### Fahipay Transfer
1. Checks the amount against the picked service's rules 1. Validates fields
2. Confirm dialog (with the GST note for Raastas), then the biometric gate if enabled 2. (If biometric gate) prompts biometrics
3. One POST to the service's Fahipay payment endpoint ([Fahipay API → Payments](../fahipayapi/09-payments.md)) 3. Submits via Fahipay API using stored `authID` + session cookie
4. On success, the result shows inside the dialog (no receipt page yet), then the form clears 4. On success, shows `TransferReceiptFragment`
See [Transfer Flows → Fahipay source](20-transfer-flows.md#fahipay-source).
### Carrier Service by BML Card (Dhiraagu Reload / Bill Pay, Ooredoo Raastas / Bill Pay)
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 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 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
See [Transfer Flows → Carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card).
### BML Merchant Payment (QR / card-only link)
A BML QR, or a BML Merchant Services link whose merchant takes BML Pay, is paid from a BML card through the QR flow. A card-only merchant link is paid with a verified card (Pomelo + 3-D Secure), followed by the return to the merchant. Neither saves a receipt. See [Transfer Flows → BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) and [Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md).
--- ---
## Transfer Receipt ## Transfer Receipt
On success of a bank transfer (MIB, BML, M-Faisa) the fragment navigates to `TransferReceiptFragment` passing the completed transfer details. Fahipay payouts, card services and merchant payments show their result inside the confirm dialog instead. On success the fragment navigates to `TransferReceiptFragment` passing the completed transfer details.
--- ---
+3 -92
View File
@@ -49,13 +49,11 @@ Each option is a `TransferType` (`ui/home/transfer/TransferType.kt`):
|---|---|---|---| |---|---|---|---|
| `Favara(info)` | Favara Transfer | `favara_logo` | MIB or BML account | | `Favara(info)` | Favara Transfer | `favara_logo` | MIB or BML account |
| `Fahipay(service, ownerName)` | the service's label, e.g. Raastas | `FahipayService.iconRes`: `ooredoo_logo` / `dhiraagu_logo` | Fahipay wallet | | `Fahipay(service, ownerName)` | the service's label, e.g. Raastas | `FahipayService.iconRes`: `ooredoo_logo` / `dhiraagu_logo` | Fahipay wallet |
| `Card(service, ownerName, cards)` | the service's label, e.g. Dhiraagu Reload | `CardPayoutService.iconRes`, with a BML badge | One of `cards`: verified BML cards that can pay by card (see [Carrier services by BML card](#carrier-services-by-bml-card)) |
Picking an option also picks the source (`TransferFragment.applyTransferType`). If the selected source can't pay that type, Thijooree switches it: Picking an option also picks the source (`TransferFragment.applyTransferType`). If the selected source can't pay that type, Thijooree switches it:
- **Favara:** the default account, if it's MIB or BML. Otherwise the source is cleared and the user is asked to pick one. - **Favara:** the default account, if it's MIB or BML. Otherwise the source is cleared and the user is asked to pick one.
- **Fahipay:** the user's Fahipay wallet. - **Fahipay:** the user's Fahipay wallet.
- **Card:** the default card, if it's one of the type's cards. Otherwise the first of them.
Then the recipient card is filled in. The options, the number they were looked up for and the pick are kept in `TransferDraft`. If the view is recreated while the popup is still unanswered, it opens again. Then the recipient card is filled in. The options, the number they were looked up for and the pick are kept in `TransferDraft`. If the view is recreated while the popup is still unanswered, it opens again.
@@ -66,11 +64,9 @@ The options are dropped when the "To" number is edited, the recipient is cleared
Two lookups run in parallel: Two lookups run in parallel:
- **Favara / IPS lookup.** Uses any logged-in MIB or BML session. The default account's bank goes first, the other is the fallback. - **Favara / IPS lookup.** Uses any logged-in MIB or BML session. The default account's bank goes first, the other is the fallback.
- **Carrier lookup** (`CarrierLookup.query`, see Fahipay source below). Only runs when the user has a Fahipay wallet or a BML card that can pay by card. One lookup feeds both. - **Carrier lookup** (see Fahipay source below). Only runs when the user has a Fahipay wallet.
Everything that resolves is offered as a transfer type. Favara comes first, then the Fahipay services, then the card services. Everything that resolves is offered as a transfer type. Favara comes first, then the Fahipay services. If nothing resolves, the Favara lookup's error is shown as a toast.
The same lookup runs when the source is already a BML card that can pay by card and a phone number is searched. If nothing resolves, the Favara lookup's error is shown as a toast.
Any other input with no source selected falls back to the default account as the source, and then the normal lookup for that bank runs. Any other input with no source selected falls back to the default account as the source, and then the normal lookup for that bank runs.
@@ -105,7 +101,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.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: 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:
- 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
@@ -135,87 +131,10 @@ Raastas charges 8% GST out of the amount paid (`FahipayService.gstPercent`), so
When the amount breaks a rule, the error replaces the helper text. When the amount breaks a rule, the error replaces the helper text.
#### Sending
`initiateTransfer` hands a Fahipay source to `FahipayTransferHandler.submit()`. The flow:
1. **Confirm dialog.** From is the wallet. To is the recipient name, the number and the service's `destinationLabel` (e.g. "Ooredoo · Raastas"). For Raastas, the GST line ("Recipient receives MVR X after 8% GST") is shown as a warning.
2. **Biometric gate**, as for every transfer.
3. **Payment.** `FahipayPaymentClient.pay()` POSTs to the service's `paymentPath` (see [Fahipay Payments](../fahipayapi/09-payments.md)). The amount is sent without trailing zeros (`11`, `10.1`).
4. **Result.** On success, the result shows inside the dialog (no receipt page yet), then OK clears the form and refreshes balances. A refusal closes the dialog and toasts the server's `msg`. A network failure shows the no-internet message.
#### Reference #### Reference
None of the Fahipay services take a reference. Picking one clears the Reference field and disables it, the same way BML merchant QR payments do. Clearing the service turns the field back on. None of the Fahipay services take a reference. Picking one clears the Reference field and disables it, the same way BML merchant QR payments do. Clearing the service turns the field back on.
### Carrier services by BML card
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: **Dhiraagu Reload**, **Dhiraagu
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
rule as card-only merchant links (`BmlVerifiedCards`, see
[Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md)). The
type remembers those cards (`TransferType.Card.cards`). Picking a card that isn't one of them
drops the pick, like any other source that can't pay the picked type.
**Which services.**
| Carrier result | Service |
|---|---|
| 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
the shared `PayoutAmountField`:
| Service | Min (MVR) | Max (MVR) | Decimals | GST |
|---|---|---|---|---|
| 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.
**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
BML transaction comes from:
1. `CardPayoutTransferHandler.submit()` has the carrier create it for the number and amount
(`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
(`TransferFragment.showProcessingDialog`) and closes before the confirm dialog opens.
2. Its payment page is loaded (`BmlMerchantTxnClient.fetchPayPage`). If it doesn't take cards, or
its amount isn't the one typed, the payment stops with a toast.
3. The page goes to `BmlTransferHandler.confirmCardMerchant`, so from here it's the merchant-link
card flow: the same confirm dialog and warning, biometric gate, Pomelo + 3-D Secure payment,
and success / failure handling.
Nothing is charged before the confirm dialog. A cancelled confirm leaves an unpaid Dhiraagu order,
which expires on its own.
### BML source ### BML source
1. If the input type is `MIB_ACCOUNT`, calls `BmlValidateClient.verifyMibAccount()`. 1. If the input type is `MIB_ACCOUNT`, calls `BmlValidateClient.verifyMibAccount()`.
@@ -280,13 +199,6 @@ Source: Fahipay
FAHIPAY_TRANSFER, RAASTAS, OOREDOO_BILL, DHIRAAGU_RELOAD, DHIRAAGU_BILL FAHIPAY_TRANSFER, RAASTAS, OOREDOO_BILL, DHIRAAGU_RELOAD, DHIRAAGU_BILL
``` ```
```
Transfer type: Card (verified BML card)
└── Carrier creates a BML merchant transaction → card-only merchant flow
DHIRAAGU_RELOAD, DHIRAAGU_BILL, OOREDOO_RAASTAS, OOREDOO_BILL
```
--- ---
## Rejected Combinations ## Rejected Combinations
@@ -415,7 +327,6 @@ The transfer button is only enabled when all of the following are true:
- Amount is greater than `0` - Amount is greater than `0`
- If transfer types are on offer, one has been picked - If transfer types are on offer, one has been picked
- For a Fahipay service, the amount meets that service's rules (see [Amount rules](#amount-rules)) - For a Fahipay service, the amount meets that service's rules (see [Amount rules](#amount-rules))
- For a carrier service by card, the amount meets that service's rules (see [Carrier services by BML card](#carrier-services-by-bml-card))
- No connectivity error for `NO_INTERNET` or for the source bank - No connectivity error for `NO_INTERNET` or for the source bank
--- ---
@@ -7,9 +7,7 @@ Two linked features:
full card details (PAN, expiry, CVV) encrypted on-device. full card details (PAN, expiry, CVV) encrypted on-device.
2. **Merchant card payment** — on [Transfer](07-transfer.md), a BML Merchant Services transaction ID 2. **Merchant card payment** — on [Transfer](07-transfer.md), a BML Merchant Services transaction ID
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)).
[carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card) (Dhiraagu
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
@@ -108,28 +106,23 @@ into the Transfer screen rather than a one-off dialog, mirroring the BML QR merc
and empties the amount and re-enables remarks. and empties the amount and re-enables remarks.
A card is only offered when it is **both** verified **and** belongs to a BML login the app has an A card is only offered when it is **both** verified **and** belongs to a BML login the app has an
OTP seed for (`BmlVerifiedCards.payable` / `isPayable`, `ui/home/transfer/BmlVerifiedCards.kt`) — OTP seed for (`verifiedCardCandidates`, `:428`; `isCardVerified`, `:463`) — the 3-D Secure step
the 3-D Secure step needs that seed. needs that seed.
### Send ### Send
`submitCardPayment` → `confirmCardMerchant` shows the shared transfer confirm dialog `submitCardPayment` (`:496`) → `confirmCardMerchant` (`:506`) shows the shared transfer confirm
(biometric-gated), then `executeCardMerchant` runs, off the main thread. dialog (biometric-gated), then `executeCardMerchant` (`:534`) runs, off the main thread:
`CardPayoutTransferHandler` calls `confirmCardMerchant` directly with the page of the
transaction a carrier created:
``` ```
BmlMerchantCardPayClient().pay(page, card) { Totp.generate(otpSeed) } BmlMerchantCardPayClient().pay(page, card) { Totp.generate(otpSeed) }
``` ```
where `card` and `otpSeed` come from `BmlVerifiedCards.load` — the `VerifiedCardStore` entry where `card` comes from `VerifiedCardStore` (expiry split `MM/YY` → month/year) and `otpSeed` is the
(expiry split `MM/YY` → month/year) and the card's BML login seed. The client (`api/bml/BmlMerchantCardPayClient.kt`) performs the whole card's BML login seed. The client (`api/bml/BmlMerchantCardPayClient.kt`) performs the whole
Pomelo + MPGS + Wibmo 3-D Secure sequence — feeding the BML token TOTP into the ACS OTP form Pomelo + MPGS + Wibmo 3-D Secure sequence — feeding the BML token TOTP into the ACS OTP form
automatically, retrying once if the first code expired. Once BML confirms, it loads automatically, retrying once if the first code expired. Outcome is shown in the shared
`<id>?wait=1` and follows the redirects to the merchant, which is how the merchant learns it was processing/success dialog; failures surface as a toast.
paid (Dhiraagu doesn't top up without it). Outcome is shown in the shared processing/success
dialog; failures surface as a toast. If the merchant couldn't be reached, success also toasts
the merchant name and BML transaction id (`bml_card_pay_merchant_not_notified`).
### Key assumption ### Key assumption
@@ -152,8 +145,6 @@ another login/person, or a card whose 3-D Secure only offers SMS/email OTP.
| `api/bml/BmlMerchantTxnClient.kt` | `fetchPayPage` (merchant-type detection), `announceBrowser`, QR payload | | `api/bml/BmlMerchantTxnClient.kt` | `fetchPayPage` (merchant-type detection), `announceBrowser`, QR payload |
| `api/bml/BmlMerchantCardPayClient.kt` | Pomelo tokenise + 3-D Secure card payment | | `api/bml/BmlMerchantCardPayClient.kt` | Pomelo tokenise + 3-D Secure card payment |
| `ui/home/transfer/BmlTransferHandler.kt` | On-screen card merchant mode + payment | | `ui/home/transfer/BmlTransferHandler.kt` | On-screen card merchant mode + payment |
| `ui/home/transfer/BmlVerifiedCards.kt` | Which cards can pay by card; loads their details |
| `ui/home/transfer/CardPayoutTransferHandler.kt` | Carrier services by card: carrier creates the transaction, then `confirmCardMerchant` |
| `ui/home/TransferFragment.kt` | Transaction-ID lookup + routing | | `ui/home/TransferFragment.kt` | Transaction-ID lookup + routing |
--- ---
+3 -3
View File
@@ -15,7 +15,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [04 — Accounts](04-accounts.md) | Account list grouped display, AccountsAdapter, profile images, quick-transfer shortcut | | [04 — Accounts](04-accounts.md) | Account list grouped display, AccountsAdapter, profile images, quick-transfer shortcut |
| [05 — Account History](05-account-history.md) | Paginated transaction history, search, infinite scroll | | [05 — Account History](05-account-history.md) | Paginated transaction history, search, infinite scroll |
| [06 — Transfer History](06-transfer-history.md) | Multi-bank merged transfer history, parallel loading | | [06 — Transfer History](06-transfer-history.md) | Multi-bank merged transfer history, parallel loading |
| [07 — Transfer](07-transfer.md) | Recipient lookup, transfer type picker, MIB/BML/Fahipay transfers, Fahipay payouts, Dhiraagu Reload by BML card, QR, biometric gate, BML OTP | | [07 — Transfer](07-transfer.md) | Recipient lookup, MIB/BML/Fahipay transfer flows, QR, biometric gate, BML OTP |
| [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet | | [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet |
| [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt | | [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt |
| [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators | | [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators |
@@ -34,7 +34,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [26 — Circular Nav](26-circular-nav.md) | Radial 4-slot wheel UI with lock centre | | [26 — Circular Nav](26-circular-nav.md) | Radial 4-slot wheel UI with lock centre |
| [27 — Settings: Notifications](27-settings-notifications.md) | Opt-in flow: permission → battery opt → service start | | [27 — Settings: Notifications](27-settings-notifications.md) | Opt-in flow: permission → battery opt → service start |
| [28 — Settings: About](28-settings-about.md) | Version, T&Cs, donate buttons | | [28 — Settings: About](28-settings-about.md) | Version, T&Cs, donate buttons |
| [29 — Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md) | NFC/manual card verification + card-only BML merchant payment (links and carrier services), return to merchant | | [29 — Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md) | NFC/manual card verification + card-only BML merchant payment |
## Reference ## Reference
@@ -42,5 +42,5 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
|---|---| |---|---|
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, per-bank references, real samples, Fahipay WIP, parsing reference | | [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, per-bank references, real samples, Fahipay WIP, parsing reference |
| [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model | | [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model |
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type picker, Fahipay services, carrier services by BML card, routing, rejected combinations, BML business OTP flow, BML QR merchant payments | | [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
| [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy | | [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy |
@@ -1,11 +0,0 @@
- 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
@@ -1,5 +0,0 @@
- 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