Known limitations
The QR image itself never truly expires
Section titled “The QR image itself never truly expires”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), viaREVERT_TO_STATIC_QRIS_SQLinservices/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 fromqg_qris_codesdirectly — 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. This is why BCA Link exists as a poller-based alternative for BCA
QR Merchant accounts specifically — there is no device-side fallback to fall back to.
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.
GoBiz Link, ShopeePay Link, and BCA Link talk to unofficial, private APIs
Section titled “GoBiz Link, ShopeePay Link, and BCA Link talk to unofficial, private APIs”GoBiz Link, ShopeePay Link, and BCA Link are all optional — off by default — and all work by calling the same private API each provider’s own merchant dashboard uses internally. None of these providers publishes or supports this as an integration, which means:
- The provider can change how login/authentication works, add rate limits, or invalidate a linked session at any time without notice — there’s no advance warning from this gateway.
- An account driven by an automated client risks being flagged or suspended, entirely at the provider’s discretion. This is outside the gateway’s control.
- None of these links are required: leave them unlinked and the App Listener alone still detects every payment from a tested app — except BCA QR Merchant, whose own app notifications the App Listener can’t read (see above), which is why BCA Link exists.
If a linked session ever breaks, payment detection never breaks with it — the affected tenant simply falls back to whatever detection it still has (App Listener and/or its other linked providers) until it’s re-linked (see each doc’s own “Session health” section).