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:
351
backend/src/services/payment-catalog.service.js
Normal file
351
backend/src/services/payment-catalog.service.js
Normal 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
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user