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>
This commit is contained in:
2026-05-27 21:33:51 +08:00
parent 1f6d8e09ae
commit 2c95fd040d
53 changed files with 2389 additions and 832 deletions

View File

@@ -0,0 +1,223 @@
# 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](phase5-xendit-plan.md) for the umbrella Xendit work.
> This doc extends [phase5-payment-catalog-plan.md](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](../backend/src/services/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](../backend/src/routes/public/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](../backend/src/routes/internal/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](../backend/src/routes/public/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](../backend/.dev/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](../backend/src/routes/public/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](../client_app/android/app/src/main/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](../client_app/ios/Runner/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.