Files
halobestie-clone/backend/src/services/payment-catalog.service.js
Ramadhan Sjamsani 2c95fd040d Phase 5.x payment revamp + Xendit Stage-8 prep
- Backend wraps idn-finlogos npm at /assets/payment-icons/<slug>.svg with
  1y immutable cache. Mobile drops bundled SVGs (only placeholder remains)
  and fetches via flutter_cache_manager. payment_methods.icon is now a
  CSV of slugs; catalog emits icon_urls[]. CARDS tile renders Visa + MC +
  JCB side by side.
- Per-method min/max amount bounds (BIGINT, nullable). Picker greys out
  out-of-range tiles with subtitle; backend gates with INVALID_PAYMENT_AMOUNT
  (422). Defense in depth against stale-catalog clients.
- Xendit channel codes corrected from authoritative docs
  (BCA_VA -> BCA_VIRTUAL_ACCOUNT, CREDIT_CARD -> CARDS, ovo -> ovo-new,
  shopeepay -> shopee-pay, ...). 18 methods x 5 groups seeded with
  Xendit-published per-channel min/max.
- Re-runnable seed (ON CONFLICT DO NOTHING on payment_code + new unique
  index on group name). Operator CC edits never clobbered across re-runs.
  One-shot reset + inspect scripts under backend/.dev/.
- Customer redirect HTML pages at /payment/return/{success,failure},
  brand-styled with "Buka HaloBestie" CTA firing halobestie:// deeplink.
  URL scheme registered on Android (intent-filter w/ BROWSABLE on
  MainActivity) and iOS (CFBundleURLTypes). Waiting-payment poller still
  owns confirmation; deeplink just brings the activity to foreground.
- Control center payment-catalog page: min/max inputs + columns. Other
  CC pages restyled with new theme tokens (separate work, bundled here).

169/169 backend tests pass. See requirement/phase5-payment-revamp-2026-05-27.md
for the full revamp doc. Stage 8 (E2E) still pending: webhook URL routing
decision + two client_app follow-ups (legacy /chat/request removal,
extension Custom Tab).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:33:51 +08:00

404 lines
12 KiB
JavaScript

/**
* 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
}