This commit is contained in:
+83
-17
@@ -13,14 +13,14 @@ import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Dhiraagu prepaid reload through the dhiraagu.com.mv "Easy TopUp" page, paid by card on BML's
|
||||
* merchant gateway. Dhiraagu only builds the order; the money moves on the BML Merchant Services
|
||||
* transaction [createBmlTransaction] hands back, which is paid like any card-only BML merchant
|
||||
* link. See `docs/dhiraaguapi/02-reload.md`.
|
||||
* Dhiraagu prepaid reload ("Easy TopUp") and bill payment ("Easy Pay") through dhiraagu.com.mv,
|
||||
* paid by card on BML's merchant gateway. Dhiraagu only builds the order; the money moves on the
|
||||
* BML Merchant Services transaction handed back, which is paid like any card-only BML merchant
|
||||
* link. See `docs/dhiraaguapi/02-reload.md` and `docs/dhiraaguapi/03-bill-pay.md`.
|
||||
*
|
||||
* Every call blocks, so run it on an IO thread.
|
||||
*/
|
||||
class DhiraaguReloadClient {
|
||||
class DhiraaguPaymentClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
@@ -33,11 +33,11 @@ class DhiraaguReloadClient {
|
||||
* amount paid, GST included. Returns the 24-hex BML transaction id. Throws with Dhiraagu's
|
||||
* wording when a step is refused.
|
||||
*/
|
||||
fun createBmlTransaction(number: String, amount: Int): String {
|
||||
fun createReloadTransaction(number: String, amount: Int): String {
|
||||
val topupNonce = pageNonce("$BASE/services/easy-topup")
|
||||
val gst = gstOf(amount)
|
||||
val cart = api("cart", "recharge", topupNonce, JSONObject()
|
||||
.put("formId", FORM_ID)
|
||||
.put("formId", FORM_RELOAD)
|
||||
.put("serviceNumber", number)
|
||||
.put("amount", amount)
|
||||
.put("amountGST", gst.toDouble())
|
||||
@@ -46,21 +46,66 @@ class DhiraaguReloadClient {
|
||||
.put("memberId", "").put("memberName", "").put("memberNId", "")
|
||||
.put("customerId", "").put("customerCode", "")
|
||||
.put("version", 2))
|
||||
return payCart(FORM_RELOAD, cart, BigDecimal(amount))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the bill payment order for postpaid [number] and the BML transaction paying for
|
||||
* it: lookup (for the billing account) → cart → merchant → payment → BML transaction.
|
||||
* [amount] is MVR, up to 2 decimal places. Returns the 24-hex BML transaction id. Throws
|
||||
* with Dhiraagu's wording when a step is refused.
|
||||
*/
|
||||
fun createBillPayTransaction(number: String, amount: BigDecimal): String {
|
||||
val easyPayNonce = pageNonce("$BASE/services/easy-pay")
|
||||
// The cart needs the billing account the number belongs to, which only the lookup gives
|
||||
val info = call("dhiraaguIO", "infoUnlisted", easyPayNonce, JSONObject().put("number", number))
|
||||
if (info.optJSONArray("serviceDetails")?.optJSONObject(0)?.optString("prepaidIndicator") == "Y") {
|
||||
throw Exception("Prepaid number is not allowed.")
|
||||
}
|
||||
val accountNumber = info.optString("accountNumber").ifBlank { throw Exception("Invalid account/service number") }
|
||||
|
||||
// The page refuses some account statuses and customer types before ordering; so do we
|
||||
val rules = runCatching { get("setting", "bill", easyPayNonce).getJSONObject("resp").getJSONObject("settingAppJson1") }.getOrNull()
|
||||
val status = info.optString("accountStatus")
|
||||
if (rules != null && status in rules.blockedValues("accountStatus")) {
|
||||
throw Exception("Dhiraagu can't accept payment for this service. Contact Dhiraagu customer service. [Account Status: $status]")
|
||||
}
|
||||
val customerType = info.optString("customerType")
|
||||
if (rules != null && customerType in rules.blockedValues("customerType")) {
|
||||
throw Exception("The number is not allowed. [Customer Type: $customerType]")
|
||||
}
|
||||
|
||||
val cart = api("cart", "easyPay", easyPayNonce, JSONObject()
|
||||
.put("formId", FORM_BILL)
|
||||
.put("serviceNumber", number)
|
||||
.put("accountNumber", accountNumber)
|
||||
.put("amount", money(amount))
|
||||
.put("memberId", "").put("memberName", "").put("memberNId", "")
|
||||
.put("billRef", "")
|
||||
.put("billType", if (info.optString("type") == BILL_WRITE_OFF) BILL_WRITE_OFF else BILL_PAYMENT))
|
||||
return payCart(FORM_BILL, cart, amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* The payment page's half, shared by every form: picks the BML gateway, creates the payment
|
||||
* for [cart] and has Dhiraagu create the BML transaction. Returns its 24-hex id.
|
||||
*/
|
||||
private fun payCart(formId: Int, cart: JSONObject, amount: BigDecimal): String {
|
||||
val cartId = cart.optString("cartId").ifBlank { throw Exception("Dhiraagu didn't create the order") }
|
||||
|
||||
// The payment page carries its own nonce, used for the rest of the order
|
||||
val paymentNonce = pageNonce("$BASE/services/payment-v2?cartid=$cartId")
|
||||
val merchants = apiList("merchant", "form", paymentNonce, JSONObject().put("formId", FORM_ID))
|
||||
val merchants = apiList("merchant", "form", paymentNonce, JSONObject().put("formId", formId))
|
||||
val bml = (0 until merchants.length()).map { merchants.getJSONObject(it) }
|
||||
.firstOrNull { it.optInt("gatewayId") == GATEWAY_BML }
|
||||
?: throw Exception("Dhiraagu isn't taking BML card payments right now")
|
||||
|
||||
val payment = api("payment", "create", paymentNonce, JSONObject()
|
||||
.put("formId", FORM_ID)
|
||||
.put("formId", formId)
|
||||
.put("cartId", cartId)
|
||||
.put("gatewayId", GATEWAY_BML)
|
||||
.put("dhiraaguPayNumber", "")
|
||||
.put("amount", String.format(Locale.US, "%.2f", amount.toDouble()))
|
||||
.put("amount", money(amount))
|
||||
.put("paymentMerchantId", bml.getString("merchantId"))
|
||||
.put("memberId", "").put("tokenize", "").put("paymentType", "")
|
||||
.put("recurringFrequency", "").put("bmlTokenId", ""))
|
||||
@@ -71,13 +116,22 @@ class DhiraaguReloadClient {
|
||||
?: throw Exception("BML didn't create the transaction")
|
||||
}
|
||||
|
||||
private fun money(amount: BigDecimal) =
|
||||
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
|
||||
|
||||
/** The `val` list of a `setting` rule, e.g. `{"accountStatus":{"val":["F"]}}`. */
|
||||
private fun JSONObject.blockedValues(rule: String): Set<String> {
|
||||
val vals = optJSONObject(rule)?.optJSONArray("val") ?: return emptySet()
|
||||
return (0 until vals.length()).map { vals.optString(it) }.toSet()
|
||||
}
|
||||
|
||||
// ── HTTP ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every page embeds a `var nonce = "…"` that its API calls send as the `nonce` header. */
|
||||
private fun pageNonce(url: String): String =
|
||||
NONCE.find(get(url))?.groupValues?.get(1) ?: throw Exception("Dhiraagu page didn't load")
|
||||
NONCE.find(page(url))?.groupValues?.get(1) ?: throw Exception("Dhiraagu page didn't load")
|
||||
|
||||
private fun get(url: String): String = client.newCall(
|
||||
private fun page(url: String): String = client.newCall(
|
||||
Request.Builder().url(url)
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
@@ -94,10 +148,16 @@ class DhiraaguReloadClient {
|
||||
call(sub, act, nonce, body).getJSONArray("resp")
|
||||
|
||||
/** POSTs to `sdk-dhr-webapi.ashx`; throws unless `respStatus` is OK. */
|
||||
private fun call(sub: String, act: String, nonce: String, body: JSONObject): JSONObject {
|
||||
private fun call(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
|
||||
send(sub, act, nonce, body.toString().toRequestBody(JSON))
|
||||
|
||||
/** GETs from `sdk-dhr-webapi.ashx` (the settings calls); throws unless `respStatus` is OK. */
|
||||
private fun get(sub: String, act: String, nonce: String): JSONObject = send(sub, act, nonce, null)
|
||||
|
||||
private fun send(sub: String, act: String, nonce: String, body: okhttp3.RequestBody?): JSONObject {
|
||||
val text = client.newCall(
|
||||
Request.Builder().url("$API?website_id=$WEBSITE_ID&sub=$sub&act=$act")
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.apply { if (body != null) post(body) }
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
@@ -112,7 +172,7 @@ class DhiraaguReloadClient {
|
||||
throw Exception("Unexpected response from Dhiraagu")
|
||||
}
|
||||
if (obj.optString("respStatus") != "OK") {
|
||||
throw Exception(obj.optString("respMsg").ifBlank { obj.optString("resp") }.ifBlank { "Dhiraagu refused the reload" })
|
||||
throw Exception(obj.optString("respMsg").ifBlank { obj.optString("resp") }.ifBlank { "Dhiraagu refused the payment" })
|
||||
}
|
||||
return obj
|
||||
}
|
||||
@@ -126,14 +186,20 @@ class DhiraaguReloadClient {
|
||||
private val NONCE = Regex("""var nonce = "([^"]+)"""")
|
||||
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
|
||||
|
||||
/** Easy Pay's (bill payment) form id across cart / merchant / payment. */
|
||||
private const val FORM_BILL = 1
|
||||
/** Easy TopUp's form id across cart / merchant / payment. */
|
||||
private const val FORM_ID = 2
|
||||
private const val FORM_RELOAD = 2
|
||||
/** Bank of Maldives in `merchant&act=form` (1 = BML, 2 = MIB, 3 = DhiraaguPay). */
|
||||
private const val GATEWAY_BML = 1
|
||||
private const val GST_RATE = 0.08
|
||||
|
||||
/** Easy Pay's `billType`s; the lookup's `type` says which applies. */
|
||||
private const val BILL_PAYMENT = "BillPayment"
|
||||
private const val BILL_WRITE_OFF = "writeOffPayments"
|
||||
|
||||
/**
|
||||
* The GST inside a GST-inclusive [amount], as the page works it out:
|
||||
* The GST inside a GST-inclusive reload [amount], as the page works it out:
|
||||
* `amount × rate / (1 + rate)`, to 2 places. The number is credited `amount − gst`.
|
||||
*/
|
||||
fun gstOf(amount: Int): BigDecimal {
|
||||
@@ -9,7 +9,7 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguReloadClient
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguPaymentClient
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferFragment
|
||||
@@ -20,8 +20,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 reload 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.
|
||||
* 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.
|
||||
*/
|
||||
enum class CardPayoutService(
|
||||
override val label: String,
|
||||
@@ -36,8 +36,11 @@ enum class CardPayoutService(
|
||||
minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8) {
|
||||
// Dhiraagu rounds the GST to 2 places and credits the rest
|
||||
override fun creditedAfterGst(amount: BigDecimal): BigDecimal =
|
||||
amount - DhiraaguReloadClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt())
|
||||
};
|
||||
amount - DhiraaguPaymentClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt())
|
||||
},
|
||||
// Easy Pay sets no limits of its own: any amount with up to 2 decimals, no GST
|
||||
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
|
||||
minAmount = 1, maxAmount = null, decimalsAllowed = true, gstPercent = null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +81,11 @@ class CardPayoutTransferHandler(
|
||||
fun typesFor(result: CarrierLookup.Result, cards: Set<String>): List<TransferType.Card> {
|
||||
if (cards.isEmpty()) return emptyList()
|
||||
return buildList {
|
||||
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(CardPayoutService.DHIRAAGU_RELOAD)
|
||||
when (result.dhiraagu.type) {
|
||||
DhiraaguClient.CustType.RELOAD -> add(CardPayoutService.DHIRAAGU_RELOAD)
|
||||
DhiraaguClient.CustType.BILL_PAY -> add(CardPayoutService.DHIRAAGU_BILL)
|
||||
DhiraaguClient.CustType.UNSUPPORTED -> {}
|
||||
}
|
||||
}.map { TransferType.Card(it, result.ownerName, cards) }
|
||||
}
|
||||
|
||||
@@ -144,7 +151,9 @@ class CardPayoutTransferHandler(
|
||||
runCatching {
|
||||
val txnId = when (svc) {
|
||||
CardPayoutService.DHIRAAGU_RELOAD ->
|
||||
DhiraaguReloadClient().createBmlTransaction(number, amount.intValueExact())
|
||||
DhiraaguPaymentClient().createReloadTransaction(number, amount.intValueExact())
|
||||
CardPayoutService.DHIRAAGU_BILL ->
|
||||
DhiraaguPaymentClient().createBillPayTransaction(number, amount)
|
||||
}
|
||||
BmlMerchantTxnClient().fetchPayPage(txnId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user