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