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:
2026-05-27 21:33:51 +08:00
parent 1f6d8e09ae
commit 2c95fd040d
53 changed files with 2389 additions and 832 deletions

View File

@@ -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'))

View 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() } })
})
}

View File

@@ -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.

View 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',
}))
})
}

View 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)))
})
}