- 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>
224 lines
11 KiB
Markdown
224 lines
11 KiB
Markdown
# 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.
|