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>
This commit is contained in:
2026-05-26 23:06:46 +08:00
parent d60c048776
commit 1f6d8e09ae
39 changed files with 2634 additions and 370 deletions

View File

@@ -0,0 +1,261 @@
# 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`](../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:
```json
{ "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`](../backend/src/services/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 `_GroupCard`s.
- 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
```dart
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](https://github.com/hafidznoor/idn-finlogos) `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](../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 endpoint**`GET /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.