Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
740030e061
|
||
|
|
6b9ac5532b
|
||
|
|
46edefe4d7
|
||
|
|
870d3eb0f6
|
||
|
|
7ccd8aa4f5
|
||
|
|
05ff5a829a
|
||
|
|
d4805219f4
|
||
|
|
4c680ad22c
|
||
|
|
494a42c1a8
|
||
|
|
9ace8dd724
|
||
|
|
a3449167db
|
||
|
|
1612e21471
|
||
|
|
f02b05b180
|
||
|
|
5abd981096
|
||
|
|
bb76f4e591
|
||
|
|
8795d5f758
|
||
|
|
0d4f0074c7
|
@@ -24,20 +24,11 @@ jobs:
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
|
||||
|
||||
BEFORE="${{ github.event.before }}"
|
||||
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"
|
||||
if git tag -l | grep -q "^v${VERSION}$"; then
|
||||
echo "Tag v${VERSION} already exists, skipping"
|
||||
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
|
||||
echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})"
|
||||
echo "New version detected: v${VERSION}"
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.envrc
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
@@ -18,6 +19,9 @@ docs/bmlapi/tmp
|
||||
docs/fahipayapi/tmp
|
||||
docs/mfaisaapi/tmp
|
||||
docs/dhiraaguapi/tmp
|
||||
docs/ooredooapi/tmp
|
||||
docs/ooredooapi/tmp
|
||||
tmp
|
||||
app/key.jks
|
||||
.kotlin/*
|
||||
.claude/*
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 34
|
||||
versionName = "1.0.33"
|
||||
versionCode = 36
|
||||
versionName = "1.0.36"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -34,10 +34,12 @@ import android.util.Base64
|
||||
* 4. The 3-D Secure challenge on BML's Wibmo ACS: the render page auto-posts the `creq`, we pick
|
||||
* 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`.
|
||||
* 5. Poll next-action until TRANSACTION_CONFIRMED.
|
||||
* 5. Poll next-action until TRANSACTION_CONFIRMED. A decline after 3-D Secure (e.g. insufficient
|
||||
* funds) doesn't show up there: it's a new `paymentErrorHistory` entry on the transaction,
|
||||
* checked alongside.
|
||||
* 6. Return to the merchant: `GET <txn>?wait=1` redirects to the merchant's `redirectUrl` with a
|
||||
* signed `state=CONFIRMED` — the browser's last hop, and how some merchants (Dhiraagu) learn
|
||||
* they were paid. Without it the card is charged but the merchant never delivers.
|
||||
* 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
|
||||
* session cookies.
|
||||
@@ -90,7 +92,10 @@ class BmlMerchantCardPayClient {
|
||||
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
|
||||
val txnId = page.transactionId
|
||||
|
||||
runCatching { BmlMerchantTxnClient().announceBrowser(txnId) }
|
||||
val txnClient = BmlMerchantTxnClient()
|
||||
val browserId = runCatching { txnClient.announceBrowser(txnId) }.getOrNull()
|
||||
// Earlier attempts' declines are already in the history; only newer ones are ours
|
||||
val priorErrors = browserId?.let { id -> runCatching { txnClient.paymentErrors(txnId, id).size }.getOrNull() }
|
||||
|
||||
// 1-2. Credentials, then tokenise the card with Pomelo
|
||||
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
|
||||
@@ -154,6 +159,11 @@ class BmlMerchantCardPayClient {
|
||||
"TRANSACTION_CONFIRMED" -> return confirmed(txnId)
|
||||
"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)
|
||||
}
|
||||
return Result.Failure("Payment status unknown — check with the merchant before retrying")
|
||||
@@ -165,25 +175,37 @@ class BmlMerchantCardPayClient {
|
||||
/**
|
||||
* What the browser does once the payment page sees the confirmation: loads `<txn>?wait=1`,
|
||||
* which 302s to the merchant's `redirectUrl` (`…?transactionId=<id>&state=CONFIRMED&signature=…`)
|
||||
* and on to its receipt page. Follows the redirects and retries a couple of times; true when
|
||||
* the chain ended on a 2xx page.
|
||||
* 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 {
|
||||
client.newCall(Request.Builder()
|
||||
.url("$PAGE_ORIGIN/$txnId?wait=1")
|
||||
.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()
|
||||
).execute().use { it.isSuccessful }
|
||||
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. */
|
||||
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
|
||||
@@ -206,6 +228,8 @@ class BmlMerchantCardPayClient {
|
||||
}
|
||||
|
||||
// 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
|
||||
for (attempt in 0..1) {
|
||||
form = AcsForm.parse(html, acsUrl) ?: break
|
||||
@@ -213,9 +237,10 @@ class BmlMerchantCardPayClient {
|
||||
form.fields["otpValue"] = otp(retry)
|
||||
form.fields["formReqType"] = "SUBMIT"
|
||||
html = execText(form.toRequest())
|
||||
if (!html.contains("incorrect", true) && !html.contains("expired", true)) break
|
||||
if (!otpRejected(html)) break
|
||||
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
|
||||
// gateway's own auto-post back to BML) so the verdict is recorded before we poll.
|
||||
repeat(3) {
|
||||
@@ -226,6 +251,9 @@ class BmlMerchantCardPayClient {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun otpRejected(html: String) = html.contains("name=\"otpValue\"") &&
|
||||
(html.contains("incorrect", true) || html.contains("expired", true))
|
||||
|
||||
// ── next-action helpers ──────────────────────────────────────────────────
|
||||
|
||||
private fun nextAction(pk: String, body: JSONObject): JSONObject =
|
||||
@@ -329,5 +357,9 @@ class BmlMerchantCardPayClient {
|
||||
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,12 +113,36 @@ class BmlMerchantTxnClient {
|
||||
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
|
||||
}
|
||||
|
||||
/** The PATCHes the page sends on load: register this "browser" and clear any FX selection. */
|
||||
fun announceBrowser(transactionId: String) {
|
||||
patch(transactionId, JSONObject().put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}"))
|
||||
/**
|
||||
* The PATCHes the page sends on load: register this "browser" and clear any FX selection.
|
||||
* Returns the browser id, for [paymentErrors].
|
||||
*/
|
||||
fun announceBrowser(transactionId: String): String {
|
||||
val browserId = "${transactionId}_${System.currentTimeMillis()}"
|
||||
patch(transactionId, JSONObject().put("activeBrowserId", browserId))
|
||||
patch(transactionId, JSONObject().put("fx", "reset"))
|
||||
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 {
|
||||
val request = Request.Builder()
|
||||
.url("$API_BASE/transactions/$transactionId")
|
||||
|
||||
@@ -11,7 +11,7 @@ import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val SKIP_TYPES = setOf("Switch Profile", "Log in")
|
||||
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||
private const val MIB_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
|
||||
|
||||
class MibActivityHistoryClient {
|
||||
|
||||
@@ -35,7 +35,7 @@ class MibActivityHistoryClient {
|
||||
start: Int,
|
||||
end: Int
|
||||
): FetchResult {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; " +
|
||||
val cookieHeader = "mbmodel=AND-1.0; " +
|
||||
"xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; " +
|
||||
"mbnonce=${session.nonceGenerator}; " +
|
||||
|
||||
@@ -15,7 +15,7 @@ data class MibCardActionResult(
|
||||
|
||||
class MibCardsClient {
|
||||
|
||||
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||
private val BASE_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
|
||||
|
||||
private val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
|
||||
@@ -25,7 +25,7 @@ class MibCardsClient {
|
||||
.build()
|
||||
|
||||
private fun cookieHeader(session: MibSession) =
|
||||
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
|
||||
"mbmodel=AND-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
|
||||
"mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
|
||||
fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> {
|
||||
|
||||
@@ -1,135 +1,83 @@
|
||||
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 MibContactsClient {
|
||||
class MibContactsClient(private val flow: MibLoginFlow) {
|
||||
|
||||
private val TAG = "MibContactsClient"
|
||||
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||
fun fetchCategories(session: MibSession): List<MibBeneficiaryCategory> =
|
||||
fetchContactsAndCategories(session).second
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
fun fetchContacts(session: MibSession): List<MibBeneficiary> =
|
||||
fetchContactsAndCategories(session).first
|
||||
|
||||
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.withSessionHeaders(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/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> {
|
||||
/**
|
||||
* Pages through B41. Every page also carries the full category list.
|
||||
* start/end select rows by their 1-based `rnum`, both ends inclusive.
|
||||
*/
|
||||
fun fetchContactsAndCategories(session: MibSession): Pair<List<MibBeneficiary>, List<MibBeneficiaryCategory>> {
|
||||
val all = mutableListOf<MibBeneficiary>()
|
||||
var page = 1
|
||||
val pageSize = 100
|
||||
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 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)
|
||||
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", "")
|
||||
}
|
||||
all.addAll(contacts)
|
||||
if (all.size >= totalCount || contacts.isEmpty()) break
|
||||
page++
|
||||
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 all
|
||||
return Pair(all, categories)
|
||||
}
|
||||
|
||||
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)
|
||||
private fun parseCategories(resp: JSONObject): List<MibBeneficiaryCategory> {
|
||||
val arr = resp.optJSONArray("categories") ?: return emptyList()
|
||||
return (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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseContact(o: JSONObject): MibBeneficiary {
|
||||
val hash = o.optString("customerImgHash")
|
||||
return MibBeneficiary(
|
||||
benefNo = o.optString("benefNo"),
|
||||
benefName = o.optString("benefName"),
|
||||
benefNickName = o.optString("benefNickName")
|
||||
.ifBlank { o.optString("benefName") },
|
||||
benefAccount = o.optString("benefAccount"),
|
||||
benefType = o.optString("benefType"),
|
||||
bankColor = o.optString("bankColor", "#888888"),
|
||||
benefBankName = o.optString("benefBankName"),
|
||||
bankCode = o.optString("bankCode"),
|
||||
benefStatus = o.optString("benefStatus"),
|
||||
transferCyDesc = o.optString("transferCyDesc", "MVR"),
|
||||
customerImgHash = hash.takeIf { it.isNotBlank() && it != "null" },
|
||||
benefCategoryId = o.optString("benefCategoryID", "0")
|
||||
)
|
||||
}
|
||||
|
||||
/** Creates a beneficiary via B43. The server rejects nicknames longer than ~20 chars. */
|
||||
fun createContact(
|
||||
session: MibSession,
|
||||
benefType: String, // "I" = MIB internal, "L" = local/BML
|
||||
bankNo: Int, // 2 = MIB, 3 = BML
|
||||
benefAccount: String,
|
||||
benefName: String,
|
||||
@@ -138,82 +86,62 @@ class MibContactsClient {
|
||||
categoryId: String = "0",
|
||||
imageBase64: String = ""
|
||||
): Boolean {
|
||||
val body = FormBody.Builder()
|
||||
.add("benefType", benefType)
|
||||
.add("imageSet", if (imageBase64.isNotBlank()) "1" else "0")
|
||||
.add("benefAccount", benefAccount)
|
||||
.add("benefIban", "")
|
||||
.add("benefName", benefName)
|
||||
.add("nickName", nickName)
|
||||
.add("transferCy", transferCy)
|
||||
.add("transferCySwift", "840")
|
||||
.add("benefAddress", "")
|
||||
.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 }
|
||||
val (bankName, bankSwift) = bankInfo(bankNo)
|
||||
val resp = flow.request(session, "B43") {
|
||||
put("benefName", benefName)
|
||||
put("benefAccount", benefAccount)
|
||||
put("bankNo", bankNo.toString())
|
||||
put("nickName", nickName)
|
||||
put("transferCy", transferCy)
|
||||
put("categoryId", categoryId)
|
||||
if (imageBase64.isNotBlank()) put("image", imageBase64)
|
||||
put("bankName", bankName)
|
||||
put("bankSwift", bankSwift)
|
||||
}
|
||||
return resp.optBoolean("success")
|
||||
}
|
||||
|
||||
fun deleteContact(session: MibSession, benefNo: String): Boolean {
|
||||
val body = FormBody.Builder().add("benefNo", benefNo).build()
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_WV_URL/ajaxBeneficiary/deleteBeneficiary")
|
||||
.post(body)
|
||||
.withSessionHeaders(session)
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return@use false
|
||||
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
|
||||
/** Updates a beneficiary via B52. A blank [imageBase64] keeps the current photo. */
|
||||
fun updateContact(
|
||||
session: MibSession,
|
||||
benefNo: String,
|
||||
bankNo: Int, // 2 = MIB, 3 = BML
|
||||
benefAccount: String,
|
||||
benefName: String,
|
||||
nickName: String,
|
||||
transferCy: String = "462",
|
||||
categoryId: String = "0",
|
||||
imageBase64: String = ""
|
||||
): Boolean {
|
||||
val (bankName, bankSwift) = bankInfo(bankNo)
|
||||
val resp = flow.request(session, "B52") {
|
||||
put("benefName", benefName)
|
||||
put("benefAccount", benefAccount)
|
||||
put("bankNo", bankNo.toString())
|
||||
put("nickName", nickName)
|
||||
put("transferCy", transferCy)
|
||||
put("categoryId", categoryId)
|
||||
put("benefNo", benefNo)
|
||||
put("imageChanged", if (imageBase64.isNotBlank()) "1" else "0")
|
||||
if (imageBase64.isNotBlank()) put("image", imageBase64)
|
||||
put("bankName", bankName)
|
||||
put("bankSwift", bankSwift)
|
||||
}
|
||||
return resp.optBoolean("success")
|
||||
}
|
||||
|
||||
/** Bank name/SWIFT as listed by B49 for the bank IDs we support. */
|
||||
private fun bankInfo(bankNo: Int): Pair<String, String> = when (bankNo) {
|
||||
2 -> "Maldives Islamic Bank" to "MADVMVMV"
|
||||
else -> "Bank of Maldives PLC" to "MALBMVMV"
|
||||
}
|
||||
|
||||
fun deleteContact(session: MibSession, benefNo: String): Boolean =
|
||||
flow.request(session, "B48") { put("benefNo", benefNo) }.optBoolean("success")
|
||||
|
||||
fun fetchProfileImageBase64(session: MibSession, imageHash: String): String? {
|
||||
val body = FormBody.Builder()
|
||||
.add("imageHash", imageHash)
|
||||
.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
|
||||
}
|
||||
}
|
||||
val resp = flow.request(session, "B46") { put("imageHash", imageHash) }
|
||||
if (!resp.optBoolean("success")) return null
|
||||
return resp.optString("profileImage").takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import kotlin.math.ceil
|
||||
class MibFinancingClient {
|
||||
|
||||
private val TAG = "MibFinancingClient"
|
||||
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||
private val BASE_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
@@ -17,7 +17,7 @@ class MibFinancingClient {
|
||||
.build()
|
||||
|
||||
fun fetchFinancing(session: MibSession): List<MibFinanceDeal> {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; " +
|
||||
val cookieHeader = "mbmodel=AND-1.0; " +
|
||||
"xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; " +
|
||||
"mbnonce=${session.nonceGenerator}; " +
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
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"
|
||||
class MibHistoryClient(private val flow: MibLoginFlow) {
|
||||
|
||||
/**
|
||||
* Fetches transaction history for a single MIB account.
|
||||
* Fetches transaction history for a single MIB account via the encrypted `A84` route.
|
||||
* @param start 1-based index; use 1 for first page, 21 for second, etc. (pageSize=20)
|
||||
* @return Pair of (transactions, totalCount)
|
||||
*/
|
||||
@@ -33,58 +14,36 @@ class MibHistoryClient {
|
||||
start: Int = 1,
|
||||
pageSize: Int = 20
|
||||
): Pair<List<Transaction>, Int> {
|
||||
val body = FormBody.Builder()
|
||||
.add("accountNo", accountNo)
|
||||
.add("trxNo", "")
|
||||
.add("trxType", "0")
|
||||
.add("sortTrx", "date")
|
||||
.add("sortDir", "desc")
|
||||
.add("fromDate", "")
|
||||
.add("toDate", "")
|
||||
.add("start", start.toString())
|
||||
.add("end", (start + pageSize - 1).toString())
|
||||
.add("includeCount", "1")
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_WV_URL/ajaxAccounts/trxHistory")
|
||||
.post(body)
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
.header("Origin", BASE_WV_URL)
|
||||
.header("Referer", "$BASE_WV_URL//accountDetails?trxh=1&dashurl=1&accountNo=$accountNo")
|
||||
.build()
|
||||
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (response.code in 500..599) throw BankServerException("MIB")
|
||||
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
|
||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
|
||||
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
val total = json.optString("total_count", "0").toIntOrNull() ?: 0
|
||||
val data = json.optJSONArray("data") ?: return Pair(emptyList(), total)
|
||||
val transactions = (0 until data.length()).map { i ->
|
||||
val item = data.getJSONObject(i)
|
||||
Transaction(
|
||||
id = item.optString("trxNumber"),
|
||||
date = item.optString("trxDate"),
|
||||
description = item.optString("descr1").trim(),
|
||||
amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0,
|
||||
currency = item.optString("curCodeDesc"),
|
||||
counterpartyName = item.optString("benefName").takeIf {
|
||||
it.isNotBlank() && it != "null"
|
||||
},
|
||||
reference = item.optString("trxNumber2").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNo,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "MIB"
|
||||
)
|
||||
}
|
||||
Pair(transactions, total)
|
||||
// A84 uses a 0-based inclusive range: first page is start=0, end=19
|
||||
val json = flow.request(session, "A84") {
|
||||
put("accountNo", accountNo)
|
||||
put("start", start - 1)
|
||||
put("end", start - 1 + pageSize - 1)
|
||||
put("includeCount", true)
|
||||
put("trxType", 0)
|
||||
put("sortTrx", "date")
|
||||
put("sortDir", "desc")
|
||||
}
|
||||
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
val total = json.optString("total_count", "0").toIntOrNull() ?: 0
|
||||
val data = json.optJSONArray("data") ?: return Pair(emptyList(), total)
|
||||
val transactions = (0 until data.length()).map { i ->
|
||||
val item = data.getJSONObject(i)
|
||||
Transaction(
|
||||
id = item.optString("trxNumber"),
|
||||
date = item.optString("trxDate"),
|
||||
description = item.optString("descr1").trim(),
|
||||
amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0,
|
||||
currency = item.optString("curCodeDesc"),
|
||||
counterpartyName = item.optString("benefName").takeIf {
|
||||
it.isNotBlank() && it != "null"
|
||||
},
|
||||
reference = item.optString("trxNumber2").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNo,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "MIB"
|
||||
)
|
||||
}
|
||||
return Pair(transactions, total)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
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.atomic.AtomicBoolean
|
||||
import kotlin.math.abs
|
||||
@@ -16,7 +20,7 @@ class SessionExpiredException : Exception("MIB session expired")
|
||||
|
||||
class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
|
||||
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
|
||||
private val BASE_URL = "https://faisamobilex-smvc-v2.mib.com.mv/index/"
|
||||
|
||||
/** The active session after a successful login, usable for subsequent WebView requests. */
|
||||
var lastSession: MibSession? = null
|
||||
@@ -26,6 +30,12 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
var lastProfiles: List<MibProfile> = emptyList()
|
||||
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. */
|
||||
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
|
||||
|
||||
@@ -42,8 +52,9 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.addNetworkInterceptor { chain ->
|
||||
val req = chain.request().newBuilder()
|
||||
.header("User-Agent", "android/1.0")
|
||||
.header("User-Agent", "android/2.0")
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Request-ID", generateRequestId())
|
||||
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
||||
.build()
|
||||
chain.proceed(req)
|
||||
@@ -144,6 +155,8 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
}
|
||||
|
||||
val profiles = parseProfiles(loginResp)
|
||||
lastFullName = loginResp.optString("fullName").takeIf { it != "null" } ?: ""
|
||||
lastEmail = loginResp.optString("email").takeIf { it != "null" } ?: ""
|
||||
lastSession = session2
|
||||
lastProfiles = profiles // keep ALL profiles so settings can show them all
|
||||
|
||||
@@ -300,6 +313,13 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
put("xxid", session.xxid)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* subsequent WebView requests run under that profile.
|
||||
@@ -424,6 +444,16 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
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 {
|
||||
/** Returns SHA-256(password) as uppercase hex. Store this instead of the raw password. */
|
||||
fun hashPassword(password: String): String =
|
||||
|
||||
@@ -35,7 +35,8 @@ data class MibTransferResult(
|
||||
val success: Boolean,
|
||||
val trxId: String = "",
|
||||
val date: String = "",
|
||||
val errorMessage: String = ""
|
||||
val errorMessage: String = "",
|
||||
val reasonCode: String = ""
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,50 +1,87 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class MibPersonalProfile(
|
||||
val fullName: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val enrolled: String
|
||||
val idCard: String
|
||||
)
|
||||
|
||||
class MibProfileClient {
|
||||
class MibProfileClient(private val flow: MibLoginFlow) {
|
||||
|
||||
private val BASE_WV_URL = "https://faisamobilex-wv-v2.mib.com.mv"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.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? {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
val personal = flow.lastProfiles.firstOrNull { it.profileType == "0" }
|
||||
if (personal != null) flow.switchProfile(session, personal)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
|
||||
.get()
|
||||
.header("Cookie", cookieHeader)
|
||||
.build()
|
||||
return try {
|
||||
val resp = client.newCall(request).execute()
|
||||
val html = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
fun scrape(label: String): String {
|
||||
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
||||
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
|
||||
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
|
||||
}
|
||||
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
||||
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
|
||||
MibPersonalProfile(
|
||||
fullName = fullName,
|
||||
username = scrape("Username:"),
|
||||
email = scrape("Email:"),
|
||||
mobile = scrape("Mobile no:"),
|
||||
enrolled = scrape("Enrolled:")
|
||||
.url("$BASE_WV_URL/ajaxAlias/getAliasTypes")
|
||||
.post(FormBody.Builder().build())
|
||||
.header("Cookie", "mbmodel=AND-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}")
|
||||
.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"
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
.header("Origin", BASE_WV_URL)
|
||||
.header("Referer", "$BASE_WV_URL/Alias/Alias?dashurl=1")
|
||||
.build()
|
||||
|
||||
val json = client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: return null
|
||||
try { JSONObject(body) } catch (_: Exception) { return null }
|
||||
}
|
||||
if (!json.optBoolean("success")) return null
|
||||
|
||||
// Alias type ids: 0 = mobile number, 1 = email, 3 = National ID
|
||||
val aliases = json.optJSONArray("data")
|
||||
fun alias(id: Int): String {
|
||||
if (aliases == null) return ""
|
||||
for (i in 0 until aliases.length()) {
|
||||
val o = aliases.getJSONObject(i)
|
||||
if (o.optInt("id", -1) == id) return o.optString("suggestedAlias").takeIf { it != "null" } ?: ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return MibPersonalProfile(
|
||||
fullName = flow.lastFullName.ifBlank { personal?.name ?: "" },
|
||||
email = alias(1).ifBlank { flow.lastEmail },
|
||||
mobile = alias(0),
|
||||
idCard = alias(3)
|
||||
)
|
||||
}
|
||||
|
||||
/** Fetches and caches the user's details for [loginId] unless a v2 fetch is already cached. */
|
||||
fun cacheIfMissing(store: CredentialStore, loginId: String, session: MibSession) {
|
||||
if (store.loadMibUserProfile(loginId)?.idCard != null) return
|
||||
val p = fetchPersonalProfile(session) ?: return
|
||||
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
|
||||
fullName = p.fullName,
|
||||
username = loginId,
|
||||
email = p.email,
|
||||
mobile = p.mobile,
|
||||
enrolled = "",
|
||||
idCard = p.idCard
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,12 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
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 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")
|
||||
class MibTransferClient(private val flow: MibLoginFlow) {
|
||||
|
||||
/**
|
||||
* Execute a transfer. bankNo=2 → MIB internal (/transferInternal), bankNo=3 → local/BML (/transferLocal).
|
||||
* currencyCode: "462"=MVR, "840"=USD. purpose uses "-" if blank.
|
||||
* Execute a transfer. bankNo=2 → MIB internal (T50), bankNo=3 → local/BML via Favara (T51).
|
||||
* currencyCode: "462"=MVR, "840"=USD.
|
||||
*/
|
||||
fun transfer(
|
||||
session: MibSession,
|
||||
@@ -49,157 +19,79 @@ class MibTransferClient {
|
||||
purpose: String,
|
||||
otp: String
|
||||
): MibTransferResult {
|
||||
val endpoint = if (bankNo == 2) "transferInternal" else "transferLocal"
|
||||
val body = FormBody.Builder()
|
||||
.add("benefName", benefName)
|
||||
.add("benefNo", "0")
|
||||
.add("fromAccountNo", fromAccount)
|
||||
.add("benefAccountNo", toAccount)
|
||||
.add("transferCy", currencyCode)
|
||||
.add("benefCurrencyCode", currencyCode)
|
||||
.add("amount", amount)
|
||||
.add("bankNo", bankNo.toString())
|
||||
.add("purpose", purpose.ifBlank { "-" })
|
||||
.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") ?: ""
|
||||
)
|
||||
}
|
||||
val json = flow.request(session, if (bankNo == 2) "T50" else "T51") {
|
||||
put("benefName", benefName)
|
||||
put("benefNo", "0")
|
||||
put("fromAccountNo", fromAccount)
|
||||
put("benefAccountNo", toAccount)
|
||||
put("transferCy", currencyCode)
|
||||
put("benefCurrencyCode", currencyCode)
|
||||
put("amount", amount)
|
||||
put("bankNo", bankNo.toString())
|
||||
put("purpose", purpose)
|
||||
put("otp", otp)
|
||||
put("otpType", "3")
|
||||
}
|
||||
if (!json.optBoolean("success")) {
|
||||
return MibTransferResult(
|
||||
success = false,
|
||||
errorMessage = json.optString("reasonText").ifBlank { "Transfer failed" },
|
||||
reasonCode = json.optString("reasonCode")
|
||||
)
|
||||
}
|
||||
val data = json.optJSONArray("data")?.optJSONObject(0)
|
||||
return MibTransferResult(
|
||||
success = true,
|
||||
trxId = data?.optString("trxId") ?: "",
|
||||
date = data?.optString("date") ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes the lookup to the correct endpoint based on input format:
|
||||
* Resolves a destination via B40, which takes any supported input:
|
||||
*
|
||||
* - 15 digits starting with 7 → IPS account lookup (BML / local bank)
|
||||
* - 17 digits starting with 9 → MIB internal account name lookup
|
||||
* - 13 digits starting with 7 → BML / local bank account
|
||||
* - 17 digits starting with 9 → MIB account
|
||||
* - 7 digits starting with 7 or 9,
|
||||
* A + 6 digits,
|
||||
* or email address → Favara alias lookup
|
||||
* or email address → Favara alias
|
||||
*
|
||||
* The format check only avoids sending inputs B40 can't resolve.
|
||||
*/
|
||||
fun lookup(session: MibSession, input: String): MibIpsAccountInfo {
|
||||
val trimmed = input.trim()
|
||||
return when {
|
||||
trimmed.matches(Regex("^7\\d{12}$")) -> lookupIpsAccount(session, trimmed)
|
||||
trimmed.matches(Regex("^9\\d{16}$")) -> lookupAccountName(session, trimmed)
|
||||
trimmed.matches(Regex("^[79]\\d{6}$")) -> lookupAlias(session, trimmed)
|
||||
trimmed.matches(Regex("^[Aa]\\d{6}$")) -> lookupAlias(session, trimmed)
|
||||
trimmed.contains("@") -> lookupAlias(session, trimmed)
|
||||
else -> throw MibLookupException("Unrecognized account number format")
|
||||
}
|
||||
}
|
||||
// B40 only resolves Favara IDs with an uppercase "A" ("a239225" fails, "A239225" works)
|
||||
val trimmed = input.trim().let { if (it.matches(Regex("^a\\d{6}$"))) it.uppercase() else it }
|
||||
val supported = trimmed.matches(Regex("^7\\d{12}$")) ||
|
||||
trimmed.matches(Regex("^9\\d{16}$")) ||
|
||||
trimmed.matches(Regex("^[79]\\d{6}$")) ||
|
||||
trimmed.matches(Regex("^[Aa]\\d{6}$")) ||
|
||||
trimmed.contains("@")
|
||||
if (!supported) throw MibLookupException("Unrecognized account number format")
|
||||
|
||||
/** BML / local bank: 15-digit account starting with 7. */
|
||||
private fun lookupIpsAccount(session: MibSession, accountNumber: String): MibIpsAccountInfo {
|
||||
val body = FormBody.Builder().add("benefAccount", accountNumber).build()
|
||||
val request = Request.Builder()
|
||||
.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 json = flow.request(session, "B40") { put("search", trimmed) }
|
||||
val match = json.optJSONArray("data")?.optJSONObject(0)
|
||||
if (!json.optBoolean("success") || match == null) {
|
||||
throw MibLookupException(
|
||||
json.optString("reasonText").trim().ifBlank { "Account not found" }
|
||||
)
|
||||
}
|
||||
val accountNumber = match.optString("benefAccount")
|
||||
// B40 reports MIB accounts either as "MIB" or by BIC; callers expect the BIC
|
||||
val bankId = match.optString("bankCode").let { if (it == "MIB") "MADVMVMV" else it }
|
||||
val currency = match.optString("transferCyDesc").takeIf { it == "MVR" || it == "USD" }
|
||||
?: if (bankId == "MADVMVMV") "" else "MVR" // cross-bank lookups only resolve MVR accounts
|
||||
return MibIpsAccountInfo(
|
||||
accountName = match.optString("benefName").trim(),
|
||||
accountNumber = accountNumber,
|
||||
bankId = bankId,
|
||||
currency = currency
|
||||
)
|
||||
}
|
||||
|
||||
/** MIB internal: 17-digit account starting with 9. */
|
||||
private fun lookupAccountName(session: MibSession, accountNumber: String): MibIpsAccountInfo {
|
||||
val body = FormBody.Builder().add("accountNo", accountNumber).build()
|
||||
val request = Request.Builder()
|
||||
.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")
|
||||
)
|
||||
}
|
||||
companion object {
|
||||
/** reasonCode for a wrong OTP, e.g. one from the previous window. */
|
||||
const val REASON_INVALID_OTP = "213"
|
||||
/** reasonCode for an OTP the server has already accepted once. */
|
||||
const val REASON_OTP_ALREADY_USED = "707"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package sh.sar.basedbank.api.ooredoo
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Ooredoo prepaid recharge (Raastas) and bill payment through the ooredoo.mv Quick Pay pages,
|
||||
* paid by card on BML's merchant gateway. Ooredoo only creates the order; the money moves on the
|
||||
* BML Merchant Services transaction it hands back, which is paid like any card-only BML merchant
|
||||
* link. See `docs/ooredooapi/02-raastas.md` and `docs/ooredooapi/03-bill-pay.md`.
|
||||
*
|
||||
* Every call blocks, so run it on an IO thread.
|
||||
*/
|
||||
class OoredooPaymentClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Creates the recharge order for [number] (7 digits) and the BML transaction paying for it.
|
||||
* [amount] is what the number is credited, in whole MVR; the card pays [charged], which is
|
||||
* that plus GST. Returns the 24-hex BML transaction id. Throws with Ooredoo's wording when
|
||||
* the order is refused.
|
||||
*/
|
||||
fun createRaastasTransaction(number: String, amount: Int, charged: BigDecimal): String =
|
||||
createOrder(number, charged, amount.toString(), transType = "recharge", serviceType = "prepaid")
|
||||
|
||||
/**
|
||||
* Creates the bill payment order for postpaid [number] (7 digits) and the BML transaction
|
||||
* paying for it. [amount] is MVR, up to 2 decimal places; no GST. Returns the 24-hex BML
|
||||
* transaction id. Throws with Ooredoo's wording when the order is refused.
|
||||
*/
|
||||
fun createBillPayTransaction(number: String, amount: BigDecimal): String =
|
||||
createOrder(number, amount, money(amount), transType = "billpay", serviceType = "Mobile")
|
||||
|
||||
/** `POST PaymentGateway/bml` — one call makes the order and its BML transaction. */
|
||||
private fun createOrder(
|
||||
number: String, charged: BigDecimal, amountWithoutGst: String, transType: String, serviceType: String,
|
||||
): String {
|
||||
val msisdn = "960$number"
|
||||
val body = JSONObject()
|
||||
.put("msisdn", msisdn)
|
||||
.put("purchaseAmount", money(charged))
|
||||
.put("amountWithoutGst", amountWithoutGst)
|
||||
.put("receiverMsisdn", msisdn)
|
||||
.put("transType", transType)
|
||||
.put("serviceType", serviceType)
|
||||
.put("serviceTypeDisplayName", "Mobile")
|
||||
val text = client.newCall(
|
||||
Request.Builder().url("$BASE/ooredoo-prod/PaymentGateway/bml")
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "application/json")
|
||||
.header("Origin", BASE)
|
||||
.build()
|
||||
).execute().use { r ->
|
||||
if (r.code in 500..599) throw BankServerException("Ooredoo")
|
||||
r.body?.string().orEmpty()
|
||||
}
|
||||
val obj = try { JSONObject(text) } catch (_: Exception) {
|
||||
throw Exception("Unexpected response from Ooredoo")
|
||||
}
|
||||
if (obj.optString("status") != "OK" || obj.optString("code") != "2000") {
|
||||
throw Exception(obj.optString("msg").ifBlank { "Ooredoo refused the payment" })
|
||||
}
|
||||
val data = obj.optJSONObject("data") ?: throw Exception("Ooredoo didn't create the order")
|
||||
return TXN_URL.find(data.optString("bmlUrl"))?.groupValues?.get(1)
|
||||
?: throw Exception("BML didn't create the transaction")
|
||||
}
|
||||
|
||||
private fun money(amount: BigDecimal) =
|
||||
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
|
||||
|
||||
companion object {
|
||||
private const val BASE = "https://www.ooredoo.mv"
|
||||
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
val sess = app.anyMibSession() ?: return
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||
val base64 = app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||
ContactImageCache.save(requireContext(), hash, bitmap)
|
||||
|
||||
@@ -18,11 +18,13 @@ import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -32,6 +34,7 @@ import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountValidation
|
||||
import sh.sar.basedbank.api.bml.BmlContactsClient
|
||||
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.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
@@ -67,6 +70,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
private var selectedCategoryId: String = "0"
|
||||
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? ->
|
||||
uri ?: return@registerForActivityResult
|
||||
encodeImage(uri)
|
||||
@@ -90,7 +96,49 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
if (selectedDest?.isBml == false) setupCategoryDropdown()
|
||||
}
|
||||
|
||||
applyPrefillArgs()
|
||||
if (!applyEditArgs()) applyPrefillArgs()
|
||||
}
|
||||
|
||||
/** Switches the sheet into edit mode when opened via [forEdit]. Returns true if it did. */
|
||||
private fun applyEditArgs(): Boolean {
|
||||
val args = arguments ?: return false
|
||||
val benefNo = args.getString(ARG_EDIT_BENEF_NO) ?: return false
|
||||
val profileId = args.getString(ARG_EDIT_PROFILE_ID) ?: return false
|
||||
val contact = viewModel.contacts.value?.firstOrNull { it.benefNo == benefNo && it.profileId == profileId }
|
||||
val dest = destinations.firstOrNull { !it.isBml && it.mibProfile?.profileId == profileId }
|
||||
if (contact == null || dest == null) {
|
||||
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
|
||||
dismiss()
|
||||
return true
|
||||
}
|
||||
editContact = contact
|
||||
selectedCategoryId = contact.benefCategoryId
|
||||
categories = viewModel.contactCategories.value?.filter { it.id != "BML" } ?: emptyList()
|
||||
|
||||
binding.tvTitle.setText(R.string.contact_edit_title)
|
||||
selectedDest = dest
|
||||
binding.actvDestination.setText(dest.label, false)
|
||||
binding.tilDestination.isEnabled = false
|
||||
binding.actvGroup.setText(
|
||||
categories.firstOrNull { it.id == contact.benefCategoryId }?.categoryName
|
||||
?: getString(R.string.contact_no_group), false
|
||||
)
|
||||
updateMibOnlyVisibility()
|
||||
|
||||
mibLookupAccount = contact.benefAccount
|
||||
binding.tvResultName.text = contact.benefName
|
||||
binding.tvResultBank.text = "${contact.benefBankName} · ${contact.benefAccount}"
|
||||
val photo = contact.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||
binding.ivResultAvatar.setImageBitmap(photo ?: makeInitialsBitmap(contact.benefName, contact.bankColor))
|
||||
if (photo != null) binding.ivContactImage.setImageBitmap(photo)
|
||||
binding.cardResult.visibility = View.VISIBLE
|
||||
binding.tilAccount.visibility = View.GONE
|
||||
binding.btnClearResult.visibility = View.GONE
|
||||
|
||||
binding.etAlias.setText(contact.benefNickName)
|
||||
binding.etCurrency.setText(contact.transferCyDesc)
|
||||
updateSaveState()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun applyPrefillArgs() {
|
||||
@@ -201,6 +249,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
binding.layoutImage.visibility = if (isMib) View.VISIBLE else View.GONE
|
||||
binding.tilGroup.visibility = if (isMib) View.VISIBLE else View.GONE
|
||||
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() {
|
||||
@@ -307,8 +367,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
|
||||
val mibSess = app.anyMibSession() ?: return null
|
||||
val mibFlow = app.anyMibFlow() ?: return null
|
||||
return try {
|
||||
val info = MibTransferClient().lookup(mibSess, input)
|
||||
val info = MibTransferClient(mibFlow).lookup(mibSess, input)
|
||||
BmlAccountValidation(
|
||||
trnType = "DOT",
|
||||
validationType = "MIB",
|
||||
@@ -328,8 +389,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
val profile = dest.mibProfile ?: return null
|
||||
|
||||
val mibResult = try {
|
||||
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
|
||||
val info = MibTransferClient().lookup(mibSess, input)
|
||||
val flow = app.mibFlowFor(loginId)
|
||||
flow.switchProfile(mibSess, profile)
|
||||
val info = MibTransferClient(flow).lookup(mibSess, input)
|
||||
BmlAccountValidation(
|
||||
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
|
||||
validationType = "MIB",
|
||||
@@ -398,7 +460,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
binding.etCurrency.setText(validation.currency)
|
||||
|
||||
binding.btnClearResult.setOnClickListener { clearLookupResult() }
|
||||
binding.btnSave.isEnabled = true
|
||||
updateSaveState()
|
||||
}
|
||||
|
||||
private fun clearLookupResult() {
|
||||
@@ -438,6 +500,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private fun setupSaveButton() {
|
||||
binding.btnSave.setOnClickListener { performSave() }
|
||||
binding.etAlias.addTextChangedListener(afterTextChanged = { updateSaveState() })
|
||||
}
|
||||
|
||||
private fun performSave() {
|
||||
@@ -447,6 +510,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
binding.tilAlias.error = "Alias is required"
|
||||
return
|
||||
}
|
||||
if (!dest.isBml && alias.length > MIB_MAX_ALIAS) {
|
||||
updateSaveState()
|
||||
return
|
||||
}
|
||||
binding.tilAlias.error = null
|
||||
|
||||
binding.btnSave.isEnabled = false
|
||||
@@ -454,11 +521,16 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
if (dest.isBml) saveToBml(alias) else saveToMib(alias)
|
||||
when {
|
||||
editContact != null -> updateMib(alias)
|
||||
dest.isBml -> saveToBml(alias)
|
||||
else -> saveToMib(alias)
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
Toast.makeText(requireContext(), R.string.contact_saved, Toast.LENGTH_SHORT).show()
|
||||
reloadSavedProfileContacts(dest)
|
||||
// Same reload as pull-to-refresh on the contacts page
|
||||
(activity as? HomeActivity)?.loadAllContacts(showIndicator = true)
|
||||
dismiss()
|
||||
} else {
|
||||
binding.btnSave.isEnabled = true
|
||||
@@ -495,15 +567,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
val transferCy = if (currency.equals("USD", ignoreCase = true)) "840" else "462"
|
||||
|
||||
val isMibInternal = account.matches(Regex("^9\\d{16}$"))
|
||||
val benefType = if (isMibInternal) "I" else "L"
|
||||
val bankNo = if (isMibInternal) 2 else 3
|
||||
val name = bmlLookup?.name ?: ""
|
||||
|
||||
return try {
|
||||
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
|
||||
MibContactsClient().createContact(
|
||||
MibContactsClient(app.mibFlowFor(loginId)).createContact(
|
||||
session = mibSess,
|
||||
benefType = benefType,
|
||||
bankNo = bankNo,
|
||||
benefAccount = account,
|
||||
benefName = name,
|
||||
@@ -515,6 +585,33 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
} 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 {
|
||||
val sizePx = (resources.displayMetrics.density * 40).toInt()
|
||||
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
|
||||
@@ -532,35 +629,6 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
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() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
@@ -570,10 +638,23 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
// BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts
|
||||
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_ACCOUNT_NUMBER = "account_number"
|
||||
private const val ARG_RECIPIENT_NAME = "recipient_name"
|
||||
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(
|
||||
bmlProfileId: String? = null,
|
||||
|
||||
@@ -77,7 +77,15 @@ class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
|
||||
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
|
||||
}
|
||||
binding.btnEdit.setOnClickListener {
|
||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
// Only contacts saved in MIB can be edited for now
|
||||
val isMibContact = contact.network == TransferNetwork.MIB || contact.network == TransferNetwork.LOCAL
|
||||
val parent = contactsFragment
|
||||
if (isMibContact && parent != null) {
|
||||
dismiss()
|
||||
AddContactSheetFragment.forEdit(contact).show(parent.childFragmentManager, "edit_contact")
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
binding.btnDelete.setOnClickListener {
|
||||
val parent = contactsFragment
|
||||
|
||||
@@ -356,7 +356,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
val base64 = if (hash in profileImageHashes) {
|
||||
app.anyMibFlow()?.fetchProfileImage(sess, hash)
|
||||
} else {
|
||||
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
||||
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash)
|
||||
} ?: return@launch
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||
|
||||
@@ -143,6 +143,8 @@ class ContactsFragment : Fragment() {
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
// triggerRefresh() only covers accounts; reload contacts from the banks too
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
@@ -257,7 +259,7 @@ class ContactsFragment : Fragment() {
|
||||
return
|
||||
}
|
||||
val sess = session ?: return
|
||||
val client = MibContactsClient()
|
||||
val client = MibContactsClient(app.anyMibFlow() ?: return)
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||
|
||||
@@ -30,8 +30,10 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -61,6 +63,7 @@ import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibCardsClient
|
||||
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.MibSession
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
@@ -428,10 +431,10 @@ class HomeActivity : AppCompatActivity() {
|
||||
for (session in sessions) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||
val cookieHeader = "mbmodel=AND-1.0; xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
val request = Request.Builder()
|
||||
.url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive")
|
||||
.url("https://faisamobilex-wv-v2.mib.com.mv/aProfile/keepAlive")
|
||||
.post(ByteArray(0).toRequestBody())
|
||||
.header("Cookie", cookieHeader)
|
||||
.header("User-Agent", "okhttp/4.11.0")
|
||||
@@ -819,6 +822,8 @@ fun applyNavLabelVisibility() {
|
||||
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||
app.mibLoginFlows[loginId] = flow
|
||||
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
|
||||
} catch (e: java.io.IOException) {
|
||||
refreshErrors.add("NO_INTERNET")
|
||||
@@ -1210,10 +1215,10 @@ fun applyNavLabelVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshBmlContacts(app: BasedBankApp) {
|
||||
if (app.bmlSessions.isEmpty()) return
|
||||
private fun refreshBmlContacts(app: BasedBankApp): Job? {
|
||||
if (app.bmlSessions.isEmpty()) return null
|
||||
val store = CredentialStore(this)
|
||||
lifecycleScope.launch {
|
||||
return lifecycleScope.launch {
|
||||
try {
|
||||
val allBmlContacts = withContext(Dispatchers.IO) {
|
||||
store.getBmlLoginIds().flatMap { loginId ->
|
||||
@@ -1233,7 +1238,8 @@ fun applyNavLabelVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAllContacts() {
|
||||
/** Loads contacts from cache, then refreshes them from every bank. [showIndicator] shows the top refresh bar until done. */
|
||||
fun loadAllContacts(showIndicator: Boolean = false) {
|
||||
val app = application as BasedBankApp
|
||||
val store = sh.sar.basedbank.util.CredentialStore(this)
|
||||
// Populate ViewModel from cache immediately if empty
|
||||
@@ -1249,16 +1255,24 @@ fun applyNavLabelVisibility() {
|
||||
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
|
||||
}
|
||||
// Refresh all banks in background
|
||||
val jobs = mutableListOf<Job>()
|
||||
for ((loginId, session) in app.mibSessions) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||
refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))?.let { jobs.add(it) }
|
||||
}
|
||||
refreshBmlContacts(app)?.let { jobs.add(it) }
|
||||
for ((_, session) in app.fahipaySessions) jobs.add(refreshFahipayContacts(session))
|
||||
if (showIndicator && jobs.isNotEmpty()) {
|
||||
setRefreshing(true)
|
||||
lifecycleScope.launch {
|
||||
jobs.joinAll()
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
refreshBmlContacts(app)
|
||||
for ((_, session) in app.fahipaySessions) refreshFahipayContacts(session)
|
||||
}
|
||||
|
||||
private fun refreshFahipayContacts(session: FahipaySession) {
|
||||
lifecycleScope.launch {
|
||||
private fun refreshFahipayContacts(session: FahipaySession): Job {
|
||||
return lifecycleScope.launch {
|
||||
try {
|
||||
val groups = withContext(Dispatchers.IO) {
|
||||
FahipayContactsClient().fetchContacts(session)
|
||||
@@ -1288,11 +1302,11 @@ fun applyNavLabelVisibility() {
|
||||
return result
|
||||
}
|
||||
|
||||
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) {
|
||||
if (profiles.isEmpty()) return
|
||||
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>): Job? {
|
||||
if (profiles.isEmpty()) return null
|
||||
val flow = (application as BasedBankApp).mibFlowFor(loginId)
|
||||
val contactsClient = MibContactsClient()
|
||||
lifecycleScope.launch {
|
||||
val contactsClient = MibContactsClient(flow)
|
||||
return lifecycleScope.launch {
|
||||
try {
|
||||
val (allContacts, allCategories) = withContext(Dispatchers.IO) {
|
||||
val seenContacts = mutableSetOf<String>()
|
||||
@@ -1302,10 +1316,12 @@ fun applyNavLabelVisibility() {
|
||||
for (profile in profiles) {
|
||||
try {
|
||||
flow.switchProfile(session, profile)
|
||||
for (cat in contactsClient.fetchCategories(session)) {
|
||||
val (profileContacts, profileCategories) =
|
||||
contactsClient.fetchContactsAndCategories(session)
|
||||
for (cat in profileCategories) {
|
||||
if (seenCategories.add(cat.id)) categories.add(cat)
|
||||
}
|
||||
for (contact in contactsClient.fetchContacts(session)) {
|
||||
for (contact in profileContacts) {
|
||||
if (seenContacts.add(contact.benefNo))
|
||||
contacts.add(contact.copy(profileId = profile.profileId))
|
||||
}
|
||||
|
||||
@@ -353,15 +353,16 @@ class OtpFragment : Fragment() {
|
||||
val session = app.mibSessions[loginId] ?: continue
|
||||
val flow = app.mibFlowFor(loginId)
|
||||
val profile = withContext(Dispatchers.IO) {
|
||||
try { MibProfileClient().fetchPersonalProfile(session) } catch (_: Exception) { null }
|
||||
try { MibProfileClient(flow).fetchPersonalProfile(session) } catch (_: Exception) { null }
|
||||
}
|
||||
if (profile != null) {
|
||||
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
|
||||
fullName = profile.fullName,
|
||||
username = profile.username,
|
||||
username = loginId,
|
||||
email = profile.email,
|
||||
mobile = profile.mobile,
|
||||
enrolled = profile.enrolled
|
||||
enrolled = "",
|
||||
idCard = profile.idCard
|
||||
))
|
||||
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
|
||||
|
||||
@@ -668,10 +668,13 @@ class SettingsLoginsFragment : Fragment() {
|
||||
scroll.addView(container)
|
||||
|
||||
// Account info lines
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
listOfNotNull(
|
||||
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
|
||||
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" }
|
||||
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
|
||||
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
|
||||
profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
|
||||
).forEach { line ->
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = line
|
||||
|
||||
@@ -52,6 +52,7 @@ import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemTransferTypeBinding
|
||||
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
|
||||
@@ -407,7 +408,7 @@ class TransferFragment : Fragment() {
|
||||
return@setFragmentResultListener
|
||||
}
|
||||
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
||||
if (applyFahipayContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) {
|
||||
if (applyServiceContact(accountNumber, bundle.getString(ContactPickerSheetFragment.KEY_CATEGORY), label)) {
|
||||
return@setFragmentResultListener
|
||||
}
|
||||
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
|
||||
@@ -453,7 +454,7 @@ class TransferFragment : Fragment() {
|
||||
// Pre-select contact if navigated from contacts page or QR scan
|
||||
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
||||
val name = arguments?.getString(ARG_NAME) ?: account
|
||||
if (applyFahipayContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let
|
||||
if (applyServiceContact(account, arguments?.getString(ARG_CONTACT_CATEGORY), name)) return@let
|
||||
prefillToDirectly(
|
||||
accountNumber = account,
|
||||
displayName = name,
|
||||
@@ -1125,7 +1126,7 @@ class TransferFragment : Fragment() {
|
||||
|
||||
binding.etTo.setOnItemClickListener { _, _, position, _ ->
|
||||
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
|
||||
if (applyFahipayContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
|
||||
if (applyServiceContact(contact.benefAccount, contact.benefCategoryId, contact.benefNickName)) {
|
||||
return@setOnItemClickListener
|
||||
}
|
||||
prefillToDirectly(
|
||||
@@ -1141,15 +1142,27 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved Fahipay favourite: its list ([categoryId]) already says which service pays it, so
|
||||
* that service is applied as the only transfer type, with no carrier lookup. That switches
|
||||
* the source to the Fahipay wallet and brings in the service's amount rules, the same as a
|
||||
* searched number. Returns false, doing nothing, when [categoryId] isn't a Fahipay list.
|
||||
* A saved Fahipay favourite, or a recent paid with a Fahipay or card service: its category
|
||||
* ([categoryId]) already says which service pays it, so that service is applied as the only
|
||||
* transfer type, with no carrier lookup. That switches the source to the Fahipay wallet or
|
||||
* the default card and brings in the service's amount rules, the same as a searched number.
|
||||
* Returns false, doing nothing, when [categoryId] isn't one of those.
|
||||
*/
|
||||
private fun applyFahipayContact(number: String, categoryId: String?, name: String): Boolean {
|
||||
val service = FahipayService.fromContactCategory(categoryId) ?: return false
|
||||
private fun applyServiceContact(number: String, categoryId: String?, name: String): Boolean {
|
||||
val ownerName = name.takeIf { it.isNotBlank() && it != number }
|
||||
val type = FahipayService.fromContactCategory(categoryId)?.let { TransferType.Fahipay(it, ownerName) }
|
||||
?: CardPayoutService.fromContactCategory(categoryId)?.let { service ->
|
||||
// The cards that could pay it then may not be payable now
|
||||
val cards = cardPayoutHandler().payableCards()
|
||||
if (cards.isEmpty()) {
|
||||
Toast.makeText(requireContext(), R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||
return true
|
||||
}
|
||||
TransferType.Card(service, ownerName, cards)
|
||||
}
|
||||
?: return false
|
||||
clearRecipient()
|
||||
offerTransferTypes(number, listOf(TransferType.Fahipay(service, name.takeIf { it.isNotBlank() && it != number })))
|
||||
offerTransferTypes(number, listOf(type))
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -302,7 +302,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||
for (state in states) {
|
||||
try {
|
||||
val (list, total) = MibHistoryClient().fetchHistory(
|
||||
val (list, total) = MibHistoryClient(app.mibFlowFor(loginId)).fetchHistory(
|
||||
session = session,
|
||||
accountNo = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
@@ -382,7 +382,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
val sess = app.anyMibSession() ?: return
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||
val base64 = app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||
ContactImageCache.save(requireContext(), hash, bitmap)
|
||||
|
||||
@@ -250,7 +250,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
val base64 = if (isProfile) {
|
||||
app.anyMibFlow()?.fetchProfileImage(sess, hash)
|
||||
} else {
|
||||
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
||||
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(sess, hash)
|
||||
} ?: return@launch
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||
|
||||
@@ -550,7 +550,14 @@ class BmlTransferHandler(
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
BmlMerchantCardPayClient().pay(page, card) { _ -> Totp.generate(otpSeed) }
|
||||
BmlMerchantCardPayClient().pay(page, card) { retry ->
|
||||
// A code about to roll over can expire before the ACS checks it, and a
|
||||
// retry in the same window would resend the rejected code: wait for the
|
||||
// next window in both cases. Runs on IO.
|
||||
val left = TOTP_WINDOW_MS - System.currentTimeMillis() % TOTP_WINDOW_MS
|
||||
if (retry || left < TOTP_MIN_LEFT_MS) Thread.sleep(left + 500)
|
||||
Totp.generate(otpSeed)
|
||||
}
|
||||
}.getOrElse {
|
||||
BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed")
|
||||
}
|
||||
@@ -957,4 +964,11 @@ class BmlTransferHandler(
|
||||
}
|
||||
|
||||
private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account)
|
||||
|
||||
private companion object {
|
||||
/** The BML token's TOTP window. */
|
||||
const val TOTP_WINDOW_MS = 30_000L
|
||||
/** Don't send a card payment's 3-D Secure code with less than this left in its window. */
|
||||
const val TOTP_MIN_LEFT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ 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
|
||||
@@ -20,8 +22,8 @@ import java.math.RoundingMode
|
||||
* A carrier service a verified BML card can pay, through the carrier's own website and its BML
|
||||
* merchant gateway. The limits are the carrier website's, not Fahipay's.
|
||||
*
|
||||
* Only Dhiraagu so far. Add the others as constants once their send path lands; the exhaustive
|
||||
* `when`s over this enum point at every site that needs updating.
|
||||
* 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,
|
||||
@@ -31,16 +33,37 @@ enum class CardPayoutService(
|
||||
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) {
|
||||
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);
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,6 +109,12 @@ class CardPayoutTransferHandler(
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -122,7 +151,8 @@ class CardPayoutTransferHandler(
|
||||
displayName = displayName,
|
||||
subtitle = "${type.label} · $number",
|
||||
colorHex = "#E4002B",
|
||||
imageHash = null
|
||||
imageHash = null,
|
||||
contactCategory = type.service.contactCategory
|
||||
)
|
||||
fragment.focusAmount()
|
||||
}
|
||||
@@ -143,6 +173,7 @@ class CardPayoutTransferHandler(
|
||||
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))
|
||||
@@ -154,6 +185,10 @@ class CardPayoutTransferHandler(
|
||||
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)
|
||||
}
|
||||
@@ -164,8 +199,8 @@ class CardPayoutTransferHandler(
|
||||
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
|
||||
it.amount.toBigDecimal().compareTo(amount) != 0 ->
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
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.MibSession
|
||||
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||
@@ -40,6 +41,10 @@ class MibTransferHandler(
|
||||
val session: MibSession?
|
||||
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. */
|
||||
val anySession: MibSession? get() = app.anyMibSession()
|
||||
|
||||
@@ -54,8 +59,9 @@ class MibTransferHandler(
|
||||
*/
|
||||
fun lookupDestination(accountNumber: String): LookupOutcome {
|
||||
val sess = session ?: return LookupOutcome(null, notFound())
|
||||
val flow = flow ?: return LookupOutcome(null, notFound())
|
||||
return try {
|
||||
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
|
||||
LookupOutcome(MibTransferClient(flow).lookup(sess, accountNumber), null)
|
||||
} catch (e: MibLookupException) {
|
||||
LookupOutcome(null, e.message)
|
||||
} catch (_: Exception) {
|
||||
@@ -69,7 +75,8 @@ class MibTransferHandler(
|
||||
*/
|
||||
fun lookupCurrency(accountNumber: String): String {
|
||||
val sess = session ?: return ""
|
||||
return try { MibTransferClient().lookup(sess, accountNumber).currency }
|
||||
val flow = flow ?: return ""
|
||||
return try { MibTransferClient(flow).lookup(sess, accountNumber).currency }
|
||||
catch (_: Exception) { "" }
|
||||
}
|
||||
|
||||
@@ -88,7 +95,7 @@ class MibTransferHandler(
|
||||
val base64 = if (isProfile) {
|
||||
app.anyMibFlow()?.fetchProfileImage(session, hash)
|
||||
} else {
|
||||
MibContactsClient().fetchProfileImageBase64(session, hash)
|
||||
app.anyMibFlow()?.let { MibContactsClient(it) }?.fetchProfileImageBase64(session, hash)
|
||||
}
|
||||
if (base64 == null) null else {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
@@ -100,8 +107,9 @@ class MibTransferHandler(
|
||||
|
||||
/**
|
||||
* Switches to the profile that owns [src], then runs the transfer with a freshly generated
|
||||
* TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is
|
||||
* "CONNECTIVITY" when the network was unreachable.
|
||||
* TOTP. If MIB rejects the OTP as invalid or already used, waits for the next OTP window and
|
||||
* retries once; a second rejection is returned as a failure. Blocking — call from IO.
|
||||
* Returns `(ok, message, receipt)`; the message is "CONNECTIVITY" when the network was unreachable.
|
||||
*/
|
||||
fun doTransfer(
|
||||
src: BankAccount,
|
||||
@@ -114,8 +122,7 @@ class MibTransferHandler(
|
||||
): Triple<Boolean, String, TransferReceiptData?> {
|
||||
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||
val loginId = src.loginTag.removePrefix("mib_")
|
||||
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
val otpSeed = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
|
||||
?: return Triple(false, "OTP unavailable", null)
|
||||
val currencyCode = if (src.currencyName == "USD") "840" else "462"
|
||||
val currency = if (src.currencyName == "USD") "USD" else "MVR"
|
||||
@@ -145,17 +152,26 @@ class MibTransferHandler(
|
||||
val profile = profiles.firstOrNull { it.profileId == src.profileId }
|
||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||
}
|
||||
val result = MibTransferClient().transfer(
|
||||
session = sess,
|
||||
fromAccount = src.accountNumber,
|
||||
toAccount = destAccount,
|
||||
amount = amount,
|
||||
currencyCode = currencyCode,
|
||||
benefName = destName.ifBlank { "Recipient" },
|
||||
bankNo = bankNo,
|
||||
purpose = remarks,
|
||||
otp = otp
|
||||
)
|
||||
val client = MibTransferClient(app.mibFlowFor(loginId))
|
||||
val send = { otp: String ->
|
||||
client.transfer(
|
||||
session = sess,
|
||||
fromAccount = src.accountNumber,
|
||||
toAccount = destAccount,
|
||||
amount = amount,
|
||||
currencyCode = currencyCode,
|
||||
benefName = destName.ifBlank { "Recipient" },
|
||||
bankNo = bankNo,
|
||||
purpose = remarks,
|
||||
otp = otp
|
||||
)
|
||||
}
|
||||
var result = send(Totp.generate(otpSeed))
|
||||
if (!result.success && result.reasonCode in OTP_REJECTED_CODES) {
|
||||
// The code was stale or already spent; the next window's code is always fresh
|
||||
Thread.sleep(Totp.millisUntilNextPeriod() + OTP_WINDOW_MARGIN_MS)
|
||||
result = send(Totp.generate(otpSeed))
|
||||
}
|
||||
if (result.success) {
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MIB",
|
||||
@@ -181,4 +197,13 @@ class MibTransferHandler(
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val OTP_REJECTED_CODES = setOf(
|
||||
MibTransferClient.REASON_INVALID_OTP,
|
||||
MibTransferClient.REASON_OTP_ALREADY_USED
|
||||
)
|
||||
// Small delay past the window boundary so device and server clocks agree on the new code
|
||||
const val OTP_WINDOW_MARGIN_MS = 1_000L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,17 +25,33 @@ interface PayoutService {
|
||||
val maxAmount: Int?
|
||||
/** Whether the amount may have a fractional part (up to 2 decimal places). */
|
||||
val decimalsAllowed: Boolean
|
||||
/** GST the carrier takes out of the amount before crediting it, in percent, or null for none. */
|
||||
/** GST the carrier charges, in percent, or null for none. [gstAdded] says how. */
|
||||
val gstPercent: Int?
|
||||
/**
|
||||
* 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.
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,11 +97,18 @@ class PayoutAmountField(
|
||||
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
|
||||
}
|
||||
|
||||
/** What the recipient is credited once GST comes out, for services that charge it. */
|
||||
/**
|
||||
* For services that charge GST: what the recipient is credited once it comes out, or what
|
||||
* is paid once it's added on top.
|
||||
*/
|
||||
fun gstNote(svc: PayoutService): String? {
|
||||
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)
|
||||
|
||||
@@ -255,17 +255,7 @@ class CredentialsFragment : Fragment() {
|
||||
store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
|
||||
withContext(Dispatchers.IO) {
|
||||
flow.lastSession?.let { s ->
|
||||
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
|
||||
)
|
||||
)
|
||||
try { MibProfileClient(flow).cacheIfMissing(store, loginId, s) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||
|
||||
@@ -27,14 +27,18 @@ object ContactManager {
|
||||
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
|
||||
val sess = app.anyMibSession() ?: return false
|
||||
return try {
|
||||
var flow = app.anyMibFlow() ?: return false
|
||||
if (contact.profileId.isNotBlank()) {
|
||||
val (loginId, profile) = app.mibProfilesMap.entries
|
||||
.firstNotNullOfOrNull { (id, profiles) ->
|
||||
profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it }
|
||||
} ?: (null to null)
|
||||
if (profile != null && loginId != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||
if (profile != null && loginId != null) {
|
||||
flow = app.mibFlowFor(loginId)
|
||||
flow.switchProfile(sess, profile)
|
||||
}
|
||||
}
|
||||
MibContactsClient().deleteContact(sess, contact.id)
|
||||
MibContactsClient(flow).deleteContact(sess, contact.id)
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,7 +669,8 @@ class CredentialStore(context: Context) {
|
||||
val username: String,
|
||||
val email: 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(
|
||||
@@ -736,6 +737,7 @@ class CredentialStore(context: Context) {
|
||||
put("email", p.email)
|
||||
put("mobile", p.mobile)
|
||||
put("enrolled", p.enrolled)
|
||||
if (p.idCard != null) put("idCard", p.idCard)
|
||||
}.toString()
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply()
|
||||
@@ -752,7 +754,8 @@ class CredentialStore(context: Context) {
|
||||
username = o.optString("username"),
|
||||
email = o.optString("email"),
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
val profile = profiles.firstOrNull { it.profileId == account.profileId }
|
||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||
val (list, total) = MibHistoryClient().fetchHistory(
|
||||
val (list, total) = MibHistoryClient(app.mibFlowFor(loginId)).fetchHistory(
|
||||
session = session,
|
||||
accountNo = account.accountNumber,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
|
||||
@@ -18,6 +18,12 @@ object Totp {
|
||||
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 {
|
||||
val msg = ByteArray(8) { i -> ((counter shr ((7 - i) * 8)) and 0xFF).toByte() }
|
||||
val mac = Mac.getInstance("HmacSHA1")
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
|
||||
@@ -261,7 +261,6 @@
|
||||
<string name="transfer_to">އެކައުންޓް ނަންބަރު ނުވަތަ ފަވާރާ އައިޑީ</string>
|
||||
<string name="transfer_label_from">ފޮނުވާ އެކައުންޓް</string>
|
||||
<string name="transfer_label_to">ލިބޭ އެކައުންޓް</string>
|
||||
<string name="transfer_select_service">ޚިދުމަތް އިޚްތިޔާރުކުރޭ</string>
|
||||
<string name="transfer_fahipay_phone_only">ފަހިޕޭއިން ފައިސާ ފޮނުވޭނީ 7 އަދަދުގެ ފޯނު ނަންބަރަކަށް</string>
|
||||
<string name="transfer_my_accounts">އަމިއްލަ އެކައުންޓްތައް</string>
|
||||
<string name="transfer_same_as_from">މިއީ ފައިސާ ފޮނުވާ އެކައުންޓް</string>
|
||||
@@ -331,6 +330,8 @@
|
||||
|
||||
<!-- Contact expand/delete -->
|
||||
<string name="contact_edit">އުނިއިތުރުގެނޭ</string>
|
||||
<string name="contact_edit_title">ކޮންޓެކްޓް އުނިއިތުރުގެނޭ</string>
|
||||
<string name="contact_alias_mib_max">MIB ގައި ލިޔެވޭނީ ގިނަވެގެން %1$d އަކުރު</string>
|
||||
<string name="contact_delete">ފުހެލާ</string>
|
||||
<string name="contact_delete_title">ކޮންޓެކްޓް ފުހެލުން</string>
|
||||
<string name="contact_delete_message">%s ކޮންޓެކްޓް ލިސްޓުން ފުހެލަންވީތޯ؟</string>
|
||||
|
||||
@@ -269,6 +269,8 @@
|
||||
<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_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_my_accounts">My Accounts</string>
|
||||
<string name="transfer_same_as_from">This is the same account as the sender</string>
|
||||
@@ -339,6 +341,8 @@
|
||||
|
||||
<!-- Contact expand/delete -->
|
||||
<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_title">Delete Contact</string>
|
||||
<string name="contact_delete_message">Remove %s from your contacts?</string>
|
||||
|
||||
+1
-1
@@ -18,4 +18,4 @@
|
||||
| [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 |
|
||||
| [dhiraaguapi/](dhiraaguapi/README.md) | Dhiraagu Easy Pay / Easy TopUp — number lookup, reload and bill pay by BML card |
|
||||
| [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation for Raastas / bill pay |
|
||||
| [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation, Raastas and bill pay by BML card |
|
||||
|
||||
@@ -205,8 +205,10 @@ no `action`; their JavaScript posts back to the **same creq URL**. So the creq U
|
||||
`destValue=token`, `selectChannel=token`, `authMethod=OOB`, `otpDest=`, `formReqType=SUBMIT`
|
||||
(keep the hidden `creq` / `otpChannels`).
|
||||
3. **POST channel** → the **OTP entry** page (`otpValue` input). Submit `otpValue=<BML token TOTP>`,
|
||||
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page with text containing
|
||||
*"incorrect"* / *"expired"* — regenerate the TOTP and retry once.
|
||||
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page (still with `otpValue`) and
|
||||
*"The OTP code you entered is incorrect Please try again."* — wait for the next TOTP window,
|
||||
regenerate and retry once. Rejected twice, the payment stops ("The bank rejected the BML token
|
||||
code"). The app also never sends a code with under 5 s left in its window.
|
||||
4. On success the ACS returns a form auto-posting **`cres`** to the Mastercard gateway; the gateway
|
||||
returns a form auto-posting the result (`order.id`, `result=SUCCESS`, …) to
|
||||
**`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded.
|
||||
@@ -225,6 +227,23 @@ Poll `next-action` until the recorded verdict surfaces:
|
||||
| `TRANSACTION_CONFIRMED` | Success |
|
||||
| `TRANSACTION_FAILED` | Declined |
|
||||
|
||||
**A decline after 3-D Secure doesn't arrive this way.** In the Ooredoo capture, the card passed
|
||||
3-D Secure (`mpgsNotification` got `result=SUCCESS`, `gatewayRecommendation=PROCEED`) and was then
|
||||
declined for insufficient funds. The browser's `?wait=1` went to `?error=1` instead of the
|
||||
merchant, and the transaction stayed payable: `state` still `QR_CODE_GENERATED`, `hasError: true`,
|
||||
`allowRetry: true`, and a new `paymentErrorHistory` entry:
|
||||
|
||||
```json
|
||||
{"date":"…","vendor":"mpgs","code":"INSUFFICIENT_FUNDS",
|
||||
"reason":"Transaction declined due to insufficient funds",
|
||||
"customerVisibleDescription":"Insufficient funds. Please use another card or payment method."}
|
||||
```
|
||||
|
||||
The same link was then paid successfully after topping up the card. So while polling,
|
||||
`BmlMerchantCardPayClient` also reads the transaction (the load PATCH,
|
||||
`BmlMerchantTxnClient.paymentErrors`) and stops with `customerVisibleDescription` as soon as an
|
||||
entry newer than the ones there before the attempt shows up.
|
||||
|
||||
## 7. Return to the merchant
|
||||
|
||||
The `mpgsNotification` response is a page whose script sends the browser to
|
||||
@@ -240,13 +259,21 @@ GET transaction…/<id>?wait=1
|
||||
|
||||
(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, up to 3 tries, success = the chain ends on a 2xx page. The
|
||||
UA GET that follows the redirects and auto-submitted forms, up to 3 tries, success = the chain
|
||||
ends on a 2xx page. The
|
||||
merchant host may be behind Cloudflare: plain `curl` got a 403 on `dhiraagu.com.mv`, okhttp got
|
||||
through.
|
||||
|
||||
|
||||
+11
-1
@@ -57,6 +57,16 @@ def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
|
||||
|
||||
## 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`
|
||||
|
||||
**Key**: `DEFAULT_KEY = 8M3L9SBF1AC4FRE56788M3L9SBF1AC4FRE5678`
|
||||
@@ -67,7 +77,7 @@ def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
|
||||
"sfunc": "r",
|
||||
"data": {
|
||||
"cmod": "<G^A mod P as decimal string>",
|
||||
"appId": "IOS17.2-<15 random alphanumeric chars>",
|
||||
"appId": "AND34-<15 random alphanumeric chars>",
|
||||
"routePath": "S40",
|
||||
"sodium": "<random int in [1_000_000, 16_000_000)>",
|
||||
"xxid": "<random 40-bit int as string>"
|
||||
|
||||
@@ -12,7 +12,7 @@ MIB uses **two completely separate backends**:
|
||||
|
||||
| Backend | Base URL | Auth | Used for |
|
||||
|---|---|---|---|
|
||||
| Encrypted API | `https://faisanet.mib.com.mv/faisamobilex_smvc/` | Blowfish + DH session key | Login, key exchange |
|
||||
| Encrypted API | `https://faisamobilex-smvc-v2.mib.com.mv/index/` | Blowfish + DH session key | Login, key exchange |
|
||||
| WebView host | `https://faisamobilex-wv.mib.com.mv` | Session cookies | Accounts, history, transfers, contacts, cards, financing |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# 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
|
||||
@@ -129,4 +129,4 @@ Always fall back to the other provider's lookup if this API returns `custType: n
|
||||
|
||||
---
|
||||
|
||||
[← README](README.md)
|
||||
[← README](README.md) · [Raastas →](02-raastas.md)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Raastas (Quick Pay recharge, paid by BML card)
|
||||
|
||||
Recharge an Ooredoo prepaid number through the ooredoo.mv **Quick Pay** page. Ooredoo creates the
|
||||
order and hands back a **BML Merchant Services transaction**, which is paid like any card-only
|
||||
BML merchant link ([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
|
||||
|
||||
Reconstructed from `docs/ooredooapi/tmp/ooredoo_raastas_bml_card.har` (a Firefox HAR, which also
|
||||
holds a rejected OTP and an insufficient-funds decline on the same transaction).
|
||||
|
||||
---
|
||||
|
||||
## Flow overview
|
||||
|
||||
```
|
||||
GET /ooredoo-prod/QuickPayPackage/v1/numberTypeValidation?… → custType PRE (Number Validation)
|
||||
POST /ooredoo-prod/PaymentGateway/bml → orderID, bmlUrl ──┐
|
||||
│
|
||||
── from here: the BML card-only merchant flow ── │
|
||||
GET transaction.merchants…/<id> ←──────────────────────────────────────────────────┘
|
||||
… Pomelo tokenise, next-action, Wibmo 3-D Secure, MPGS … → TRANSACTION_CONFIRMED
|
||||
GET transaction.merchants…/<id>?wait=1 → 302 my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED
|
||||
(200, auto-submits) → POST www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml
|
||||
→ /payment-status?order_id=<orderID>&statusDesc=sucess&status=1
|
||||
```
|
||||
|
||||
Unlike Dhiraagu, there's no nonce or cart: one POST makes the order and the BML transaction.
|
||||
|
||||
---
|
||||
|
||||
## 1. Order
|
||||
|
||||
`POST https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/bml`
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Content-Type` | `application/json` |
|
||||
| `Accept` | `application/json` |
|
||||
| `Origin` | `https://www.ooredoo.mv` |
|
||||
|
||||
```json
|
||||
{"msisdn":"9609XXXXXX","purchaseAmount":"21.60","amountWithoutGst":"20",
|
||||
"receiverMsisdn":"9609XXXXXX","transType":"recharge","serviceType":"prepaid",
|
||||
"serviceTypeDisplayName":"Mobile"}
|
||||
```
|
||||
```json
|
||||
{"status":"OK","msg":"Successfully generated order id","code":"2000",
|
||||
"data":{"orderID":"36447930","purchaseAmount":"2160","hashSignature":"…",
|
||||
"shortUrl":"https://pay.bml.com.mv/7A86qLZ1QV",
|
||||
"bmlUrl":"https://transaction.merchants.bankofmaldives.com.mv/6ac009f7bd9b264b80ba6abf", …}}
|
||||
```
|
||||
|
||||
The 24-hex id at the end of `bmlUrl` is the transaction (`shortUrl` 301s to the same page). The
|
||||
page is card-only, no BML Pay. The transaction's `localId` is the MSISDN, `customerReference` the
|
||||
order id, and it expires after 7 days.
|
||||
|
||||
### GST
|
||||
|
||||
**Added on top**, not taken out: the number is credited `amountWithoutGst` and the card pays
|
||||
`purchaseAmount = amount + toFixed2(amount × 8 / 100)` (MVR 20 → 21.60). The page's payment
|
||||
method list gives `gstPercent: 8` for BML. This is the opposite of Raastas through Fahipay, where
|
||||
GST comes out of the amount.
|
||||
|
||||
### Limits
|
||||
|
||||
Whole MVR, minimum **20** (before GST, so the card pays at least 21.60). The maximum isn't known;
|
||||
the capture starts on the payment page.
|
||||
|
||||
---
|
||||
|
||||
## 2. Return to Ooredoo
|
||||
|
||||
`?wait=1` 302s to `https://my.ooredoo.mv/bml/response_new.php?transactionId=<id>&state=CONFIRMED&signature=<hash>`.
|
||||
That page is a 200 that auto-submits:
|
||||
|
||||
```html
|
||||
<body onload="document.forms['wtmpay'].submit()">
|
||||
<form method="POST" action="https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml" name="wtmpay">
|
||||
order_id=36447930 amount=21.60000000 msisdn=9609XXXXXX transtype=2
|
||||
bml_transaction_id=<id> bml_hash=<hash> bml_response=CONFIRMED
|
||||
error_code=0 payment_status=success ptype=bml
|
||||
```
|
||||
|
||||
which ends on `https://www.ooredoo.mv/payment-status?order_id=36447930&statusDesc=sucess&status=1`
|
||||
(the receipt: `totalAmount 21.6`, `amountWithoutGst 20`, `gstAmt 1.6`). The card flow submits the
|
||||
form, see [BML API → Return to the merchant](../bmlapi/16-card-payment.md#7-return-to-the-merchant).
|
||||
|
||||
A declined attempt sends `?wait=1` to `transaction…/<id>?error=1` instead, and the same
|
||||
transaction can be paid again.
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare
|
||||
|
||||
`ooredoo.mv` and `my.ooredoo.mv` are behind Cloudflare. The capture carries a `cf_clearance`
|
||||
cookie; okhttp from the phone gets through without one, as with
|
||||
[Number Validation](01-number-validation.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**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)
|
||||
@@ -0,0 +1,65 @@
|
||||
# Bill Pay (Quick Pay, paid by BML card)
|
||||
|
||||
Pay an Ooredoo postpaid bill through the ooredoo.mv **Quick Pay** bill-pay page. It is the same
|
||||
single order call as [Raastas](02-raastas.md) with a different `transType` / `serviceType`, and
|
||||
no GST. From the order on, it's the BML card-only merchant flow and the same return to Ooredoo.
|
||||
|
||||
Reconstructed from `docs/ooredooapi/tmp/ooredoo_billpay_bml_card.har` (a Firefox HAR).
|
||||
|
||||
---
|
||||
|
||||
## Flow overview
|
||||
|
||||
```
|
||||
GET /bill-pay
|
||||
GET /ooredoo-prod/QuickPayPackage/v1/numberTypeValidation?… → custType POST (Number Validation)
|
||||
POST /ooredoo-prod/PaymentGateway/bml → orderID, bmlUrl
|
||||
── BML card-only merchant flow ── → TRANSACTION_CONFIRMED
|
||||
GET transaction.merchants…/<id>?wait=1 → 302 my.ooredoo.mv/bml/response_new.php?…state=CONFIRMED
|
||||
(200, auto-submits, transtype=1) → POST www.ooredoo.mv/ooredoo-prod/PaymentGateway/redirect/bml
|
||||
→ /payment-status?statusDesc=sucess&status=1&order_id=<orderID>
|
||||
```
|
||||
|
||||
The page doesn't look up the outstanding bill: the amount is whatever is typed.
|
||||
|
||||
---
|
||||
|
||||
## Order
|
||||
|
||||
`POST https://www.ooredoo.mv/ooredoo-prod/PaymentGateway/bml`, headers as in
|
||||
[Raastas → Order](02-raastas.md#1-order).
|
||||
|
||||
```json
|
||||
{"msisdn":"9609XXXXXX","purchaseAmount":"10.01","amountWithoutGst":"10.01",
|
||||
"receiverMsisdn":"9609XXXXXX","transType":"billpay","serviceType":"Mobile",
|
||||
"serviceTypeDisplayName":"Mobile"}
|
||||
```
|
||||
```json
|
||||
{"status":"OK","msg":"Successfully generated order id","code":"2000",
|
||||
"data":{"orderID":"36447962","purchaseAmount":"1001","shortUrl":"https://pay.bml.com.mv/…",
|
||||
"bmlUrl":"https://transaction.merchants.bankofmaldives.com.mv/6ac011e099f9d890856e2d33", …}}
|
||||
```
|
||||
|
||||
| | Raastas | Bill Pay |
|
||||
|---|---|---|
|
||||
| `transType` | `recharge` | `billpay` |
|
||||
| `serviceType` | `prepaid` | `Mobile` |
|
||||
| `amountWithoutGst` | amount credited | = `purchaseAmount` |
|
||||
| GST | 8% added on top | none |
|
||||
| Return form `transtype` | `2` | `1` |
|
||||
|
||||
### Limits
|
||||
|
||||
Minimum **MVR 10**, decimals allowed (up to 2 places). The maximum isn't known.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
**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)
|
||||
@@ -81,6 +81,8 @@ The API expects the full MSISDN including country code `960` (e.g. `9609654321`)
|
||||
| # | File | Description |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ A phone number searched with no source yet (or from a BML card that can pay by c
|
||||
|---|---|---|
|
||||
| Favara Transfer | bank account behind the number | MIB or BML account |
|
||||
| Fahipay service | Raastas, Ooredoo Bill Pay, Dhiraagu Reload, Dhiraagu Bill Pay | Fahipay wallet |
|
||||
| Card service | Dhiraagu Reload, Dhiraagu Bill Pay (BML badge) | Verified BML card |
|
||||
| Card service | Dhiraagu Reload, Dhiraagu Bill Pay, Raastas, Ooredoo Bill Pay (BML badge) | Verified BML card |
|
||||
|
||||
One option is applied straight away; with more, a picker opens and Send stays disabled until one is chosen. Picking a type also picks a source that can pay it. Fahipay and card services clear and disable the Remarks field and apply their own amount rules (minimum, maximum, whole amounts, 8% GST note). Details: [Transfer Flows → Transfer Type picker](20-transfer-flows.md#transfer-type-picker).
|
||||
|
||||
@@ -122,11 +122,11 @@ When the source is a BML USD account and the destination is a MIB account but no
|
||||
|
||||
See [Transfer Flows → Fahipay source](20-transfer-flows.md#fahipay-source).
|
||||
|
||||
### Carrier Service by BML Card (Dhiraagu Reload / Bill Pay)
|
||||
### Carrier Service by BML Card (Dhiraagu Reload / Bill Pay, Ooredoo Raastas / Bill Pay)
|
||||
|
||||
1. Checks the amount against Dhiraagu's rules (reload: MVR 20–1000, whole amounts, 8% GST included; bill pay: from MVR 1, up to 2 decimals, no GST)
|
||||
2. A "Processing..." dialog shows while Dhiraagu creates the order and its BML merchant transaction ([Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md))
|
||||
3. From there it is the card-only merchant flow: the same confirm dialog and warning, biometric gate, card + 3-D Secure payment, and the return to Dhiraagu (`?wait=1`) that tops the number up or posts the bill payment
|
||||
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).
|
||||
|
||||
@@ -105,7 +105,7 @@ Each Fahipay favourites list is one payout service (`FahipayService.contactCateg
|
||||
| `FAHIPAY_OOREDOO_BILL` | Ooredoo Bill Pay |
|
||||
| `FAHIPAY_DHIRAAGU_BILL` | Dhiraagu Bill Pay |
|
||||
|
||||
So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyFahipayContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked:
|
||||
So picking a favourite skips the carrier lookup and the picker. That service is offered as the only transfer type, so it's picked straight away (`TransferFragment.applyServiceContact`). As with a searched number, that switches the source to the Fahipay wallet and applies the service's amount rules. This happens wherever a favourite is picked:
|
||||
|
||||
- the contact picker sheet (the row's category goes back as `ContactPickerSheetFragment.KEY_CATEGORY`)
|
||||
- the "To" field's search-as-you-type dropdown
|
||||
@@ -151,8 +151,8 @@ None of the Fahipay services take a reference. Picking one clears the Reference
|
||||
### Carrier services by BML card
|
||||
|
||||
A carrier service can also be paid with a verified BML card, through the carrier's own website
|
||||
and its BML merchant gateway, instead of the Fahipay wallet. Only **Dhiraagu Reload** and
|
||||
**Dhiraagu Bill Pay** so far (`CardPayoutService`, `ui/home/transfer/CardPayoutTransferHandler.kt`).
|
||||
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
|
||||
@@ -166,6 +166,8 @@ drops the pick, like any other source that can't pay the picked type.
|
||||
|---|---|
|
||||
| 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`:
|
||||
@@ -174,17 +176,34 @@ the shared `PayoutAmountField`:
|
||||
|---|---|---|---|---|
|
||||
| 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.
|
||||
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`, see
|
||||
[Dhiraagu API → Reload](../dhiraaguapi/02-reload.md) and [→ Bill Pay](../dhiraaguapi/03-bill-pay.md)).
|
||||
(`DhiraaguPaymentClient.createReloadTransaction` / `createBillPayTransaction`,
|
||||
`OoredooPaymentClient.createRaastasTransaction` / `createBillPayTransaction`, see
|
||||
[Dhiraagu API → Reload](../dhiraaguapi/02-reload.md), [→ Bill Pay](../dhiraaguapi/03-bill-pay.md)
|
||||
and [Ooredoo API → Raastas](../ooredooapi/02-raastas.md), [→ Bill Pay](../ooredooapi/03-bill-pay.md)).
|
||||
Bill pay looks the number up again first, for the billing account the order is made out to.
|
||||
That takes a few round trips, so the payment's "Processing..." box shows meanwhile
|
||||
(`TransferFragment.showProcessingDialog`) and closes before the confirm dialog opens.
|
||||
@@ -265,7 +284,7 @@ Source: Fahipay
|
||||
Transfer type: Card (verified BML card)
|
||||
|
||||
└── Carrier creates a BML merchant transaction → card-only merchant flow
|
||||
DHIRAAGU_RELOAD, DHIRAAGU_BILL
|
||||
DHIRAAGU_RELOAD, DHIRAAGU_BILL, OOREDOO_RAASTAS, OOREDOO_BILL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ Two linked features:
|
||||
whose merchant has **no BML Pay** is paid with a verified card via the Pomelo + 3-D Secure flow
|
||||
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)). The same flow pays
|
||||
[carrier services by BML card](20-transfer-flows.md#carrier-services-by-bml-card) (Dhiraagu
|
||||
Reload and Bill Pay), once the carrier has created the transaction.
|
||||
Reload and Bill Pay, Ooredoo Raastas and Bill Pay), once the carrier has created the transaction.
|
||||
|
||||
> ⚠️ The merchant card flow is scraped browser/ACS traffic, not a stable API. Storing the CVV is a
|
||||
> security/PCI liability. See the API doc's
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
- updated dhivehi transaltions (thank you @quillfires)
|
||||
- improved fahipay support
|
||||
- new UI to select transfer type (Favara, Reload, Raastas, Billpay)
|
||||
- Ooredoo Raastas via Fahipay
|
||||
- Ooredo Billpay via Fahipay
|
||||
- Dhiraagu reload via Fahipay
|
||||
- Dhiraagu billpay via Fahipay
|
||||
- Ooredoo Raastas via BML verified cards
|
||||
- Ooredoo Billpay via BML verified cards
|
||||
- Dhiraagu Reload via BML verifed cards
|
||||
- Dhiraagu billpay via BML verified cards
|
||||
@@ -0,0 +1,5 @@
|
||||
- upgraded MIB to new v2 API (login, history, cards, contacts, transfers)
|
||||
- edit MIB contacts (alias, group, photo)
|
||||
- MIB contact alias limited to 20 characters
|
||||
- MIB transfers retry once with a new OTP if OTP is invalid or already used
|
||||
- MIB login now shows ID card, mobile and email in settings
|
||||
Reference in New Issue
Block a user