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

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