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