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>
262 lines
12 KiB
Markdown
262 lines
12 KiB
Markdown
# 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.
|