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>
This commit is contained in:
2026-05-27 21:33:51 +08:00
parent 1f6d8e09ae
commit 2c95fd040d
53 changed files with 2389 additions and 832 deletions

View File

@@ -27,7 +27,31 @@ import { getValkeyClient, publish, subscribe } from '../plugins/valkey.js'
const sql = getDb()
const CACHE_KEY = 'payment-catalog:v1'
// 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'
@@ -93,6 +117,8 @@ const buildCatalogFromDb = async () => {
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
@@ -111,11 +137,19 @@ const buildCatalogFromDb = async () => {
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,
})
}
@@ -193,18 +227,23 @@ 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
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, is_active, created_at, updated_at
icon, min_amount, max_amount, is_active, created_at, updated_at
FROM payment_methods
ORDER BY display_order ASC, display_name ASC
`
return rows
// 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) ------------------------
@@ -270,11 +309,14 @@ export const createMethod = async ({
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, is_active
group_id, display_name, payment_code, display_order, icon,
min_amount, max_amount, is_active
)
VALUES (
${groupId},
@@ -282,15 +324,22 @@ export const createMethod = async ({
${String(paymentCode).toUpperCase()},
${displayOrder},
${icon},
${minAmount},
${maxAmount},
${isActive}
)
RETURNING id, group_id, display_name, payment_code, display_order, icon, is_active
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
@@ -299,10 +348,13 @@ export const updateMethod = async (id, patch) => {
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, is_active
RETURNING id, group_id, display_name, payment_code, display_order,
icon, min_amount, max_amount, is_active
`
await invalidatePaymentCatalog()
return row

View File

@@ -0,0 +1,41 @@
/**
* Payment-icon serving — Phase 5.x icon hosting.
*
* Backend wraps the `idn-finlogos` npm package and serves its SVGs at
* `/assets/payment-icons/<slug>.svg`. The catalog endpoint returns
* `icon_url: "/assets/payment-icons/<slug>.svg"`; the client app fetches +
* caches with `flutter_cache_manager`. No bundled icons in the app anymore.
*
* License: idn-finlogos ships under CC-BY-NC-4.0 for the assets (per-brand
* permission still required for production use). Code under MIT. See
* `backend/node_modules/idn-finlogos/LICENSE-ASSETS` + `NOTICE`.
*
* Slug set is loaded ONCE at module init from `dist/icons/*.svg` filenames so
* the request path stays a single Set.has() lookup. Bump the package version
* to add/replace icons; restart the backend to refresh the slug set.
*/
import { readdirSync } from 'fs'
import { dirname, join } from 'path'
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const PACKAGE_DIR = dirname(require.resolve('idn-finlogos/package.json'))
const ICONS_DIR = join(PACKAGE_DIR, 'dist', 'icons')
const SLUGS = new Set(
readdirSync(ICONS_DIR)
.filter((f) => f.endsWith('.svg'))
.map((f) => f.slice(0, -4)),
)
export const hasIconSlug = (slug) => typeof slug === 'string' && SLUGS.has(slug)
export const resolveIconPath = (slug) => {
if (!hasIconSlug(slug)) return null
return join(ICONS_DIR, `${slug}.svg`)
}
/** Sorted slug list for the CC dropdown. */
export const listIconSlugs = () => Array.from(SLUGS).sort()