Files
halobestie-clone/requirement/phase5-payment-catalog-plan.md
Ramadhan Sjamsani 1f6d8e09ae Phase 5.x payment catalog + customer-app splash/register polish
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>
2026-05-26 23:06:46 +08:00

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_code stores 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 icon DB column is the slug only. idn-finlogos was 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

  1. Brand mark style — happy with mono-color simple-icons/Iconify SVGs tinted to brand, or full-color marks (more polished, slight legal/maintenance overhead)?
  2. GoPay via Xendit Invoice API — supported? If not, seed as is_active=false placeholder, or skip entirely?
  3. App UX — first group expanded by default and the rest collapsed (recommended), or all expanded?
  4. Payment-code casing — store exactly as Xendit expects (uppercase) and normalise the app's outgoing method to 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:
    1. Deletes the Valkey key.
    2. Publishes config:invalidate with key payment-catalog. All backend instances drop their in-process entry on receipt (existing subscriber in mitra-status.service.js shows 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 / reorderGroups
  • createMethod / 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 to is_active=true on 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 /reorder
    • GET /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 /reorder with 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 a ListView of _GroupCards.
  • Each _GroupCard is a custom collapsible (likely a wrapper around AnimatedCrossFade for tight design control). First group expanded by default, others collapsed. No persistence.
  • Inside each group: _MethodTile driven 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
  • Group 2: "E-Wallet" (order 1)
    • OVO — OVO
    • DANA — DANA
    • GoPay — check Xendit Invoice API support during step 1; may seed as is_active=false placeholder
    • ShopeePay — SHOPEEPAY
  • Group 3: "Virtual Account" (order 2)
    • BCA — BCA_VA
    • Mandiri — MANDIRI_VA
    • BNI — BNI_VA
    • BRI — BRI_VA
    • Permata — PERMATA_VA

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

  • icon column stores the slug (qris, ovo, …).
  • _MethodTile does SvgPicture.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_METHOD on 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/method and asserts the expected groups render.
    • Maestro flow with the catalog endpoint stubbed to 500 — asserts the QRIS fallback shows and checkout completes.

10. Implementation order

  1. Migrations — groups + methods tables, plus seed.
  2. Backend service + cache + app-facing endpointGET /api/client/payment-methods.
  3. Vitest coverage for the service + cache.
  4. Bundle icon assets + add flutter_svg if not already present.
  5. Customer app — provider + collapsible UI + fallback constant.
  6. Maestro regression flow.
  7. Backend control-center routes.
  8. Control center page — groups + methods CRUD.
  9. payment.service.js::createPaymentRequest flips to catalog-backed validation.
  10. Remove _PayMethod enum + dead "paling cepat" / "e-wallet lain" code.