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

@@ -1145,6 +1145,118 @@ const migrate = async () => {
WHERE payment_request_id IS NOT NULL
`
// --- Phase 5.x: Payment catalog (groups + methods) ----------------------
//
// DB-backed payment method catalog edited from control center, cached in
// Valkey by payment-catalog.service.js. Replaces the hardcoded _PayMethod
// enum in client_app/lib/features/payment/screens/payment_method_screen.dart.
// See requirement/phase5-payment-catalog-plan.md.
//
// `payment_code` stores the Xendit channel code (OVO, DANA, QRIS, BCA_VA,
// …) verbatim — no mapping layer. UPPER-CASE by convention; the service
// layer normalises incoming app-submitted codes to upper before lookup.
await sql`
CREATE TABLE IF NOT EXISTS payment_method_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
display_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_payment_method_groups_order
ON payment_method_groups (display_order)
`
await sql`
CREATE TABLE IF NOT EXISTS payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES payment_method_groups(id) ON DELETE RESTRICT,
display_name TEXT NOT NULL,
payment_code TEXT NOT NULL UNIQUE,
display_order INTEGER NOT NULL DEFAULT 0,
icon TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_payment_methods_group_order
ON payment_methods (group_id, display_order)
`
// Seed: only when the groups table is empty. Once seeded, operators edit via
// CC; we never re-seed (avoid clobbering custom orderings).
const [{ n: groupCount }] = await sql`
SELECT COUNT(*)::int AS n FROM payment_method_groups
`
if (groupCount === 0) {
const PAYMENT_CATALOG_SEED = [
{
name: 'Paling Cepat',
order: 0,
methods: [
{ code: 'QRIS', display: 'QRIS', icon: 'qris', active: true },
],
},
{
name: 'E-Wallet',
order: 1,
methods: [
{ code: 'OVO', display: 'OVO', icon: 'ovo', active: true },
{ code: 'DANA', display: 'DANA', icon: 'dana', active: true },
{ code: 'SHOPEEPAY', display: 'ShopeePay', icon: 'shopeepay', active: true },
// Xendit Invoice API doesn't expose GoPay (Gojek/GoPay relationship).
// Seeded as inactive so it surfaces in CC but is hidden from the app
// until we either confirm a Xendit channel or remove it entirely.
{ code: 'GOPAY', display: 'GoPay', icon: 'gopay', active: false },
],
},
{
name: 'Virtual Account',
order: 2,
methods: [
{ code: 'BCA_VA', display: 'BCA Virtual Account', icon: 'bca', active: true },
{ code: 'MANDIRI_VA', display: 'Mandiri Virtual Account', icon: 'mandiri', active: true },
{ code: 'BNI_VA', display: 'BNI Virtual Account', icon: 'bni', active: true },
{ code: 'BRI_VA', display: 'BRI Virtual Account', icon: 'bri', active: true },
{ code: 'PERMATA_VA', display: 'Permata Virtual Account', icon: 'permata', active: true },
],
},
]
for (const group of PAYMENT_CATALOG_SEED) {
const [{ id: groupId }] = await sql`
INSERT INTO payment_method_groups (name, display_order, is_active)
VALUES (${group.name}, ${group.order}, true)
RETURNING id
`
let methodOrder = 0
for (const m of group.methods) {
await sql`
INSERT INTO payment_methods (
group_id, display_name, payment_code, display_order, icon, is_active
)
VALUES (
${groupId},
${m.display},
${m.code},
${methodOrder++},
${m.icon},
${m.active}
)
ON CONFLICT (payment_code) DO NOTHING
`
}
}
}
console.log('Migration complete.')
await sql.end()
}