/** * 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() // Bump the version suffix whenever the catalog shape changes so a deploy // doesn't serve stale-shape entries from L2 Valkey for up to VALKEY_TTL_SECONDS. // v2: added icon_url alongside icon (2026-05-27, Phase 5.x backend icon hosting). // v3: added min_amount / max_amount per method (2026-05-27, Phase 5.x amount bounds). // v4: icon -> comma-separated slug list, emit icon_urls[] (2026-05-27, multi-logo for cards). const CACHE_KEY = 'payment-catalog:v4' // Split a comma-separated `icon` string into trimmed, non-empty slugs. // Tolerates whitespace and stray commas: " visa , mastercard , " → ['visa','mastercard']. const parseIconSlugs = (raw) => { if (!raw) return [] return String(raw) .split(',') .map((s) => s.trim()) .filter((s) => s.length > 0) } // `postgres` returns BIGINT columns as JS strings (BigInt would break JSON.stringify). // All realistic payment amounts fit comfortably below Number.MAX_SAFE_INTEGER, so we // coerce to Number for the API response — keeps the wire shape `{min_amount: 10000}` // rather than `"10000"`, which is what mobile/CC parsers expect. const coerceAmount = (v) => { if (v === null || v === undefined) return null return typeof v === 'number' ? v : Number(v) } 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.min_amount AS min_amount, m.max_amount AS max_amount, 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: [], }) } // `icon` is a comma-separated slug list (single entry for most methods; // multiple for composite tiles like a credit-card row showing Visa + MC + JCB). // We emit `icon_urls` as the canonical field; clients render them in a row. const slugs = parseIconSlugs(r.icon) byGroupId.get(r.group_id).methods.push({ id: r.method_id, payment_code: r.payment_code, display_name: r.display_name, icon: r.icon, icon_urls: slugs.map((s) => `/assets/payment-icons/${s}.svg`), // Per-method amount bounds (inclusive). Either may be null = no bound. min_amount: coerceAmount(r.min_amount), max_amount: coerceAmount(r.max_amount), 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, min_amount, max_amount, 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, min_amount, max_amount, is_active, created_at, updated_at FROM payment_methods ORDER BY display_order ASC, display_name ASC ` // BIGINT → number for CC consumption (table uses toLocaleString). return rows.map((r) => ({ ...r, min_amount: coerceAmount(r.min_amount), max_amount: coerceAmount(r.max_amount), })) } // --- 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, minAmount = null, maxAmount = null, isActive = true, }) => { const [row] = await sql` INSERT INTO payment_methods ( group_id, display_name, payment_code, display_order, icon, min_amount, max_amount, is_active ) VALUES ( ${groupId}, ${displayName}, ${String(paymentCode).toUpperCase()}, ${displayOrder}, ${icon}, ${minAmount}, ${maxAmount}, ${isActive} ) RETURNING id, group_id, display_name, payment_code, display_order, icon, min_amount, max_amount, is_active ` await invalidatePaymentCatalog() return row } export const updateMethod = async (id, patch) => { // null is a meaningful update for min/max (operator clearing the bound), so // we route those through a sentinel-aware branch instead of COALESCE. const setMin = Object.prototype.hasOwnProperty.call(patch, 'minAmount') const setMax = Object.prototype.hasOwnProperty.call(patch, 'maxAmount') 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), min_amount = ${setMin ? patch.minAmount : sql`min_amount`}, max_amount = ${setMax ? patch.maxAmount : sql`max_amount`}, 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, min_amount, max_amount, 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 }