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:
@@ -1191,69 +1191,125 @@ const migrate = async () => {
|
||||
ON payment_methods (group_id, display_order)
|
||||
`
|
||||
|
||||
// Seed: only when the groups table is empty. Once seeded, operators edit via
|
||||
// CC; we never re-seed (avoid clobbering custom orderings).
|
||||
const [{ n: groupCount }] = await sql`
|
||||
SELECT COUNT(*)::int AS n FROM payment_method_groups
|
||||
// Per-method amount bounds (Phase 5.x). Both inclusive, both nullable
|
||||
// (null = no bound). The customer app greys out methods whose bounds the
|
||||
// current bill misses; the backend rejects with INVALID_PAYMENT_AMOUNT.
|
||||
// BIGINT so we can store Xendit's published per-channel ceilings verbatim
|
||||
// (some banks document up to Rp 50 billion, well past INT range).
|
||||
await sql`
|
||||
ALTER TABLE payment_methods
|
||||
ADD COLUMN IF NOT EXISTS min_amount BIGINT,
|
||||
ADD COLUMN IF NOT EXISTS max_amount BIGINT
|
||||
`
|
||||
if (groupCount === 0) {
|
||||
const PAYMENT_CATALOG_SEED = [
|
||||
{
|
||||
name: 'Paling Cepat',
|
||||
order: 0,
|
||||
methods: [
|
||||
{ code: 'QRIS', display: 'QRIS', icon: 'qris', active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E-Wallet',
|
||||
order: 1,
|
||||
methods: [
|
||||
{ code: 'OVO', display: 'OVO', icon: 'ovo', active: true },
|
||||
{ code: 'DANA', display: 'DANA', icon: 'dana', active: true },
|
||||
{ code: 'SHOPEEPAY', display: 'ShopeePay', icon: 'shopeepay', active: true },
|
||||
// Xendit Invoice API doesn't expose GoPay (Gojek/GoPay relationship).
|
||||
// Seeded as inactive so it surfaces in CC but is hidden from the app
|
||||
// until we either confirm a Xendit channel or remove it entirely.
|
||||
{ code: 'GOPAY', display: 'GoPay', icon: 'gopay', active: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Virtual Account',
|
||||
order: 2,
|
||||
methods: [
|
||||
{ code: 'BCA_VA', display: 'BCA Virtual Account', icon: 'bca', active: true },
|
||||
{ code: 'MANDIRI_VA', display: 'Mandiri Virtual Account', icon: 'mandiri', active: true },
|
||||
{ code: 'BNI_VA', display: 'BNI Virtual Account', icon: 'bni', active: true },
|
||||
{ code: 'BRI_VA', display: 'BRI Virtual Account', icon: 'bri', active: true },
|
||||
{ code: 'PERMATA_VA', display: 'Permata Virtual Account', icon: 'permata', active: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
for (const group of PAYMENT_CATALOG_SEED) {
|
||||
const [{ id: groupId }] = await sql`
|
||||
INSERT INTO payment_method_groups (name, display_order, is_active)
|
||||
VALUES (${group.name}, ${group.order}, true)
|
||||
RETURNING id
|
||||
// Unique on group name so we can re-run the seed below with ON CONFLICT.
|
||||
await sql`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS payment_method_groups_name_uq
|
||||
ON payment_method_groups (name)
|
||||
`
|
||||
|
||||
// --- Catalog seed -----------------------------------------------------------
|
||||
//
|
||||
// Re-runnable: groups upsert via ON CONFLICT on name; methods via ON CONFLICT
|
||||
// on payment_code (already UNIQUE). Operator edits in CC are NOT clobbered
|
||||
// because DO NOTHING leaves existing rows alone. New methods you add to this
|
||||
// list later land on the next migration; existing methods don't get bumped.
|
||||
//
|
||||
// Limits are pulled from https://docs.xendit.co/docs/available-payment-channels
|
||||
// (verified 2026-05-27). Amounts are inclusive Rp. Where Xendit's documented
|
||||
// max exceeds 50B, we keep the literal number — `payment_requests.amount` is
|
||||
// INTEGER-capped at 2.1B so we never get that high in practice; the bound is
|
||||
// recorded faithfully for documentation / future raises.
|
||||
const PAYMENT_CATALOG_SEED = [
|
||||
{
|
||||
name: 'Paling Cepat',
|
||||
order: 0,
|
||||
methods: [
|
||||
{ code: 'QRIS', display: 'QRIS', icon: 'qris',
|
||||
min: 1500, max: 20000000, active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E-Wallet',
|
||||
order: 1,
|
||||
methods: [
|
||||
{ code: 'OVO', display: 'OVO', icon: 'ovo-new', min: 100, max: 20000000, active: true },
|
||||
{ code: 'DANA', display: 'DANA', icon: 'dana', min: 100, max: 20000000, active: true },
|
||||
{ code: 'SHOPEEPAY', display: 'ShopeePay', icon: 'shopee-pay', min: 1, max: 20000000, active: true },
|
||||
{ code: 'LINKAJA', display: 'LinkAja', icon: 'linkaja', min: 1, max: 20000000, active: true },
|
||||
{ code: 'ASTRAPAY', display: 'AstraPay', icon: 'astra-pay', min: 100, max: 20000000, active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Virtual Account',
|
||||
order: 2,
|
||||
methods: [
|
||||
{ code: 'BCA_VIRTUAL_ACCOUNT', display: 'BCA Virtual Account', icon: 'bca', min: 10000, max: 50000000, active: true },
|
||||
{ code: 'MANDIRI_VIRTUAL_ACCOUNT', display: 'Mandiri Virtual Account', icon: 'mandiri', min: 1, max: 50000000000n, active: true },
|
||||
{ code: 'BNI_VIRTUAL_ACCOUNT', display: 'BNI Virtual Account', icon: 'bni', min: 1, max: 50000000, active: true },
|
||||
{ code: 'BRI_VIRTUAL_ACCOUNT', display: 'BRI Virtual Account', icon: 'bri', min: 1, max: 50000000000n, active: true },
|
||||
{ code: 'BSI_VIRTUAL_ACCOUNT', display: 'BSI Virtual Account', icon: 'bsi', min: 1, max: 50000000000n, active: true },
|
||||
{ code: 'PERMATA_VIRTUAL_ACCOUNT', display: 'Permata Virtual Account', icon: 'permata', min: 1, max: 9999999999n, active: true },
|
||||
{ code: 'CIMB_VIRTUAL_ACCOUNT', display: 'CIMB Virtual Account', icon: 'cimb-niaga', min: 1, max: 50000000, active: true },
|
||||
{ code: 'BJB_VIRTUAL_ACCOUNT', display: 'BJB Virtual Account', icon: 'bank-bjb', min: 1, max: 2000000000, active: true },
|
||||
{ code: 'BSS_VIRTUAL_ACCOUNT', display: 'BSS Virtual Account', icon: 'bank-sahabat-sampoerna', min: 1, max: 50000000000n, active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Outlet',
|
||||
order: 3,
|
||||
methods: [
|
||||
{ code: 'ALFAMART', display: 'Alfamart', icon: 'alfamart', min: 10000, max: 5000000, active: true },
|
||||
{ code: 'INDOMARET', display: 'Indomaret', icon: 'indomaret', min: 10000, max: 2500000, active: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Kartu Kredit',
|
||||
order: 4,
|
||||
methods: [
|
||||
// `icon` is comma-separated → backend emits multiple icon_urls; client
|
||||
// renders them side-by-side on the same tile.
|
||||
{ code: 'CARDS', display: 'Kartu Kredit', icon: 'visa,mastercard,jcb',
|
||||
min: 5000, max: 200000000, active: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
for (const group of PAYMENT_CATALOG_SEED) {
|
||||
// INSERT new group OR fetch existing's id. ON CONFLICT (name) DO NOTHING
|
||||
// returns no row, so we RETURNING + fallback SELECT.
|
||||
const ins = await sql`
|
||||
INSERT INTO payment_method_groups (name, display_order, is_active)
|
||||
VALUES (${group.name}, ${group.order}, true)
|
||||
ON CONFLICT (name) DO NOTHING
|
||||
RETURNING id
|
||||
`
|
||||
let groupId
|
||||
if (ins.length > 0) {
|
||||
groupId = ins[0].id
|
||||
} else {
|
||||
const [row] = await sql`SELECT id FROM payment_method_groups WHERE name = ${group.name}`
|
||||
groupId = row.id
|
||||
}
|
||||
let methodOrder = 0
|
||||
for (const m of group.methods) {
|
||||
await sql`
|
||||
INSERT INTO payment_methods (
|
||||
group_id, display_name, payment_code, display_order, icon,
|
||||
min_amount, max_amount, is_active
|
||||
)
|
||||
VALUES (
|
||||
${groupId},
|
||||
${m.display},
|
||||
${m.code},
|
||||
${methodOrder++},
|
||||
${m.icon},
|
||||
${m.min},
|
||||
${m.max},
|
||||
${m.active}
|
||||
)
|
||||
ON CONFLICT (payment_code) DO NOTHING
|
||||
`
|
||||
let methodOrder = 0
|
||||
for (const m of group.methods) {
|
||||
await sql`
|
||||
INSERT INTO payment_methods (
|
||||
group_id, display_name, payment_code, display_order, icon, is_active
|
||||
)
|
||||
VALUES (
|
||||
${groupId},
|
||||
${m.display},
|
||||
${m.code},
|
||||
${methodOrder++},
|
||||
${m.icon},
|
||||
${m.active}
|
||||
)
|
||||
ON CONFLICT (payment_code) DO NOTHING
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user