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

@@ -44,10 +44,19 @@ const insertGroup = async ({ name, order = 0, active = true }) => {
return row.id
}
const insertMethod = async ({ groupId, code, display = null, order = 0, icon = null, active = true }) => {
const insertMethod = async ({
groupId, code, display = null, order = 0, icon = null,
minAmount = null, maxAmount = null, active = true,
}) => {
const [row] = await sql`
INSERT INTO payment_methods (group_id, display_name, payment_code, display_order, icon, is_active)
VALUES (${groupId}, ${display ?? code}, ${code}, ${order}, ${icon}, ${active})
INSERT INTO payment_methods (
group_id, display_name, payment_code, display_order, icon,
min_amount, max_amount, is_active
)
VALUES (
${groupId}, ${display ?? code}, ${code}, ${order}, ${icon},
${minAmount}, ${maxAmount}, ${active}
)
RETURNING id
`
return row.id
@@ -116,6 +125,41 @@ describe('payment-catalog.service', () => {
expect(groups.map((g) => g.name)).toEqual(['E-Wallet'])
})
it('parses icon as comma-separated slug list into icon_urls (relative /assets paths)', async () => {
const g = await insertGroup({ name: 'E-Wallet' })
await insertMethod({ groupId: g, code: 'OVO', icon: 'ovo-new', order: 0 })
await insertMethod({ groupId: g, code: 'CARDS', icon: 'visa,mastercard,jcb', order: 1 })
await insertMethod({ groupId: g, code: 'NOICON', icon: null, order: 2 })
await invalidatePaymentCatalog()
const { groups } = await getCatalogForApp()
const methods = groups[0].methods
expect(methods[0].icon_urls).toEqual(['/assets/payment-icons/ovo-new.svg'])
expect(methods[1].icon_urls).toEqual([
'/assets/payment-icons/visa.svg',
'/assets/payment-icons/mastercard.svg',
'/assets/payment-icons/jcb.svg',
])
expect(methods[2].icon_urls).toEqual([])
})
it('emits min_amount + max_amount per method (null when not set)', async () => {
const g = await insertGroup({ name: 'E-Wallet' })
await insertMethod({ groupId: g, code: 'OVO', minAmount: 10000, maxAmount: 10000000, order: 0 })
await insertMethod({ groupId: g, code: 'QRIS', minAmount: 1, maxAmount: null, order: 1 })
await insertMethod({ groupId: g, code: 'BCA_VA', minAmount: null, maxAmount: null, order: 2 })
await invalidatePaymentCatalog()
const { groups } = await getCatalogForApp()
const methods = groups[0].methods
expect(methods[0].min_amount).toBe(10000)
expect(methods[0].max_amount).toBe(10000000)
expect(methods[1].min_amount).toBe(1)
expect(methods[1].max_amount).toBeNull()
expect(methods[2].min_amount).toBeNull()
expect(methods[2].max_amount).toBeNull()
})
it('caches in-process (second call returns the same object reference)', async () => {
const g = await insertGroup({ name: 'E-Wallet' })
await insertMethod({ groupId: g, code: 'OVO' })