Payment catalog (Phase 5.x — see requirement/phase5-payment-catalog-plan.md):
- New tables payment_method_groups + payment_methods with seed (3 groups,
10 methods; GoPay seeded inactive pending Xendit channel confirmation).
- payment-catalog.service.js with two-layer cache (60s in-process + 1h
Valkey) and config:invalidate pub/sub fanout. Mutator API + casing-
tolerant findActiveMethodByCode for downstream validation.
- App-facing GET /api/client/payment-methods returns pre-grouped JSON,
active-only, empty groups dropped server-side.
- POST /api/client/payment-requests now validates `method` against the
catalog (INVALID_PAYMENT_METHOD 422) and stamps
product_metadata.preferred_payment_code (upper-cased).
- Control-center /internal/payment-{groups,methods}{,/:id,/reorder}
endpoints (full CRUD + idempotent reorder). New Payment Catalog page
wired into the CC nav.
- Customer app renders the catalog as collapsible groups (first expanded)
via paymentCatalogProvider; QRIS-only hardcoded fallback on 5xx so
checkout never hard-fails. Replaces the hardcoded _PayMethod enum.
- 10 brand SVGs (~63KB) bundled in client_app/assets/payment_icons/ from
github.com/hafidznoor/idn-finlogos. Xendit's per-channel media-asset
pages were planned but found decommissioned during implementation —
switched to idn-finlogos with the standard "channels-we-accept"
trademark posture. See assets/payment_icons/README.md for the workflow
to add new methods.
- 16 vitest cases covering the service + cache; full backend suite green
(162/162).
Customer-app splash + register polish:
- Splash rewritten per figma S1: warm vertical gradient, two ImageFiltered
radial orbs, 96×96 rounded-square logo tile, "HaloBestie" + "kamu gak
harus ngerasain ini sendirian." Self-driving navigation via context.go
after a 2.5s post-frame timer (native Android splash burns ~1-1.5s
before Flutter paints — 1s timer yielded near-zero visible duration).
Router early-returns null for isSplash so it never moves us off /splash
on its own.
- 3-page onboarding carousel removed: user clarified the new splash
REPLACES that carousel. Dropped /onboarding route, OnboardingScreen,
onboardingDoneProvider + gating, dead splash_{1,2,3}.png + the
splash_chat_hebat.png Flutter asset. Phase 4 /onboarding/* subroutes
untouched; Android-native launch_background drawable left alone.
- Register screen (login-by-phone) polished: circular pink back button +
72×72 logo badge (same brandLogoBg pink as splash, Transform.scale 1.4
to fill the tile). Step-dots indicator removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
Phase 5.x — Catalog-driven payment methods
Status: shipped 2026-05-26 (steps 1-5 + 7-9). Step 6 (Maestro flow) deferred. See project-payment-catalog-shipped-2026-05-26 memory note for the runtime status and follow-ups.
Goal
Replace the hardcoded _PayMethod enum + ad-hoc sections in client_app/lib/features/payment/screens/payment_method_screen.dart with a DB-backed catalog edited from the control center, cached in Valkey with Postgres as the source of truth, rendered in the customer app as collapsible groups (empty groups hidden, ordered by group then by method within each group).
Decisions locked in
payment_codestores the Xendit channel code directly (OVO,DANA,QRIS,BCA_VA, …). No mapping layer.- Icon strategy: bundled SVG slugs in the app, sourced from Xendit's official media-assets pages (merchant-licensed for the channels we transact through). The
iconDB column is the slug only.idn-finlogoswas evaluated and dropped — CC BY-NC + per-brand permission caveat makes it riskier than the Xendit-supplied kit. - Control center scope: full CRUD for both methods and groups.
- Cold-fail UX (Valkey + Postgres both unreachable): customer app falls back to a hardcoded minimal catalog (QRIS only) so checkout never hard-fails.
Open questions to confirm before kickoff
- Brand mark style — happy with mono-color simple-icons/Iconify SVGs tinted to brand, or full-color marks (more polished, slight legal/maintenance overhead)?
- GoPay via Xendit Invoice API — supported? If not, seed as
is_active=falseplaceholder, or skip entirely? - App UX — first group expanded by default and the rest collapsed (recommended), or all expanded?
- Payment-code casing — store exactly as Xendit expects (uppercase) and normalise the app's outgoing
methodto match?
1. Schema
Two new tables. Both end up under backend/src/db/migrate.js as idempotent CREATE TABLE IF NOT EXISTS blocks.
payment_method_groups
| col | type | notes |
|---|---|---|
id |
uuid PK |
|
name |
text NOT NULL |
display name shown in app (e.g. "QRIS & Virtual Account") |
display_order |
int NOT NULL DEFAULT 0 |
lower = shown first |
is_active |
bool NOT NULL DEFAULT true |
hard-hide regardless of children |
created_at, updated_at |
timestamptz |
Index: (display_order).
payment_methods
| col | type | notes |
|---|---|---|
id |
uuid PK |
|
group_id |
uuid NOT NULL REFERENCES payment_method_groups(id) ON DELETE RESTRICT |
|
display_name |
text NOT NULL |
e.g. "OVO" |
payment_code |
text NOT NULL UNIQUE |
Xendit channel code |
display_order |
int NOT NULL DEFAULT 0 |
order within the group |
icon |
text |
bundled-asset slug; nullable so a missing asset never blocks the row |
is_active |
bool NOT NULL DEFAULT true |
|
created_at, updated_at |
timestamptz |
Index: (group_id, display_order).
Rationale for ON DELETE RESTRICT: orphaning methods on group delete is almost always a control-center mistake. Surface it as an explicit error in the CC UI.
2. Cache layer (Valkey + in-process)
Single cache key for the whole catalog — <50 rows total. Mirrors the agreed config:invalidate design.
- Cache key:
payment-catalog:v1 - Shape — pre-grouped JSON the app endpoint returns verbatim:
{ "groups": [
{ "id": "...", "name": "QRIS & VA", "methods": [
{ "id": "...", "payment_code": "QRIS", "display_name": "QRIS", "icon": "qris" }
]}
]}
- TTL: 1 h Valkey + 60 s in-process map (in-process avoids round-tripping Valkey on hot paths like checkout open).
- Read path: in-process → Valkey → Postgres. Each miss promotes upward.
- Write path — any CC mutation (create / update / delete / toggle / reorder on either table) calls
invalidatePaymentCatalog()which:- Deletes the Valkey key.
- Publishes
config:invalidatewith keypayment-catalog. All backend instances drop their in-process entry on receipt (existing subscriber inmitra-status.service.jsshows the pattern).
- Fallback at boot — if Valkey is unreachable, skip it and go straight to Postgres. If Postgres is also down, the public endpoint returns 503 and the app uses its hardcoded fallback (§5).
3. Backend services & routes
New service
backend/src/services/payment-catalog.service.js:
listCatalog()— pre-grouped JSON for the app endpoint (cache-aware)listGroups()/listMethods()— flat lists for CC (cache-aware)createGroup/updateGroup/deleteGroup/reorderGroupscreateMethod/updateMethod/deleteMethod/reorderMethods/toggleMethodActive- All mutators end with
invalidatePaymentCatalog()
Validation hook
In payment.service.js::createPaymentRequest, replace the implicit "any string" check on method with assertPaymentCodeIsValid(method) → looks up the cached catalog, throws INVALID_PAYMENT_METHOD if the code is missing or inactive. Normalise to upper-case before lookup.
Routes
- App-facing:
GET /api/client/payment-methods→ returns{ groups: [...] }filtered tois_active=trueon both group and method, with empty groups removed in-place. Authenticated (existing JWT middleware). - Control center (under
/internal):GET /internal/payment-groups|POST|PATCH /:id|DELETE /:id|POST /reorderGET /internal/payment-methods|POST|PATCH /:id|DELETE /:id|POST /reorder
Tests
Vitest cases for: cache miss/hit/invalidation, INVALID_PAYMENT_METHOD on stale code, empty-group filter on app endpoint, ON DELETE RESTRICT enforcement, normalisation of lower-case method input.
4. Control center UI
New page: control_center/src/pages/payment-catalog/PaymentCatalogPage.jsx.
- Two sections (stacked):
- Groups: inline-editable list. Drag-handle for reorder (or ↑/↓ buttons if drag is too much). Each row: name, order, active toggle, delete (disabled when methods reference it).
- Methods: list filtered by selected group, drag-handle for reorder. Each row: display name, payment_code, icon picker (dropdown of bundled slugs + free-text override), active toggle, delete.
- + Add group / + Add method open a modal.
- Reorder fires
POST /reorderwith the full ordered array (idempotent — avoids per-row order bugs under concurrent edits). - Add a new nav entry "Payment Catalog" alongside Settings.
5. Customer app
New provider
client_app/lib/features/payment/state/payment_catalog_provider.dart:
- Calls
GET /api/client/payment-methods. - On 5xx / network error → returns
_kFallbackCatalog.
Screen rewrite
In payment_method_screen.dart:
- Replace
enum _PayMethod+ the two hardcoded sections with aListViewof_GroupCards. - Each
_GroupCardis a custom collapsible (likely a wrapper aroundAnimatedCrossFadefor tight design control). First group expanded by default, others collapsed. No persistence. - Inside each group:
_MethodTiledriven by the catalog row (display_name + icon). - Selected state becomes
String? selectedPaymentCode. - POST body stays
{ method: selectedPaymentCode }— backend API unchanged.
Fallback constant
const _kFallbackCatalog = [
( name: 'Paling Cepat', methods: [
( payment_code: 'QRIS', display_name: 'QRIS', icon: 'qris' ),
]),
];
Minimal — QRIS only — so users can always pay even if the catalog endpoint is down.
6. Initial seed
Migration block that inserts:
- Group 1: "Paling Cepat" (order 0)
- QRIS —
QRIS
- QRIS —
- Group 2: "E-Wallet" (order 1)
- OVO —
OVO - DANA —
DANA - GoPay — check Xendit Invoice API support during step 1; may seed as
is_active=falseplaceholder - ShopeePay —
SHOPEEPAY
- OVO —
- Group 3: "Virtual Account" (order 2)
- BCA —
BCA_VA - Mandiri —
MANDIRI_VA - BNI —
BNI_VA - BRI —
BRI_VA - Permata —
PERMATA_VA
- BCA —
Cross-check exact channel codes against Xendit Invoice API docs before applying — channel codes differ subtly between Invoice API and per-channel APIs.
7. Icon assets
Bundled SVGs in client_app/assets/payment_icons/{slug}.svg (slug = lowercased payment_code by convention, but stored independently so we can deviate).
Sourcing — idn-finlogos (decision reversed mid-implementation)
Original plan: Xendit's per-channel media-assets pages, used as the sole source so the licensing story was "we're a Xendit merchant displaying the channels we accept".
What happened: during step 4 we discovered that docs.xendit.co/ewallet/media-assets
and docs.xendit.co/qr-codes/media-assets now permanently redirect to
docs.xendit.co/ (HTTP 301). The pages have been decommissioned. The Wayback
Machine still has a snapshot from 2025-05, but it was a JS-rendered SPA back
then too — the archived HTML has no actual SVG URLs.
What shipped: SVGs copied from the
idn-finlogos GitHub repo icons/
directory directly into client_app/assets/payment_icons/. We don't depend
on the pub.dev idn_finlogos package because Flutter doesn't tree-shake
assets — adding the package would ship all 572 SVGs (≈4-6 MB). The manual
per-file copy keeps the APK lean: 10 marks ≈ 63 KB.
Workflow for adding a new method documented in client_app/assets/payment_icons/README.md.
Upstream licensing (assets/payment_icons/NOTICE_IDN_FINLOGOS.txt): MIT for
build tooling, CC BY-NC 4.0 for the SVG assets, plus an upstream note that
"commercial use of any individual brand mark requires permission from that
brand." We're shipping anyway under the standard fintech "channels-we-accept"
trademark posture — same legal stance every payment-aware app uses. Real
brand-permission negotiation is a follow-up when we go to prod.
Wiring
iconcolumn stores the slug (qris,ovo, …)._MethodTiledoesSvgPicture.asset('assets/payment_icons/$icon.svg')and falls back to a generic "payment" icon when the slug isn't bundled.- Control center picker exposes the slugs the app currently ships, plus a free-text override.
Consequence
Adding a new payment method server-side works immediately (with the generic fallback icon). Branded icon requires an app release. Acceptable tradeoff.
Dependency
flutter_svg — verify it's already in client_app/pubspec.yaml during implementation.
8. Migration from the current hardcoded enum
- Current app sends
method: qris(lower-case enum value). - Backend normalises to upper-case before catalog lookup → keeps already-installed apps working without forcing an update.
- Once the catalog is shipped and seeded, the old enum + "paling cepat" / "e-wallet lain" sections can be deleted in the same PR.
9. Validation gates & tests
- Backend: vitest — cache hit/miss/invalidate,
INVALID_PAYMENT_METHODon stale code, empty-group filter,ON DELETE RESTRICT, casing normalisation. - Control center: manual smoke for add / edit / delete / reorder on both tables; verify reorder is idempotent under concurrent edits.
- Customer app:
- Maestro flow visits
/payment/methodand asserts the expected groups render. - Maestro flow with the catalog endpoint stubbed to 500 — asserts the QRIS fallback shows and checkout completes.
- Maestro flow visits
10. Implementation order
- Migrations — groups + methods tables, plus seed.
- Backend service + cache + app-facing endpoint —
GET /api/client/payment-methods. - Vitest coverage for the service + cache.
- Bundle icon assets + add
flutter_svgif not already present. - Customer app — provider + collapsible UI + fallback constant.
- Maestro regression flow.
- Backend control-center routes.
- Control center page — groups + methods CRUD.
payment.service.js::createPaymentRequestflips to catalog-backed validation.- Remove
_PayMethodenum + dead "paling cepat" / "e-wallet lain" code.