- Backend wraps idn-finlogos npm at /assets/payment-icons/<slug>.svg with
1y immutable cache. Mobile drops bundled SVGs (only placeholder remains)
and fetches via flutter_cache_manager. payment_methods.icon is now a
CSV of slugs; catalog emits icon_urls[]. CARDS tile renders Visa + MC +
JCB side by side.
- Per-method min/max amount bounds (BIGINT, nullable). Picker greys out
out-of-range tiles with subtitle; backend gates with INVALID_PAYMENT_AMOUNT
(422). Defense in depth against stale-catalog clients.
- Xendit channel codes corrected from authoritative docs
(BCA_VA -> BCA_VIRTUAL_ACCOUNT, CREDIT_CARD -> CARDS, ovo -> ovo-new,
shopeepay -> shopee-pay, ...). 18 methods x 5 groups seeded with
Xendit-published per-channel min/max.
- Re-runnable seed (ON CONFLICT DO NOTHING on payment_code + new unique
index on group name). Operator CC edits never clobbered across re-runs.
One-shot reset + inspect scripts under backend/.dev/.
- Customer redirect HTML pages at /payment/return/{success,failure},
brand-styled with "Buka HaloBestie" CTA firing halobestie:// deeplink.
URL scheme registered on Android (intent-filter w/ BROWSABLE on
MainActivity) and iOS (CFBundleURLTypes). Waiting-payment poller still
owns confirmation; deeplink just brings the activity to foreground.
- Control center payment-catalog page: min/max inputs + columns. Other
CC pages restyled with new theme tokens (separate work, bundled here).
169/169 backend tests pass. See requirement/phase5-payment-revamp-2026-05-27.md
for the full revamp doc. Stage 8 (E2E) still pending: webhook URL routing
decision + two client_app follow-ups (legacy /chat/request removal,
extension Custom Tab).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
Phase 5.x — Payment catalog + Xendit prep revamp (2026-05-27)
Status: shipped 2026-05-27 as Stage-8 prep.
XENDIT_ENABLEDstillfalse. See project-2026-05-27-payment-xendit-prep memory note for runtime status, and phase5-xendit-plan.md for the umbrella Xendit work. This doc extends phase5-payment-catalog-plan.md (the original catalog work landed 2026-05-26 with bundled icons + best-guess codes; this revamp corrects both and adds amount bounds).
Goal
Get the payment-method catalog into a Xendit-faithful, app-release-free shape so we
can flip XENDIT_ENABLED=true without touching the mobile build. Three lifts:
- Backend-served icons so adding a new method doesn't require an app release.
- Authoritative Xendit channel codes so
createInvoicedoesn't 400 on first call. - Per-method amount bounds so the picker greys out methods the bill misses and the backend rejects out-of-range early.
Plus customer-redirect HTML pages + halobestie:// deeplink so the post-payment UX
brings the customer back into the app.
1. Icon hosting (backend wraps idn-finlogos npm)
Replaces the 10 bundled SVGs + _kBundledSlugs allowlist that shipped 2026-05-26.
Why: old model required an app release + asset copy for every new icon. New model: backend owns all 572 idn-finlogos brand SVGs; mobile fetches + caches.
- Backend:
npm i idn-finlogos@2.3.0(27 MB on disk, zero runtime deps, 572 SVGs) - New service payment-icon.service.js
loads the slug set from
dist/icons/*.svgonce at boot, exposeshasIconSlug(slug)+resolveIconPath(slug)+listIconSlugs(). - New public route shared.payment-icons.routes.js:
GET /assets/payment-icons/:slug.svg- Validates slug against a path-traversal regex + the boot-time set
- Streams the file with
Content-Type: image/svg+xmlandCache-Control: public, max-age=31536000, immutable(1 year — content is stable peridn-finlogosversion) - 404 on unknown slug (so CC typos surface during testing, not silently)
- New internal route payment-icons.routes.js:
GET /internal/payment-icons → { slugs: [...] }for a future CC dropdown populated from a known set. Not wired into the CC UI yet — operator still types slug as free text. - Mobile:
flutter_cache_manager: ^3.3.1added. NewPaymentIconwidget usesDefaultCacheManager().getSingleFile(url)+SvgPicture.file, falls back to bundledassets/payment_icons/placeholder.svgwhile loading or when URL is null. Deleted the 10 bundled brand SVGs; onlyplaceholder.svgstays.
License: idn-finlogos LICENSE-ASSETS is CC-BY-NC-4.0 + per-brand
permission. Same legal posture as before — centralized on backend now (one
NOTICE file to maintain instead of one in the app bundle).
2. Multi-icon support
The Kartu Kredit row visually represents Visa + Mastercard + JCB on a single
tile. The payment_methods.icon column is now a comma-separated slug list:
- Single-icon methods:
icon = 'ovo-new'(1 slug) - Multi-icon methods:
icon = 'visa,mastercard,jcb'
Backend parses the CSV in buildCatalogFromDb and emits icon_urls: string[]
(replacing the singular icon_url shipped earlier today). The Flutter
_MethodIconBox widget renders:
- 40×40 container with one 22px icon (single) OR
- Auto-width container with 18px icons + 3px gaps (multi)
Catalog cache key bumped v3 → v4 to avoid serving stale-shape entries from
Valkey for up to VALKEY_TTL_SECONDS after deploy.
3. Xendit-accurate channel codes
The 2026-05-26 seed used BCA_VA, MANDIRI_VA, etc. and CREDIT_CARD — all
pattern-matched from other PG APIs, none of which are what Xendit's Invoice
API actually accepts. Codes verified against
https://docs.xendit.co/docs/available-payment-channels:
| Domain | Xendit Invoice code |
|---|---|
| Bank VA | <BANK>_VIRTUAL_ACCOUNT (BCA_VIRTUAL_ACCOUNT, etc.) |
| E-Wallet | OVO, DANA, SHOPEEPAY, LINKAJA, ASTRAPAY |
| Outlet | ALFAMART, INDOMARET |
| QRIS | QRIS |
| Cards | CARDS (single channel covers Visa/MC/JCB/Amex) |
Also: the original icon slugs ovo and shopeepay don't exist in
idn-finlogos — correct slugs are ovo-new and shopee-pay. See
feedback-xendit-invoice-channel-codes for the full slug-vs-code reference.
4. Per-method amount bounds
Two new BIGINT columns on payment_methods:
min_amount— inclusive Rupiah floormax_amount— inclusive Rupiah ceiling
Both nullable (null = no bound). BIGINT because Xendit documents some maxes at Rp 50,000,000,000 (50B) — past INT range.
UX: picker tile renders at 50% opacity with subtitle "min Rp 10.000" or
"maks Rp 1.000.000" when the current bill misses the bound. Tap is no-op.
Backend gate: client.payment.routes.js
rejects with INVALID_PAYMENT_AMOUNT (422) when amount < min or amount > max,
returning { amount, min_amount, max_amount } in details. Defense in depth
against a stale-catalog client.
5. Re-runnable seed
Old gate: if (groupCount === 0) — ran once, never again. Once seeded, the
operator was on their own.
New: every migrate run executes the seed; groups upsert via ON CONFLICT (name) DO NOTHING (needs new unique index on payment_method_groups.name); methods
via ON CONFLICT (payment_code) DO NOTHING. Operator edits in CC are never
clobbered. New methods added to the seed list later land on the next migration;
existing rows aren't touched.
For fully clean state (one-time wipe) use reset-payment-catalog.js:
cd backend
node .dev/reset-payment-catalog.js
node src/db/migrate.js
node .dev/inspect-payment-catalog.js # sanity dump
6. Seeded catalog (18 methods, 5 groups)
All values verified from Xendit docs 2026-05-27.
| Group | Display | payment_code | icon | min Rp | max Rp |
|---|---|---|---|---|---|
| Paling Cepat | QRIS | QRIS |
qris |
1.500 | 20.000.000 |
| E-Wallet | OVO | OVO |
ovo-new |
100 | 20.000.000 |
| E-Wallet | DANA | DANA |
dana |
100 | 20.000.000 |
| E-Wallet | ShopeePay | SHOPEEPAY |
shopee-pay |
1 | 20.000.000 |
| E-Wallet | LinkAja | LINKAJA |
linkaja |
1 | 20.000.000 |
| E-Wallet | AstraPay | ASTRAPAY |
astra-pay |
100 | 20.000.000 |
| Virtual Account | BCA VA | BCA_VIRTUAL_ACCOUNT |
bca |
10.000 | 50.000.000 |
| Virtual Account | Mandiri VA | MANDIRI_VIRTUAL_ACCOUNT |
mandiri |
1 | 50.000.000.000 |
| Virtual Account | BNI VA | BNI_VIRTUAL_ACCOUNT |
bni |
1 | 50.000.000 |
| Virtual Account | BRI VA | BRI_VIRTUAL_ACCOUNT |
bri |
1 | 50.000.000.000 |
| Virtual Account | BSI VA | BSI_VIRTUAL_ACCOUNT |
bsi |
1 | 50.000.000.000 |
| Virtual Account | Permata VA | PERMATA_VIRTUAL_ACCOUNT |
permata |
1 | 9.999.999.999 |
| Virtual Account | CIMB VA | CIMB_VIRTUAL_ACCOUNT |
cimb-niaga |
1 | 50.000.000 |
| Virtual Account | BJB VA | BJB_VIRTUAL_ACCOUNT |
bank-bjb |
1 | 2.000.000.000 |
| Virtual Account | BSS VA | BSS_VIRTUAL_ACCOUNT |
bank-sahabat-sampoerna |
1 | 50.000.000.000 |
| Outlet | Alfamart | ALFAMART |
alfamart |
10.000 | 5.000.000 |
| Outlet | Indomaret | INDOMARET |
indomaret |
10.000 | 2.500.000 |
| Kartu Kredit | Kartu Kredit | CARDS |
visa,mastercard,jcb |
5.000 | 200.000.000 |
GoPay is gone from the seed (Xendit Invoice API doesn't expose a GoPay channel; was seeded inactive previously, now skipped entirely on fresh DBs).
7. Customer redirect pages
New brand-styled HTML pages served by the backend at:
GET /payment/return/successGET /payment/return/failure
Implementation: payment-return.routes.js.
Single template function, ~140 lines, brand colors mirror halo_tokens.dart
(pink #E17A9D for success, red #D86B6B for failure). Bricolage Grotesque
headline + Poppins body via Google Fonts. Success/failure glyph in a soft
circle, headline, body copy, "Buka HaloBestie" CTA button firing
halobestie://payment/return?status=…, soft hint "atau tutup tab ini".
Set in .env:
XENDIT_SUCCESS_REDIRECT_URL=http://192.168.88.247:3000/payment/return/success
XENDIT_FAILURE_REDIRECT_URL=http://192.168.88.247:3000/payment/return/failure
For prod swap the host: https://api.halobestie.com/payment/return/....
8. halobestie:// URL scheme
Registered on both platforms so the CTA button on the redirect pages brings the app to foreground when tapped from Chrome Custom Tab / browser.
Android (AndroidManifest.xml) —
new <intent-filter android:autoVerify="false"> on .MainActivity with
VIEW + DEFAULT + BROWSABLE + scheme="halobestie". BROWSABLE is mandatory
for browser/Custom Tab link resolution — without it, taps from a browser go
nowhere.
iOS (Info.plist) — CFBundleURLTypes
array with CFBundleURLName=com.mybestie + CFBundleURLSchemes=["halobestie"].
No Flutter URI consumption today. The waiting-payment screen's poller
already detects payment confirmation independently via GET /payment-requests/:id.
The deeplink just bounces the activity to the foreground; the singleTop launch
mode reuses the existing instance. If we need to react to URI params later
(e.g. to deep-link past the waiting screen), add the app_links package and
listen to its stream from AuthBridge or main.
Gotcha: manifest/plist changes don't hot-reload. Stop flutter run (q)
and re-run, or flutter build apk --debug + reinstall.
9. Tests + verification
- 169/169 backend tests pass after the revamp.
- Flutter analyze clean (pre-existing
MyAppwidget_test.dart error untouched). - Manual smoke: backend public routes verified via
curl:/assets/payment-icons/qris.svg→ 200,image/svg+xml, 914 bytes/payment/return/success→ 200, 3102 bytes/payment/return/failure→ 200, 3117 bytes/api/client/payment-methods→ 401 (auth required, expected)
What's still pending before XENDIT_ENABLED=true
See project-xendit-integration-next §"Status update 2026-05-27" for the full pickup checklist. Headline blockers:
- Webhook URL routing — Xendit calls one URL per env; existing app owns
the prod one. Three options on the table (separate dev account, external
router on existing URL via
metadata.app="halobestie_v2", or self-poll script). User to decide. - client_app
pairing_notifier.dart:166— remove legacyPOST /chat/request. - client_app
session_closure_notifier.dart:75— extension flow Custom Tab.