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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user