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:
31
backend/.dev/inspect-payment-catalog.js
Normal file
31
backend/.dev/inspect-payment-catalog.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Dump the current payment catalog for a quick visual sanity check after a
|
||||
// reset. Read-only.
|
||||
|
||||
import 'dotenv/config'
|
||||
import { getDb } from '../src/db/client.js'
|
||||
|
||||
const sql = getDb()
|
||||
const rows = await sql`
|
||||
SELECT g.name AS grp, m.display_name, m.payment_code, m.icon,
|
||||
m.min_amount, m.max_amount, m.is_active
|
||||
FROM payment_methods m
|
||||
JOIN payment_method_groups g ON m.group_id = g.id
|
||||
ORDER BY g.display_order, m.display_order
|
||||
`
|
||||
console.log(`${rows.length} methods across ${new Set(rows.map(r => r.grp)).size} groups\n`)
|
||||
const w = (s, n) => String(s).padEnd(n)
|
||||
const r = (s, n) => String(s).padStart(n)
|
||||
console.log(w('Group', 18), w('Display', 26), w('Code', 26), w('Icon', 26), r('Min', 10), r('Max', 16))
|
||||
console.log('-'.repeat(124))
|
||||
for (const row of rows) {
|
||||
console.log(
|
||||
w(row.grp, 18),
|
||||
w(row.display_name, 26),
|
||||
w(row.payment_code, 26),
|
||||
w(row.icon ?? '—', 26),
|
||||
r(row.min_amount ?? '—', 10),
|
||||
r(row.max_amount ?? '—', 16),
|
||||
row.is_active ? '' : '(inactive)',
|
||||
)
|
||||
}
|
||||
await sql.end()
|
||||
28
backend/.dev/reset-payment-catalog.js
Normal file
28
backend/.dev/reset-payment-catalog.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// One-shot wipe of the payment_methods + payment_method_groups tables in the
|
||||
// current DATABASE_URL. Use when you want the seed in migrate.js to repopulate
|
||||
// from scratch on the next migration run.
|
||||
//
|
||||
// Safe against payment_requests because that table does NOT FK into
|
||||
// payment_methods — `xendit_payment_method` and `product_metadata.preferred_payment_code`
|
||||
// are free-text columns.
|
||||
//
|
||||
// Usage:
|
||||
// cd backend && node .dev/reset-payment-catalog.js
|
||||
// cd backend && node src/db/migrate.js
|
||||
|
||||
import 'dotenv/config'
|
||||
import { getDb } from '../src/db/client.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const [{ count: methodCount }] = await sql`SELECT COUNT(*)::int AS count FROM payment_methods`
|
||||
const [{ count: groupCount }] = await sql`SELECT COUNT(*)::int AS count FROM payment_method_groups`
|
||||
console.log(`Before: ${methodCount} methods, ${groupCount} groups`)
|
||||
|
||||
// FK is payment_methods.group_id → payment_method_groups (ON DELETE RESTRICT),
|
||||
// so methods must go first.
|
||||
await sql`DELETE FROM payment_methods`
|
||||
await sql`DELETE FROM payment_method_groups`
|
||||
|
||||
console.log('Wiped. Now run: node src/db/migrate.js')
|
||||
await sql.end()
|
||||
34
backend/package-lock.json
generated
34
backend/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"fastify": "^5.0.0",
|
||||
"firebase-admin": "^12.2.0",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"idn-finlogos": "^2.3.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwks-rsa": "^3.2.2",
|
||||
@@ -2642,6 +2643,39 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/idn-finlogos": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/idn-finlogos/-/idn-finlogos-2.3.0.tgz",
|
||||
"integrity": "sha512-s6kF3gPvcW+hdRJdMomKiH7m05X77VAltj2Z1FBQuP00pJxBMDULTOjK4bCsrq6LQ8xVATMGR130fSzdidwKUA==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-native": ">=0.60",
|
||||
"react-native-svg": ">=12.0",
|
||||
"svelte": ">=4.0",
|
||||
"vue": ">=3.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-svg": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"fastify": "^5.0.0",
|
||||
"firebase-admin": "^12.2.0",
|
||||
"google-auth-library": "^9.15.1",
|
||||
"idn-finlogos": "^2.3.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwks-rsa": "^3.2.2",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { sessionManagementRoutes } from './routes/internal/session.routes.js'
|
||||
import { mitraActivityRoutes } from './routes/internal/mitra-activity.routes.js'
|
||||
import { failedPairingsRoutes } from './routes/internal/failed-pairings.routes.js'
|
||||
import { internalPaymentCatalogRoutes } from './routes/internal/payment-catalog.routes.js'
|
||||
import { internalPaymentIconsRoutes } from './routes/internal/payment-icons.routes.js'
|
||||
import { internalTestRoutes } from './routes/internal/_test.routes.js'
|
||||
import { errorHandler } from './plugins/error-handler.js'
|
||||
|
||||
@@ -40,6 +41,7 @@ export const buildInternalApp = async () => {
|
||||
app.register(mitraActivityRoutes, { prefix: '/internal/mitra-activity' })
|
||||
app.register(failedPairingsRoutes, { prefix: '/internal/failed-pairings' })
|
||||
app.register(internalPaymentCatalogRoutes, { prefix: '/internal' })
|
||||
app.register(internalPaymentIconsRoutes, { prefix: '/internal' })
|
||||
|
||||
// Dev/test-only — never registered in production builds.
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
|
||||
@@ -17,6 +17,8 @@ import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes
|
||||
import { sharedSupportRoutes } from './routes/public/shared.support.routes.js'
|
||||
import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
|
||||
import { paymentWebhookRoutes } from './routes/public/shared.payment-webhooks.routes.js'
|
||||
import { paymentIconRoutes } from './routes/public/shared.payment-icons.routes.js'
|
||||
import { paymentReturnRoutes } from './routes/public/payment-return.routes.js'
|
||||
import { errorHandler } from './plugins/error-handler.js'
|
||||
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js'
|
||||
|
||||
@@ -47,6 +49,10 @@ export const buildPublicApp = async () => {
|
||||
app.register(sharedSupportRoutes, { prefix: '/api/shared' })
|
||||
// Payment provider webhooks. Public + token-authed via x-callback-token.
|
||||
app.register(paymentWebhookRoutes, { prefix: '/api/shared/payment' })
|
||||
// Brand-mark SVGs from idn-finlogos. Public, 1-year immutable cache.
|
||||
app.register(paymentIconRoutes, { prefix: '/assets' })
|
||||
// Xendit customer-redirect HTML pages. Public, no auth.
|
||||
app.register(paymentReturnRoutes, { prefix: '/payment' })
|
||||
|
||||
// WebSocket route (registered at app level, not prefixed)
|
||||
registerWebSocketRoute(app)
|
||||
|
||||
@@ -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
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,22 @@ const conflict = (message, extra = {}) => ({
|
||||
error: { code: 'CONFLICT', message, ...extra },
|
||||
})
|
||||
|
||||
// Amount bounds are inclusive Rupiah, null = no bound. Accept either null or
|
||||
// a non-negative finite integer. We coerce empty string / "" to null on input
|
||||
// so the CC form can clear a bound by submitting an empty field.
|
||||
const normalizeAmountBound = (raw) => {
|
||||
if (raw === null || raw === undefined || raw === '') return null
|
||||
return raw
|
||||
}
|
||||
|
||||
const validateAmountBound = (fieldName, raw) => {
|
||||
if (raw === null || raw === undefined || raw === '') return null
|
||||
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 0) {
|
||||
return validation(`${fieldName} must be a non-negative integer or null`, fieldName)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
if (request.auth?.userType !== UserType.CC_USER) {
|
||||
return reply.code(403).send({
|
||||
@@ -157,7 +173,8 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
})
|
||||
|
||||
app.post('/payment-methods', { preHandler: WRITE_GUARD }, async (request, reply) => {
|
||||
const { group_id, display_name, payment_code, display_order, icon, is_active } = request.body ?? {}
|
||||
const { group_id, display_name, payment_code, display_order, icon,
|
||||
min_amount, max_amount, is_active } = request.body ?? {}
|
||||
if (typeof group_id !== 'string' || !UUID_RE.test(group_id)) {
|
||||
return reply.code(422).send(validation('group_id is required and must be a UUID', 'group_id'))
|
||||
}
|
||||
@@ -167,6 +184,13 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
if (typeof payment_code !== 'string' || payment_code.trim().length === 0) {
|
||||
return reply.code(422).send(validation('payment_code is required', 'payment_code'))
|
||||
}
|
||||
const minErr = validateAmountBound('min_amount', min_amount)
|
||||
if (minErr) return reply.code(422).send(minErr)
|
||||
const maxErr = validateAmountBound('max_amount', max_amount)
|
||||
if (maxErr) return reply.code(422).send(maxErr)
|
||||
if (min_amount != null && max_amount != null && min_amount > max_amount) {
|
||||
return reply.code(422).send(validation('min_amount must be <= max_amount', 'min_amount'))
|
||||
}
|
||||
try {
|
||||
const row = await createMethod({
|
||||
groupId: group_id,
|
||||
@@ -174,6 +198,8 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
paymentCode: payment_code.trim(),
|
||||
displayOrder: Number.isFinite(display_order) ? display_order : 0,
|
||||
icon: typeof icon === 'string' && icon.trim().length > 0 ? icon.trim() : null,
|
||||
minAmount: normalizeAmountBound(min_amount),
|
||||
maxAmount: normalizeAmountBound(max_amount),
|
||||
isActive: typeof is_active === 'boolean' ? is_active : true,
|
||||
})
|
||||
return reply.code(201).send({ success: true, data: row })
|
||||
@@ -198,7 +224,8 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
if (!UUID_RE.test(id)) {
|
||||
return reply.code(422).send(validation('Invalid id format', 'id'))
|
||||
}
|
||||
const { group_id, display_name, payment_code, display_order, icon, is_active } = request.body ?? {}
|
||||
const body = request.body ?? {}
|
||||
const { group_id, display_name, payment_code, display_order, icon, is_active } = body
|
||||
if (group_id !== undefined && (typeof group_id !== 'string' || !UUID_RE.test(group_id))) {
|
||||
return reply.code(422).send(validation('group_id must be a UUID', 'group_id'))
|
||||
}
|
||||
@@ -208,6 +235,24 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
if (payment_code !== undefined && (typeof payment_code !== 'string' || payment_code.trim().length === 0)) {
|
||||
return reply.code(422).send(validation('payment_code must be non-empty', 'payment_code'))
|
||||
}
|
||||
const hasMin = Object.prototype.hasOwnProperty.call(body, 'min_amount')
|
||||
const hasMax = Object.prototype.hasOwnProperty.call(body, 'max_amount')
|
||||
if (hasMin) {
|
||||
const err = validateAmountBound('min_amount', body.min_amount)
|
||||
if (err) return reply.code(422).send(err)
|
||||
}
|
||||
if (hasMax) {
|
||||
const err = validateAmountBound('max_amount', body.max_amount)
|
||||
if (err) return reply.code(422).send(err)
|
||||
}
|
||||
// Cross-field check uses post-patch effective values. If only one side is
|
||||
// patched, the other comes from the existing row — fetched in the service
|
||||
// would be cleaner, but we'd need a SELECT round-trip; instead, require
|
||||
// the operator to send both when narrowing the range.
|
||||
if (hasMin && hasMax && body.min_amount != null && body.max_amount != null
|
||||
&& body.min_amount > body.max_amount) {
|
||||
return reply.code(422).send(validation('min_amount must be <= max_amount', 'min_amount'))
|
||||
}
|
||||
try {
|
||||
const row = await updateMethod(id, {
|
||||
groupId: group_id,
|
||||
@@ -215,6 +260,8 @@ export const internalPaymentCatalogRoutes = async (app) => {
|
||||
paymentCode: payment_code?.trim(),
|
||||
displayOrder: Number.isFinite(display_order) ? display_order : undefined,
|
||||
icon: icon === null ? null : (typeof icon === 'string' && icon.trim().length > 0 ? icon.trim() : undefined),
|
||||
...(hasMin ? { minAmount: normalizeAmountBound(body.min_amount) } : {}),
|
||||
...(hasMax ? { maxAmount: normalizeAmountBound(body.max_amount) } : {}),
|
||||
isActive: typeof is_active === 'boolean' ? is_active : undefined,
|
||||
})
|
||||
if (!row) return reply.code(404).send(notFound('payment_method not found'))
|
||||
|
||||
40
backend/src/routes/internal/payment-icons.routes.js
Normal file
40
backend/src/routes/internal/payment-icons.routes.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Control-center read-only manifest for the payment-icon picker.
|
||||
*
|
||||
* GET /internal/payment-icons → { slugs: [...] }
|
||||
*
|
||||
* The CC payment-method form uses this to populate a dropdown of valid
|
||||
* `icon` values, so operators pick from a known list instead of typing free
|
||||
* text and risking a 404 on the asset endpoint. Reuses the `config` `read`
|
||||
* permission (same scope used by the catalog editor).
|
||||
*/
|
||||
|
||||
import { authenticate, requirePermission } from '../../plugins/auth.js'
|
||||
import { getCcUserById } from '../../services/cc-user.service.js'
|
||||
import { UserType } from '../../constants.js'
|
||||
import { listIconSlugs } from '../../services/payment-icon.service.js'
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
if (request.auth?.userType !== UserType.CC_USER) {
|
||||
return reply.code(403).send({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Not a control center user' },
|
||||
})
|
||||
}
|
||||
const user = await getCcUserById(request.auth.userId)
|
||||
if (!user) {
|
||||
return reply.code(403).send({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Not a control center user' },
|
||||
})
|
||||
}
|
||||
request.ccUser = user
|
||||
}
|
||||
|
||||
const READ_GUARD = [authenticate, attachCcUser, requirePermission('config', 'read')]
|
||||
|
||||
export const internalPaymentIconsRoutes = async (app) => {
|
||||
app.get('/payment-icons', { preHandler: READ_GUARD }, async (_request, reply) => {
|
||||
return reply.send({ success: true, data: { slugs: listIconSlugs() } })
|
||||
})
|
||||
}
|
||||
@@ -59,6 +59,9 @@ export const clientPaymentRoutes = async (app) => {
|
||||
// must reference an active row in `payment_methods`. Casing-tolerant; older app
|
||||
// versions sending lower-case (`qris`) are normalised inside the service.
|
||||
// `method` is optional for backwards compat with pre-Phase-5.x callers.
|
||||
// Amount-bound enforcement happens AFTER amount is computed below; we
|
||||
// capture `methodEntry` here to avoid a second lookup.
|
||||
let methodEntry = null
|
||||
if (method !== null && method !== undefined) {
|
||||
if (typeof method !== 'string' || method.trim().length === 0) {
|
||||
return reply.code(422).send({
|
||||
@@ -66,8 +69,8 @@ export const clientPaymentRoutes = async (app) => {
|
||||
error: { code: 'VALIDATION_ERROR', message: 'method must be a non-empty string when provided' },
|
||||
})
|
||||
}
|
||||
const entry = await findActiveMethodByCode(method)
|
||||
if (!entry) {
|
||||
methodEntry = await findActiveMethodByCode(method)
|
||||
if (!methodEntry) {
|
||||
return reply.code(422).send({
|
||||
success: false,
|
||||
error: {
|
||||
@@ -129,6 +132,32 @@ export const clientPaymentRoutes = async (app) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-method amount bounds — defense in depth alongside the client's own
|
||||
// disabled-tile UX. A stale catalog cache on the client could let a request
|
||||
// through that the picker should have blocked. Bounds are inclusive.
|
||||
if (methodEntry) {
|
||||
if (methodEntry.min_amount != null && amount < methodEntry.min_amount) {
|
||||
return reply.code(422).send({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_PAYMENT_AMOUNT',
|
||||
message: 'Amount below the minimum for the selected payment method',
|
||||
details: { amount, min_amount: methodEntry.min_amount, max_amount: methodEntry.max_amount },
|
||||
},
|
||||
})
|
||||
}
|
||||
if (methodEntry.max_amount != null && amount > methodEntry.max_amount) {
|
||||
return reply.code(422).send({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_PAYMENT_AMOUNT',
|
||||
message: 'Amount above the maximum for the selected payment method',
|
||||
details: { amount, min_amount: methodEntry.min_amount, max_amount: methodEntry.max_amount },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: payment.service.js handles the Xendit invoice creation internally
|
||||
// when XENDIT_ENABLED=true. The row comes back with xendit_invoice_url populated;
|
||||
// when off, invoice_url is null and the dev/Maestro stub plays the webhook role.
|
||||
|
||||
154
backend/src/routes/public/payment-return.routes.js
Normal file
154
backend/src/routes/public/payment-return.routes.js
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Customer-facing return pages for Xendit Invoice checkout — Phase 5.x.
|
||||
*
|
||||
* Routes (public, no auth — Xendit's hosted checkout 302s the customer's
|
||||
* browser here after payment):
|
||||
* GET /payment/return/success
|
||||
* GET /payment/return/failure
|
||||
*
|
||||
* Xendit appends query params like `external_id` and `payment_id`; we don't
|
||||
* need them — the customer's app polls `GET /payment-requests/:id`
|
||||
* independently and learns the outcome from there. These pages are PURE UX:
|
||||
* they confirm the outcome and offer a `halobestie://` deeplink button so
|
||||
* the customer can flip back to the app with one tap.
|
||||
*
|
||||
* The deeplink scheme must be registered in
|
||||
* `client_app/android/app/src/main/AndroidManifest.xml` and
|
||||
* `client_app/ios/Runner/Info.plist`. If the scheme isn't registered the
|
||||
* button is a no-op; the customer can still tap the Custom Tab back arrow.
|
||||
*
|
||||
* Brand colors mirror `client_app/lib/core/theme/halo_tokens.dart`:
|
||||
* brand #E17A9D
|
||||
* brandDark #8C3255
|
||||
* brandSofter #FBEFF3
|
||||
* danger (red shades — failure variant)
|
||||
*/
|
||||
|
||||
const renderPage = ({ variant, title, headline, body, deeplink }) => {
|
||||
const accent = variant === 'success' ? '#E17A9D' : '#D86B6B'
|
||||
const accentDark = variant === 'success' ? '#8C3255' : '#7A1E1E'
|
||||
const accentSoft = variant === 'success' ? '#FBEFF3' : '#FCEDED'
|
||||
const glyph = variant === 'success' ? '✓' : '!'
|
||||
return `<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>${title}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@600;700&family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--accent: ${accent};
|
||||
--accent-dark: ${accentDark};
|
||||
--accent-soft: ${accentSoft};
|
||||
--ink: #1F1419;
|
||||
--ink-soft: #6B5A62;
|
||||
--bg: #FFF7FA;
|
||||
--card: #FFFFFF;
|
||||
--border: #F0E2E8;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0; min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap {
|
||||
min-height: 100vh;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.card {
|
||||
width: 100%; max-width: 380px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 24px rgba(140, 50, 85, 0.06);
|
||||
}
|
||||
.glyph {
|
||||
width: 72px; height: 72px;
|
||||
margin: 0 auto 20px;
|
||||
border-radius: 36px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-dark);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: 'Bricolage Grotesque', serif;
|
||||
font-size: 40px; font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Bricolage Grotesque', serif;
|
||||
font-size: 22px; font-weight: 700;
|
||||
color: var(--accent-dark);
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
p {
|
||||
font-size: 14px; line-height: 1.55;
|
||||
color: var(--ink-soft);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.cta {
|
||||
display: block;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
padding: 14px 20px;
|
||||
border-radius: 14px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.cta:hover, .cta:focus { background: var(--accent-dark); }
|
||||
.cta:active { transform: scale(0.99); }
|
||||
.hint {
|
||||
margin-top: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card" role="status" aria-live="polite">
|
||||
<div class="glyph" aria-hidden="true">${glyph}</div>
|
||||
<h1>${headline}</h1>
|
||||
<p>${body}</p>
|
||||
<a class="cta" href="${deeplink}">Buka HaloBestie</a>
|
||||
<div class="hint">Atau tutup tab ini untuk kembali ke aplikasi.</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
export const paymentReturnRoutes = async (app) => {
|
||||
app.get('/return/success', async (_request, reply) => {
|
||||
return reply.type('text/html; charset=utf-8').send(renderPage({
|
||||
variant: 'success',
|
||||
title: 'Pembayaran berhasil — HaloBestie',
|
||||
headline: 'Pembayaran berhasil!',
|
||||
body: 'Sesi kamu sedang disiapkan. Buka aplikasi HaloBestie untuk mulai curhat.',
|
||||
deeplink: 'halobestie://payment/return?status=success',
|
||||
}))
|
||||
})
|
||||
|
||||
app.get('/return/failure', async (_request, reply) => {
|
||||
return reply.type('text/html; charset=utf-8').send(renderPage({
|
||||
variant: 'failure',
|
||||
title: 'Pembayaran tidak berhasil — HaloBestie',
|
||||
headline: 'Pembayaran tidak berhasil',
|
||||
body: 'Tenang, belum ada yang ditarik. Buka aplikasi HaloBestie dan coba metode lain.',
|
||||
deeplink: 'halobestie://payment/return?status=failure',
|
||||
}))
|
||||
})
|
||||
}
|
||||
40
backend/src/routes/public/shared.payment-icons.routes.js
Normal file
40
backend/src/routes/public/shared.payment-icons.routes.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Public payment-icon serving — Phase 5.x.
|
||||
*
|
||||
* GET /assets/payment-icons/:slug.svg
|
||||
* Returns the idn-finlogos SVG for `slug` with a 1-year immutable cache
|
||||
* header. Content is stable per backend deploy (icons change only when the
|
||||
* `idn-finlogos` npm dep is bumped); clients can cache aggressively.
|
||||
*
|
||||
* Public on purpose — these are brand-mark icons, not sensitive data. The
|
||||
* catalog endpoint (`GET /api/client/payment-methods`) is still authenticated;
|
||||
* leaking the icon URL by itself reveals nothing useful.
|
||||
*
|
||||
* 404 on unknown slug. We deliberately do NOT 200-with-placeholder here —
|
||||
* upstream owns the "show placeholder" fallback, and 404ing tells operators
|
||||
* about typo'd slugs in the CC payment-method form.
|
||||
*/
|
||||
|
||||
import { createReadStream } from 'fs'
|
||||
import { hasIconSlug, resolveIconPath } from '../../services/payment-icon.service.js'
|
||||
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/
|
||||
|
||||
export const paymentIconRoutes = async (app) => {
|
||||
app.get('/payment-icons/:slug.svg', async (request, reply) => {
|
||||
const { slug } = request.params
|
||||
|
||||
// Guard against path-traversal / oversized slug before touching the FS.
|
||||
if (!SLUG_RE.test(slug) || !hasIconSlug(slug)) {
|
||||
return reply.code(404).send({
|
||||
success: false,
|
||||
error: { code: 'NOT_FOUND', message: 'Unknown payment icon slug' },
|
||||
})
|
||||
}
|
||||
|
||||
return reply
|
||||
.type('image/svg+xml')
|
||||
.header('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
.send(createReadStream(resolveIconPath(slug)))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
41
backend/src/services/payment-icon.service.js
Normal file
41
backend/src/services/payment-icon.service.js
Normal 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()
|
||||
@@ -123,6 +123,65 @@ describe('POST /api/client/payment-requests', () => {
|
||||
expect(confirmed.confirmed_at).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects with INVALID_PAYMENT_AMOUNT when amount falls below the method min', async () => {
|
||||
// Seed a low-min method and price the request below it.
|
||||
const sql = db()
|
||||
const [g] = await sql`
|
||||
INSERT INTO payment_method_groups (name, display_order, is_active)
|
||||
VALUES ('TestGroup-min', 99, true) RETURNING id
|
||||
`
|
||||
await sql`
|
||||
INSERT INTO payment_methods (group_id, display_name, payment_code, display_order,
|
||||
icon, min_amount, max_amount, is_active)
|
||||
VALUES (${g.id}, 'TestVA', 'TEST_VA', 0, null, 50000, null, true)
|
||||
`
|
||||
// Bust the catalog cache so the new method is visible.
|
||||
const { invalidatePaymentCatalog } = await import('../../src/services/payment-catalog.service.js')
|
||||
await invalidatePaymentCatalog()
|
||||
|
||||
// Eligible discount path puts the price at 2000 — well below TEST_VA's 50000 min.
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/client/payment-requests',
|
||||
headers: authHeader(token),
|
||||
payload: { duration_minutes: 12, method: 'TEST_VA' },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(422)
|
||||
const body = res.json()
|
||||
expect(body.error.code).toBe('INVALID_PAYMENT_AMOUNT')
|
||||
expect(body.error.details.min_amount).toBe(50000)
|
||||
expect(body.error.details.amount).toBe(2000)
|
||||
})
|
||||
|
||||
it('rejects with INVALID_PAYMENT_AMOUNT when amount exceeds the method max', async () => {
|
||||
const sql = db()
|
||||
const [g] = await sql`
|
||||
INSERT INTO payment_method_groups (name, display_order, is_active)
|
||||
VALUES ('TestGroup-max', 99, true) RETURNING id
|
||||
`
|
||||
await sql`
|
||||
INSERT INTO payment_methods (group_id, display_name, payment_code, display_order,
|
||||
icon, min_amount, max_amount, is_active)
|
||||
VALUES (${g.id}, 'TestWallet', 'TEST_W', 0, null, null, 1000, true)
|
||||
`
|
||||
const { invalidatePaymentCatalog } = await import('../../src/services/payment-catalog.service.js')
|
||||
await invalidatePaymentCatalog()
|
||||
|
||||
// Discounted 12-min = 2000 IDR, above the 1000 max.
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/client/payment-requests',
|
||||
headers: authHeader(token),
|
||||
payload: { duration_minutes: 12, method: 'TEST_W' },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(422)
|
||||
const body = res.json()
|
||||
expect(body.error.code).toBe('INVALID_PAYMENT_AMOUNT')
|
||||
expect(body.error.details.max_amount).toBe(1000)
|
||||
})
|
||||
|
||||
it('call-mode payment session uses the call tier price group', async () => {
|
||||
// 20-minute call tier in Phase 4 = 17000 IDR.
|
||||
const res = await app.inject({
|
||||
|
||||
59
backend/test/routes/shared.payment-icons.routes.test.js
Normal file
59
backend/test/routes/shared.payment-icons.routes.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../src/plugins/websocket.js', () => ({
|
||||
sendToUser: vi.fn(() => false),
|
||||
sendToSessionParticipant: vi.fn(() => false),
|
||||
registerWebSocketPlugin: vi.fn(async () => {}),
|
||||
registerWebSocketRoute: vi.fn(),
|
||||
isUserOnlineWs: vi.fn(() => false),
|
||||
getSessionConnections: vi.fn(() => ({})),
|
||||
}))
|
||||
vi.mock('../../src/services/notification.service.js', () => ({
|
||||
sendPushNotification: vi.fn(async () => true),
|
||||
registerDeviceToken: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const { buildPublic } = await import('../helpers/server.js')
|
||||
|
||||
describe('GET /assets/payment-icons/:slug.svg', () => {
|
||||
let app
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildPublic()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('serves a known idn-finlogos slug with svg content-type and immutable cache', async () => {
|
||||
// 'qris' is one of our seeded slugs and is shipped by idn-finlogos.
|
||||
const res = await app.inject({ method: 'GET', url: '/assets/payment-icons/qris.svg' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.headers['content-type']).toBe('image/svg+xml')
|
||||
expect(res.headers['cache-control']).toBe('public, max-age=31536000, immutable')
|
||||
expect(res.body).toMatch(/^<\?xml|^<svg/)
|
||||
})
|
||||
|
||||
it('404s on an unknown slug', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/assets/payment-icons/definitely-not-a-real-bank.svg',
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('404s on slug with path-traversal characters', async () => {
|
||||
// Fastify normalises `..` in the URL, so this resolves to the parent
|
||||
// route; we still expect a 404 (no SVG matches) — but the SLUG_RE guard
|
||||
// is what would catch an unencoded attempt if a router ever let one
|
||||
// through, so we cover its negative case here.
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/assets/payment-icons/UPPER.svg',
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -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