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

224 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.