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,351 @@
/**
* Payment catalog service — Phase 5.x.
*
* DB-backed list of payment methods and groups, cached at two levels:
* L1: in-process Map (60s TTL) — hot read avoids round-tripping Valkey.
* L2: Valkey GET/SETEX (1h TTL) — cross-process source.
* L3: Postgres — source of truth.
*
* Invalidation: any mutator calls invalidatePaymentCatalog(), which:
* 1. DEL the Valkey key.
* 2. Publish `config:invalidate` with key='payment-catalog'. Other backend
* instances drop their L1 entry on receipt (subscriber below).
*
* App-facing shape (returned by getCatalogForApp): groups pre-filtered to
* is_active=true (both group and method), empty groups dropped, ordered by
* display_order then method display_order. The customer app renders this
* verbatim.
*
* Control-center shape (returned by listGroups / listMethods): flat lists
* with all rows incl. inactive. The CC page filters / edits client-side.
*
* See requirement/phase5-payment-catalog-plan.md.
*/
import { getDb } from '../db/client.js'
import { getValkeyClient, publish, subscribe } from '../plugins/valkey.js'
const sql = getDb()
const CACHE_KEY = 'payment-catalog:v1'
const VALKEY_TTL_SECONDS = 60 * 60 // 1 hour
const INPROCESS_TTL_MS = 60 * 1000 // 60 seconds
const INVALIDATE_CHANNEL = 'config:invalidate'
const INVALIDATE_KEY = 'payment-catalog'
// L1 (in-process) cache. One entry — the whole catalog. { value, expiresAt }
let l1Entry = null
const l1Get = () => {
if (!l1Entry) return null
if (Date.now() > l1Entry.expiresAt) {
l1Entry = null
return null
}
return l1Entry.value
}
const l1Set = (value) => {
l1Entry = { value, expiresAt: Date.now() + INPROCESS_TTL_MS }
}
const l1Clear = () => {
l1Entry = null
}
const valkeyGet = async () => {
try {
const raw = await getValkeyClient().get(CACHE_KEY)
return raw ? JSON.parse(raw) : null
} catch (_) {
// Valkey down — fall through to DB.
return null
}
}
const valkeySet = async (value) => {
try {
await getValkeyClient().setex(CACHE_KEY, VALKEY_TTL_SECONDS, JSON.stringify(value))
} catch (_) {
// Best-effort — DB stays source of truth.
}
}
const valkeyDel = async () => {
try {
await getValkeyClient().del(CACHE_KEY)
} catch (_) {
// Best-effort.
}
}
/**
* Build the full catalog from Postgres in the app-facing shape (active rows
* only, empty groups dropped, ordered). Returned object is cached as-is.
*/
const buildCatalogFromDb = async () => {
const rows = await sql`
SELECT
g.id AS group_id,
g.name AS group_name,
g.display_order AS group_order,
m.id AS method_id,
m.payment_code AS payment_code,
m.display_name AS display_name,
m.icon AS icon,
m.display_order AS method_order
FROM payment_method_groups g
JOIN payment_methods m ON m.group_id = g.id
WHERE g.is_active = true AND m.is_active = true
ORDER BY g.display_order ASC, m.display_order ASC
`
// Group methods under their groups in the order returned.
const byGroupId = new Map()
for (const r of rows) {
if (!byGroupId.has(r.group_id)) {
byGroupId.set(r.group_id, {
id: r.group_id,
name: r.group_name,
order: r.group_order,
methods: [],
})
}
byGroupId.get(r.group_id).methods.push({
id: r.method_id,
payment_code: r.payment_code,
display_name: r.display_name,
icon: r.icon,
order: r.method_order,
})
}
// Groups with zero active methods are excluded by the inner JOIN; empty
// arrays here are impossible. No extra filter needed.
return { groups: Array.from(byGroupId.values()) }
}
/**
* App-facing catalog (cached, active-only, grouped). Returns `{ groups: [...] }`.
*
* Read path: L1 → L2 (Valkey) → DB. Each miss promotes upward.
*/
export const getCatalogForApp = async () => {
const cached = l1Get()
if (cached) return cached
const fromValkey = await valkeyGet()
if (fromValkey) {
l1Set(fromValkey)
return fromValkey
}
const fresh = await buildCatalogFromDb()
l1Set(fresh)
await valkeySet(fresh)
return fresh
}
/**
* Invalidate every layer. Call after any mutation.
*/
export const invalidatePaymentCatalog = async () => {
l1Clear()
await valkeyDel()
try {
await publish(INVALIDATE_CHANNEL, { key: INVALIDATE_KEY, ts: Date.now() })
} catch (_) {
// Best-effort — other instances will recover via their own L1 TTL.
}
}
// Cross-instance L1 invalidation: drop in-process entry when another node
// signals a catalog mutation. Idempotent re-subscribe in case the service is
// hot-reloaded in dev.
let isSubscribed = false
const ensureSubscribed = () => {
if (isSubscribed) return
isSubscribed = true
try {
subscribe(INVALIDATE_CHANNEL, (msg) => {
if (msg?.key === INVALIDATE_KEY) {
l1Clear()
}
})
} catch (_) {
isSubscribed = false
}
}
ensureSubscribed()
// --- Control-center read paths (flat, includes inactive) ---------------------
export const listGroups = async () => {
const rows = await sql`
SELECT id, name, display_order, is_active, created_at, updated_at
FROM payment_method_groups
ORDER BY display_order ASC, name ASC
`
return rows
}
export const listMethods = async ({ groupId = null } = {}) => {
const rows = groupId
? await sql`
SELECT id, group_id, display_name, payment_code, display_order,
icon, is_active, created_at, updated_at
FROM payment_methods
WHERE group_id = ${groupId}
ORDER BY display_order ASC, display_name ASC
`
: await sql`
SELECT id, group_id, display_name, payment_code, display_order,
icon, is_active, created_at, updated_at
FROM payment_methods
ORDER BY display_order ASC, display_name ASC
`
return rows
}
// --- Catalog mutators (used by control-center routes) ------------------------
export const createGroup = async ({ name, displayOrder = 0, isActive = true }) => {
const [row] = await sql`
INSERT INTO payment_method_groups (name, display_order, is_active)
VALUES (${name}, ${displayOrder}, ${isActive})
RETURNING id, name, display_order, is_active
`
await invalidatePaymentCatalog()
return row
}
export const updateGroup = async (id, patch) => {
const [row] = await sql`
UPDATE payment_method_groups
SET
name = COALESCE(${patch.name ?? null}, name),
display_order = COALESCE(${patch.displayOrder ?? null}, display_order),
is_active = COALESCE(${patch.isActive ?? null}, is_active),
updated_at = NOW()
WHERE id = ${id}
RETURNING id, name, display_order, is_active
`
await invalidatePaymentCatalog()
return row
}
export const deleteGroup = async (id) => {
// ON DELETE RESTRICT in the schema means this throws if methods still
// reference the group. We let the FK error bubble; the route translates it
// to a 409 with a clear message.
const [row] = await sql`
DELETE FROM payment_method_groups
WHERE id = ${id}
RETURNING id
`
await invalidatePaymentCatalog()
return row
}
/**
* Reorder groups idempotently. Pass the full ordered array of group IDs;
* indexes become the new display_order.
*/
export const reorderGroups = async (orderedIds) => {
await sql.begin(async (tx) => {
for (let i = 0; i < orderedIds.length; i++) {
await tx`
UPDATE payment_method_groups
SET display_order = ${i}, updated_at = NOW()
WHERE id = ${orderedIds[i]}
`
}
})
await invalidatePaymentCatalog()
}
export const createMethod = async ({
groupId,
displayName,
paymentCode,
displayOrder = 0,
icon = null,
isActive = true,
}) => {
const [row] = await sql`
INSERT INTO payment_methods (
group_id, display_name, payment_code, display_order, icon, is_active
)
VALUES (
${groupId},
${displayName},
${String(paymentCode).toUpperCase()},
${displayOrder},
${icon},
${isActive}
)
RETURNING id, group_id, display_name, payment_code, display_order, icon, is_active
`
await invalidatePaymentCatalog()
return row
}
export const updateMethod = async (id, patch) => {
const [row] = await sql`
UPDATE payment_methods
SET
group_id = COALESCE(${patch.groupId ?? null}, group_id),
display_name = COALESCE(${patch.displayName ?? null}, display_name),
payment_code = COALESCE(${patch.paymentCode ? String(patch.paymentCode).toUpperCase() : null}, payment_code),
display_order = COALESCE(${patch.displayOrder ?? null}, display_order),
icon = COALESCE(${patch.icon ?? null}, icon),
is_active = COALESCE(${patch.isActive ?? null}, is_active),
updated_at = NOW()
WHERE id = ${id}
RETURNING id, group_id, display_name, payment_code, display_order, icon, is_active
`
await invalidatePaymentCatalog()
return row
}
export const deleteMethod = async (id) => {
const [row] = await sql`
DELETE FROM payment_methods
WHERE id = ${id}
RETURNING id
`
await invalidatePaymentCatalog()
return row
}
export const reorderMethods = async (orderedIds) => {
await sql.begin(async (tx) => {
for (let i = 0; i < orderedIds.length; i++) {
await tx`
UPDATE payment_methods
SET display_order = ${i}, updated_at = NOW()
WHERE id = ${orderedIds[i]}
`
}
})
await invalidatePaymentCatalog()
}
// --- Validation used by payment.service.js -----------------------------------
/**
* Look up a payment_code in the (cached) catalog. Returns the method row if
* found and active, otherwise null. Casing-tolerant — incoming codes from old
* app versions sending lower-case (`qris`) are normalised to upper.
*/
export const findActiveMethodByCode = async (rawCode) => {
if (!rawCode) return null
const code = String(rawCode).toUpperCase()
const { groups } = await getCatalogForApp()
for (const g of groups) {
for (const m of g.methods) {
if (m.payment_code === code) return m
}
}
return null
}

View File

@@ -176,6 +176,12 @@ export const requestPayment = async ({
isFirstSessionDiscount = false,
isExtension = false,
targetedMitraId = null,
// Customer's pre-picked payment method from the catalog. Optional;
// upper-cased Xendit channel code (e.g. `OVO`). Stamped onto
// product_metadata for analytics + future use as a Xendit `paymentMethods`
// filter. Not currently passed to Xendit invoice creation — the customer
// re-picks on Xendit's checkout page.
preferredPaymentCode = null,
}) => {
if (!customerId) {
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
@@ -203,9 +209,10 @@ export const requestPayment = async ({
is_extension: isExtension,
targeted_mitra_id: targetedMitraId,
is_first_session_discount: isFirstSessionDiscount,
preferred_payment_code: preferredPaymentCode,
...productMetadata,
}
: productMetadata
: { preferred_payment_code: preferredPaymentCode, ...productMetadata }
const [row] = await sql`
INSERT INTO payment_requests (