Files
halobestie-clone/requirement/phase5-payment-revamp-2026-05-27.md
Ramadhan Sjamsani 2c95fd040d Phase 5.x payment revamp + Xendit Stage-8 prep
- 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>
2026-05-27 21:33:51 +08:00

11 KiB
Raw Permalink Blame History

Phase 5.x — Payment catalog + Xendit prep revamp (2026-05-27)

Status: shipped 2026-05-27 as Stage-8 prep. XENDIT_ENABLED still false. 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:

  1. Backend-served icons so adding a new method doesn't require an app release.
  2. Authoritative Xendit channel codes so createInvoice doesn't 400 on first call.
  3. 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/*.svg once at boot, exposes hasIconSlug(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+xml and Cache-Control: public, max-age=31536000, immutable (1 year — content is stable per idn-finlogos version)
    • 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.1 added. New PaymentIcon widget uses DefaultCacheManager().getSingleFile(url) + SvgPicture.file, falls back to bundled assets/payment_icons/placeholder.svg while loading or when URL is null. Deleted the 10 bundled brand SVGs; only placeholder.svg stays.

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 floor
  • max_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/success
  • GET /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 MyApp widget_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:

  1. 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.
  2. client_app pairing_notifier.dart:166 — remove legacy POST /chat/request.
  3. client_app session_closure_notifier.dart:75 — extension flow Custom Tab.