diff --git a/backend/.dev/inspect-payment-catalog.js b/backend/.dev/inspect-payment-catalog.js
new file mode 100644
index 0000000..4ec3e7b
--- /dev/null
+++ b/backend/.dev/inspect-payment-catalog.js
@@ -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()
diff --git a/backend/.dev/reset-payment-catalog.js b/backend/.dev/reset-payment-catalog.js
new file mode 100644
index 0000000..75d3933
--- /dev/null
+++ b/backend/.dev/reset-payment-catalog.js
@@ -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()
diff --git a/backend/package-lock.json b/backend/package-lock.json
index aeca6c3..b95f4f9 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -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",
diff --git a/backend/package.json b/backend/package.json
index d012770..a2a61bc 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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",
diff --git a/backend/src/app.internal.js b/backend/src/app.internal.js
index 117bdbe..7d15d08 100644
--- a/backend/src/app.internal.js
+++ b/backend/src/app.internal.js
@@ -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') {
diff --git a/backend/src/app.public.js b/backend/src/app.public.js
index d924e20..4195790 100644
--- a/backend/src/app.public.js
+++ b/backend/src/app.public.js
@@ -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)
diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js
index dd59ae4..05c9dab 100644
--- a/backend/src/db/migrate.js
+++ b/backend/src/db/migrate.js
@@ -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
- `
- }
}
}
diff --git a/backend/src/routes/internal/payment-catalog.routes.js b/backend/src/routes/internal/payment-catalog.routes.js
index 1ff790e..3467c6d 100644
--- a/backend/src/routes/internal/payment-catalog.routes.js
+++ b/backend/src/routes/internal/payment-catalog.routes.js
@@ -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'))
diff --git a/backend/src/routes/internal/payment-icons.routes.js b/backend/src/routes/internal/payment-icons.routes.js
new file mode 100644
index 0000000..efb0e54
--- /dev/null
+++ b/backend/src/routes/internal/payment-icons.routes.js
@@ -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() } })
+ })
+}
diff --git a/backend/src/routes/public/client.payment.routes.js b/backend/src/routes/public/client.payment.routes.js
index 303e270..6f04c6c 100644
--- a/backend/src/routes/public/client.payment.routes.js
+++ b/backend/src/routes/public/client.payment.routes.js
@@ -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.
diff --git a/backend/src/routes/public/payment-return.routes.js b/backend/src/routes/public/payment-return.routes.js
new file mode 100644
index 0000000..3f17a51
--- /dev/null
+++ b/backend/src/routes/public/payment-return.routes.js
@@ -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 `
+
+
+
+
+
+ ${title}
+
+
+
+
+
+
+
+
+
${glyph}
+
${headline}
+
${body}
+
Buka HaloBestie
+
Atau tutup tab ini untuk kembali ke aplikasi.
+
+
+
+`
+}
+
+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',
+ }))
+ })
+}
diff --git a/backend/src/routes/public/shared.payment-icons.routes.js b/backend/src/routes/public/shared.payment-icons.routes.js
new file mode 100644
index 0000000..de88757
--- /dev/null
+++ b/backend/src/routes/public/shared.payment-icons.routes.js
@@ -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)))
+ })
+}
diff --git a/backend/src/services/payment-catalog.service.js b/backend/src/services/payment-catalog.service.js
index 0b4c7b0..a69bdf2 100644
--- a/backend/src/services/payment-catalog.service.js
+++ b/backend/src/services/payment-catalog.service.js
@@ -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
diff --git a/backend/src/services/payment-icon.service.js b/backend/src/services/payment-icon.service.js
new file mode 100644
index 0000000..a1cb944
--- /dev/null
+++ b/backend/src/services/payment-icon.service.js
@@ -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/.svg`. The catalog endpoint returns
+ * `icon_url: "/assets/payment-icons/.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()
diff --git a/backend/test/routes/client.payment.routes.test.js b/backend/test/routes/client.payment.routes.test.js
index 2503163..04472fa 100644
--- a/backend/test/routes/client.payment.routes.test.js
+++ b/backend/test/routes/client.payment.routes.test.js
@@ -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({
diff --git a/backend/test/routes/shared.payment-icons.routes.test.js b/backend/test/routes/shared.payment-icons.routes.test.js
new file mode 100644
index 0000000..da25f09
--- /dev/null
+++ b/backend/test/routes/shared.payment-icons.routes.test.js
@@ -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|^