# 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 | `_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 `` 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.