Skip to content

Known limitations

The “dynamic” QRIS this service generates is a merchant’s real static QRIS with an amount tag injected locally (services/generate.ts) — it is never registered with the QRIS switching network (ASPI) the way a licensed PJSP’s dynamic QR API would. That means expires_at and payment_status are our own bookkeeping only: they control whether this gateway will match an incoming payment notification to that transaction, not whether the QR image is payable. A saved/screenshotted QR remains a scannable, valid EMV QRIS payload indefinitely; some wallet apps fall back to open-amount entry once their own view of the transaction lapses, which is network-side behavior outside this service’s control.

Mitigations in place:

  • Short default expiry (15 min).
  • The moment a transaction becomes paid/expired, its qris_string — both the stored column and everything the API returns for it (GET/list, simulate-payment) — is rewritten to the merchant’s plain static QRIS (no amount tag), via REVERT_TO_STATIC_QRIS_SQL in services/qrisCodes.ts, applied in every status-transition UPDATE (getHistory’s lazy expiry check, simulatePayment, the expiry sweep, and the inbound webhook’s paid transition). A client re-fetching a resolved transaction sees the same open-amount code it would get from qg_qris_codes directly — never a fixed-amount payload sitting next to a status implying it shouldn’t be paid at that price anymore.

None of this prevents a previously-saved image from being scanned and paid on the real network — the only real fix is moving dynamic QR generation to a licensed PJSP’s API (Midtrans, Xendit, DOKU, etc.), where the switching network itself enforces expiry. That’s a different architecture than this notification-listener design and is not implemented here.

Sandbox QRIS is deliberately never payable

Section titled “Sandbox QRIS is deliberately never payable”

Because of the limitation above — every generated QR is a real, scannable EMV payload, not a sandbox simulation registered with the switching network — a sandbox-mode QRIS code has its CRC checksum (tag 63) intentionally corrupted before storage (corruptCrc in services/qrisCodes.ts), and every qris_string generated from it gets the same treatment again (convertQRIS always recalculates CRC from scratch, so the corruption has to be reapplied per generation — see services/generate.ts). The result still looks and decodes like a normal QRIS (same merchant/amount tags) for testing your own rendering/decode flow, but any real wallet app will reject it on CRC mismatch before payment. live-mode codes are never touched.

This is why sandbox testing goes through simulate-payment rather than an actual scan — the QR was never meant to be paid at all, and now technically can’t be.

Apps with custom notification layouts aren’t readable

Section titled “Apps with custom notification layouts aren’t readable”

The App Listener’s MacroDroid macro reads a notification’s title/text via Android’s standard NotificationListenerService extras (EXTRA_TITLE, EXTRA_TEXT). Some apps instead render their notification with a custom RemoteViews layout (setCustomContentView) instead of setContentTitle/setContentText — those standard extras are left empty even though the notification clearly shows text in the shade, because that text was drawn by the app’s own layout, not stored in a field a listener can read.

com.bca.msb (Merchant BCA) is a confirmed example: capturing {not_title}/{notification} into local variables logged both as empty strings for a real payment notification, while {not_app_package} (metadata, not rendered content) came through fine. Apps that use standard setContentText (GoPay Merchant, DANA, ShopeePay — see Tested apps) are unaffected.

There’s no fix within MacroDroid’s Notification trigger for this — the text isn’t merely mismatched by the parsing regex, it never reaches the trigger’s variables at all. Recovering it would require reflection into the RemoteViews’ internal setText actions (the technique used by third-party notification readers), which MacroDroid doesn’t implement. Until an alternative device-side reader supports this, apps that render notifications this way are unsupported by the App Listener.