Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
255f43db24
|
|||
|
01cae559cf
|
|||
|
015919a4ac
|
|||
|
93a7c8bbde
|
|||
|
8f4672f269
|
|||
|
00e6b40ee0
|
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 22
|
||||
versionName = "1.0.21"
|
||||
versionCode = 23
|
||||
versionName = "1.0.22"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ class PayMvQrFragment : Fragment() {
|
||||
val eligible = accounts.filter {
|
||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
||||
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
||||
it.bank != "MFAISA" && // TODO: M-Faisa PayMV QR not implemented yet
|
||||
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
||||
}
|
||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||
|
||||
@@ -11,17 +11,13 @@ import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.PixelCopy
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
@@ -159,9 +155,6 @@ class TransferReceiptFragment : Fragment() {
|
||||
}
|
||||
})
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
|
||||
shareReceipt()
|
||||
}
|
||||
@@ -377,21 +370,19 @@ class TransferReceiptFragment : Fragment() {
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Captures the receipt card using PixelCopy, which correctly handles
|
||||
* hardware-accelerated views (avoids the black-square problem with view.draw()).
|
||||
* Draws the receipt card to an offscreen bitmap at its natural (unscaled)
|
||||
* dimensions, so the captured image isn't affected by the on-screen scale
|
||||
* applied to fit small viewports and doesn't pick up overlapping siblings.
|
||||
*/
|
||||
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
||||
val view = _receiptCard ?: run { callback(null); return }
|
||||
if (view.width == 0 || view.height == 0) { callback(null); return }
|
||||
|
||||
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
||||
val location = IntArray(2)
|
||||
view.getLocationInWindow(location)
|
||||
val srcRect = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height)
|
||||
|
||||
PixelCopy.request(requireActivity().window, srcRect, bitmap, { result ->
|
||||
callback(if (result == PixelCopy.SUCCESS) bitmap else null)
|
||||
}, Handler(Looper.getMainLooper()))
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
view.draw(canvas)
|
||||
callback(bitmap)
|
||||
}
|
||||
|
||||
private fun formatBmlTimestamp(raw: String): String {
|
||||
@@ -494,13 +485,9 @@ class TransferReceiptFragment : Fragment() {
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||
_receiptCard = null
|
||||
pendingToAvatarBitmap = null
|
||||
}
|
||||
|
||||
@@ -36,9 +36,9 @@ object BmlCardParser {
|
||||
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
|
||||
"C8101" -> "cards/bml/master_masveriyaa.png"
|
||||
"C8102" -> "cards/bml/master_odiveriyaa.png"
|
||||
"C8010", "C8011" -> "cards/bml/master_platinum.png"
|
||||
"C8010", "C8011", "C8033" -> "cards/bml/master_platinum.png"
|
||||
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
||||
"C8030" -> "cards/bml/master_business_debit.png"
|
||||
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
||||
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
||||
|
||||
@@ -246,18 +246,10 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Save"
|
||||
app:icon="@drawable/ic_save" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDone"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Done" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -376,18 +376,10 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Save"
|
||||
app:icon="@drawable/ic_save" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDone"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Done" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -273,18 +273,10 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Save"
|
||||
app:icon="@drawable/ic_save" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDone"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="Done" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -190,9 +190,9 @@ Known asset mappings:
|
||||
|---|---|---|
|
||||
| `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` |
|
||||
| `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` |
|
||||
| `C8010`, `C8011` | Mastercard Platinum | `master_platinum` |
|
||||
| `C8010`, `C8011`, `C8033` | Mastercard Platinum | `master_platinum` |
|
||||
| `C8020`, `C8022` | Mastercard Gold | `master_gold` |
|
||||
| `C8030`, `C8033` | Mastercard Business Debit | `master_business_debit` |
|
||||
| `C8030` | Mastercard Business Debit | `master_business_debit` |
|
||||
| `C8040`, `C8044` | Mastercard World | `master_world` |
|
||||
| `C8101` | Mastercard Masveriyaa | `master_masveriyaa` |
|
||||
| `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` |
|
||||
|
||||
@@ -279,4 +279,4 @@ python tmp/mfaisa_transfer.py <myMsisdn> <myMpin> <recipientMsisdn>
|
||||
|
||||
---
|
||||
|
||||
> **← Back to** [Transaction History](03-history.md) | [README](README.md)
|
||||
> **← Back to** [Transaction History](03-history.md) | [README](README.md) | **Next →** [QR Merchant Payment](05-qr-pay.md)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
# QR Merchant Payment ("Smart Pay")
|
||||
|
||||
Pay an Ooredoo M-Faisa merchant by scanning their QR. The QR encodes only a numeric `qrCodeId` (e.g. `1594103440350`) — no URL, no EMV TLV envelope. The flow is three calls and **does not require OTP** (`2FARequired=NONE`).
|
||||
|
||||
> **Currency / pocket constraints:** captures cover MVR→MVR purchases from the user's EMONEY pocket only. The `transactionCurrency` field is taken from the QR lookup; we have not seen a non-MVR variant.
|
||||
|
||||
---
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
| POST /QRCodeUtility/fetchQRCodeById | ← user scanned a QR
|
||||
| formData = { qrCodeId, tenantCode } |
|
||||
|------------------------------------------------>|
|
||||
| [{ success, response:[{ commercialName, |
|
||||
| customerId, mobileNumber, currencyCode, |
|
||||
| txnAmount, status, ... }] }] |
|
||||
|<------------------------------------------------|
|
||||
| |
|
||||
| (show merchant, accept amount + remarks)
|
||||
| |
|
||||
| POST /initiateNewBuy |
|
||||
| formData = { merchantId, mobileNumber, |
|
||||
| sourceDetails, transactionAmount, |
|
||||
| transactionType:"PURCHASE", … } |
|
||||
|------------------------------------------------>|
|
||||
| [{ 2FARequired:"NONE", |
|
||||
| authenticationType:"NONE", |
|
||||
| success:true, |
|
||||
| response:[{ responseObject:{ referenceId, |
|
||||
| chargeDetails, … } }] }] |
|
||||
|<------------------------------------------------|
|
||||
| |
|
||||
| (no OTP — go straight to confirm)
|
||||
| |
|
||||
| POST /confirmNewBuy |
|
||||
| formData = { referenceId } |
|
||||
| transactionAuthDetails = "null" ← literal |
|
||||
|------------------------------------------------>|
|
||||
| [{ success:true, |
|
||||
| message:"Payment Completed Successfully", |
|
||||
| response:[{ responseObject:{ isCompleted, |
|
||||
| balanceInquiryDTO, ... } }] }] |
|
||||
|<------------------------------------------------|
|
||||
```
|
||||
|
||||
All three endpoints carry the standard anti-replay pair (`rndValue` + `csValue`) derived from each request's `formData` JSON — see [01-encryption.md → rndValue / csValue](01-encryption.md#anti-replay-envelope-rndvalue--csvalue).
|
||||
|
||||
Unlike the transfer flow, **every endpoint here returns its envelope as a JSON array** `[{...}]` for both success and error.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: `QRCodeUtility/fetchQRCodeById` — resolve merchant
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/QRCodeUtility/fetchQRCodeById
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | **`R01`** (the other two endpoints use `RETAIL_SUBSCRIBER` — this one does not) |
|
||||
| `channel` | `C03` |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | [Standard anti-replay](01-encryption.md#anti-replay-envelope-rndvalue--csvalue) |
|
||||
| `formData` | JSON below ([html-safe `=` escaping](01-encryption.md#html-safe-gson--escape)) |
|
||||
|
||||
```json
|
||||
{
|
||||
"qrCodeId": "<numeric id from QR>",
|
||||
"tenantCode": "ooredoo"
|
||||
}
|
||||
```
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"success": true,
|
||||
"message": "QRCode fetched Successfully.",
|
||||
"response": [
|
||||
{
|
||||
"mobileNumber": "9609569506", /* merchant's '960' + msisdn */
|
||||
"customerId": "72518", /* used as merchantId in step 2 */
|
||||
"commercialName": "Family Room", /* merchant display name */
|
||||
"qrCodeId": "1594103440350",
|
||||
"qrImageString": "<base64 PNG>", /* unused */
|
||||
"accountNumber": null,
|
||||
"txnAmount": null, /* static QR; dynamic QRs put a number here */
|
||||
"currencyCode": "MVR",
|
||||
"status": "Active",
|
||||
"role": "AGENT",
|
||||
"tenantCode": "ooredoo",
|
||||
"...": "..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`accountNumber` / `txnAmount` are JSON `null` for **static QRs** (the user chooses the amount). For **dynamic QRs** the server returns the fixed amount in `txnAmount` and the client should lock the amount field.
|
||||
|
||||
The `mobileNumber` field already includes the `960` country prefix.
|
||||
|
||||
### Response — QR not found / inactive
|
||||
|
||||
```json
|
||||
[{ "success": false, "message": "QRCode not found." }]
|
||||
```
|
||||
|
||||
The client also rejects entries with `status != "Active"`.
|
||||
|
||||
---
|
||||
|
||||
## Step 2: `initiateNewBuy` — initiate purchase (no OTP triggered)
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/initiateNewBuy
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | `RETAIL_SUBSCRIBER` |
|
||||
| `channel` | `C03` (top-level differs from inner `formData.channel`, which is `SubscriberApp`) |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | Standard anti-replay (derived from the `formData` below) |
|
||||
| `formData` | JSON below |
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "SubscriberApp",
|
||||
"commodityType": "WALLET",
|
||||
"description": "<remarks>", /* free text, may be empty */
|
||||
"merchantId": "<customerId from step 1>",
|
||||
"mobileNumber": "<merchant '960' msisdn from step 1>",
|
||||
"sourceDetails": {
|
||||
"MDNId": "960<myMsisdn>", /* PLAINTEXT — '960' + my phone */
|
||||
"actorRoleType": "RETAIL_SUBSCRIBER",
|
||||
"pocketId": "<my source pocket id>" /* EMONEY pocket from login */
|
||||
},
|
||||
"transactionAmount": "<amount>", /* string, e.g. "7.56" */
|
||||
"transactionCurrency": "MVR",
|
||||
"transactionType": "PURCHASE"
|
||||
}
|
||||
```
|
||||
|
||||
Unlike the transfer flow's `initiateFTRequest`, this endpoint does **not** take an `identifier` header field or `tPin`.
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"2FARequired": "NONE", /* ← the key difference */
|
||||
"authenticationType": "NONE",
|
||||
"success": true,
|
||||
"message": "Purchase Initiated Successfully",
|
||||
"response": [
|
||||
{
|
||||
"requestObject": { "...": "..." },
|
||||
"responseObject": {
|
||||
"referenceId": "685011023630",
|
||||
"transactionAmount": { "amount": 7.56, "currencyCode": "MVR" },
|
||||
"netAmount": { "amount": 7.56, "currencyCode": "MVR" },
|
||||
"chargeDetailsDTO": { "totalFeesInTenantCurrency": { "amount": 0.0, "...": "..." }, "...": "..." },
|
||||
"isCompleted": false,
|
||||
"authenticationType":"NONE",
|
||||
"...": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Cache `referenceId` for step 3. No SMS is sent — proceed straight to confirm.
|
||||
|
||||
> **Defensive check:** the Thijooree client throws if it ever sees `2FARequired != "NONE"` on this endpoint so a no-op confirm can't silently complete. If Ooredoo ever turns 2FA on for QR pay, you'll see a clear error instead of a partial transaction.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: `confirmNewBuy` — settle purchase
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/confirmNewBuy
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | `RETAIL_SUBSCRIBER` |
|
||||
| `channel` | `C03` |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | Anti-replay derived from `formData` below |
|
||||
| `formData` | `{"referenceId": "<from step 2>"}` |
|
||||
| `transactionAuthDetails` | **literal string `"null"`** (not a JSON null, not an empty string — the captured request sends the four-character string `null`) |
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"success": true,
|
||||
"message": "Payment Completed Successfully",
|
||||
"response": [
|
||||
{
|
||||
"responseObject": {
|
||||
"isCompleted": true,
|
||||
"balanceInquiryDTO": {
|
||||
"currencyCode": "MVR",
|
||||
"pocketAmount": 92.85,
|
||||
"pocketId": "<source pocket id>",
|
||||
"pocketBalanceMap": { "...": "..." }
|
||||
},
|
||||
"status": { "replyCode": 0.0 },
|
||||
"...": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Session expiry
|
||||
|
||||
Same envelope as elsewhere — `attributeValue: "SESSION_EXPIRED"` with HTTP 200; the client throws `MfaisaSessionExpiredException`. See [03-history.md → Session expiry](03-history.md#session-expiry).
|
||||
|
||||
---
|
||||
|
||||
## Optional: `save/smart-pay-recipient` — bookkeeping
|
||||
|
||||
After a successful confirm the official Ooredoo app saves the merchant to a server-side "recent recipients" list:
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/save/smart-pay-recipient
|
||||
```
|
||||
|
||||
This is **not** required for the payment itself — the transfer is final once `confirmNewBuy` succeeds. Thijooree skips it; the merchant is kept in the local picker `RecentsCache` under an `mfaisaqr:<qrCodeId>` synthetic accountNumber (mirroring the BML QR `bmlqr:` scheme).
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **← Back to** [Transfer Money](04-transfer.md) | [README](README.md)
|
||||
@@ -88,6 +88,7 @@ Client Server
|
||||
| 2 | [Login](02-login.md) | Subscriber lookup + mPIN login |
|
||||
| 3 | [Transaction History](03-history.md) | Paginated history per session |
|
||||
| 4 | [Transfer Money](04-transfer.md) | Three-step wallet-to-wallet send: recipient lookup → initiate (server SMSes OTP) → confirm |
|
||||
| 5 | [QR Merchant Payment](05-qr-pay.md) | Three-step "smart pay" scan-to-merchant: QR lookup → initiate → confirm. **No OTP** (`2FARequired=NONE`) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user