Files
halobestie-clone/backend/src/routes/public/client.payment.routes.js
Ramadhan Sjamsani 2c95fd040d 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>
2026-05-27 21:33:51 +08:00

263 lines
9.7 KiB
JavaScript

import { authenticate } from '../../plugins/auth.js'
import { getCustomerById } from '../../services/customer.service.js'
import {
requestPayment,
confirmPaymentForCustomer,
cancelPayment,
getPayment,
getCustomerPendingPayments,
} from '../../services/payment.service.js'
import {
isCustomerEligibleForFirstSessionDiscount,
isValidTier,
findTier,
readFirstSessionDiscountConfig,
} from '../../services/pricing.service.js'
import { getXenditConfig } from '../../services/config.service.js'
import { findActiveMethodByCode } from '../../services/payment-catalog.service.js'
import { UserType, SessionMode } from '../../constants.js'
const resolveCustomer = async (request, reply) => {
if (request.auth?.userType !== UserType.CUSTOMER) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Customer account required' },
})
}
const customer = await getCustomerById(request.auth.userId)
if (!customer) {
return reply.code(404).send({
success: false,
error: { code: 'ACCOUNT_NOT_FOUND', message: 'Customer account not found' },
})
}
request.customer = customer
}
/**
* Payment session lifecycle (mocked — no Xendit yet).
*
* POST /api/client/payment-requests
* POST /api/client/payment-requests/:id/confirm
* POST /api/client/payment-requests/:id/cancel
* GET /api/client/payment-requests/:id
*/
export const clientPaymentRoutes = async (app) => {
// Create a payment session (status = pending). First-session-discount is server-authoritative:
// if the customer is eligible AND this is NOT an extension AND mode is in the configured
// modes list, amount is forced to the configured discount price.
app.post('/', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const {
duration_minutes,
mode = SessionMode.CHAT,
targeted_mitra_id = null,
is_extension = false,
method = null,
} = request.body ?? {}
// Catalog validation — the customer's pre-pick (set in payment_method_screen.dart)
// 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({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'method must be a non-empty string when provided' },
})
}
methodEntry = await findActiveMethodByCode(method)
if (!methodEntry) {
return reply.code(422).send({
success: false,
error: {
code: 'INVALID_PAYMENT_METHOD',
message: 'Selected payment method is not available',
},
})
}
}
if (typeof duration_minutes !== 'number' || duration_minutes <= 0) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'duration_minutes must be a positive number' },
})
}
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
return reply.code(422).send({
success: false,
error: { code: 'VALIDATION_ERROR', message: 'mode must be chat or call' },
})
}
let isFirstSessionDiscount = false
let amount
if (!is_extension) {
const eligible = await isCustomerEligibleForFirstSessionDiscount(request.customer.id)
if (eligible) {
const discount = await readFirstSessionDiscountConfig()
// Discount is mode-gated. With default config (modes: ['chat']) call-mode never
// gets the discount even if the user is eligible.
if (
discount.enabled
&& discount.modes.includes(mode)
&& duration_minutes === discount.duration_minutes
) {
isFirstSessionDiscount = true
amount = discount.actual_price_idr
}
}
}
if (!isFirstSessionDiscount) {
// Resolve amount from the configured tier list for the requested mode.
const tier = await findTier({ mode, durationMinutes: duration_minutes })
if (!tier) {
return reply.code(400).send({
success: false,
error: { code: 'INVALID_TIER', message: 'No price tier matches the requested mode/duration' },
})
}
amount = tier.price_idr
if (!(await isValidTier({ mode, durationMinutes: duration_minutes, priceIdr: amount }))) {
return reply.code(400).send({
success: false,
error: { code: 'INVALID_TIER', message: 'Invalid price tier selection' },
})
}
}
// 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.
const session = await requestPayment({
productType: 'chat_session',
customerId: request.customer.id,
durationMinutes: duration_minutes,
amount,
isFirstSessionDiscount,
isExtension: Boolean(is_extension),
targetedMitraId: targeted_mitra_id || null,
mode,
preferredPaymentCode: method ? String(method).toUpperCase() : null,
})
return reply.code(201).send({
success: true,
data: {
id: session.id,
amount: session.amount,
duration_minutes: session.duration_minutes,
is_first_session_discount: session.is_first_session_discount,
is_extension: session.is_extension,
mode: session.mode,
targeted_mitra_id: session.targeted_mitra_id,
expires_at: session.expires_at,
status: session.status,
invoice_url: session.xendit_invoice_url ?? null,
},
})
})
app.post('/:id/confirm', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
// Phase 5 D9: when Xendit is live, only the webhook can confirm. The dev/Maestro
// stub at /internal/_test/force-confirm-payment bypasses this gate (internal listener).
if (getXenditConfig().enabled) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Confirmation must come from Xendit webhook' },
})
}
const session = await confirmPaymentForCustomer(request.params.id, request.customer.id)
return reply.send({
success: true,
data: {
id: session.id,
status: session.status,
confirmed_at: session.confirmed_at,
},
})
})
app.post('/:id/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await cancelPayment(request.params.id, request.customer.id)
return reply.send({
success: true,
data: {
id: session.id,
status: session.status,
},
})
})
// Phase 4 Stage 10 — Chat Tab Pembayaran feed. Static path; registered
// before `/:id` so find-my-way matches this and not the wildcard.
app.get('/pending', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const data = await getCustomerPendingPayments(request.customer.id)
return reply.send({ success: true, data })
})
app.get('/:id', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await getPayment(request.params.id)
if (!session) {
return reply.code(404).send({
success: false,
error: { code: 'NOT_FOUND', message: 'Payment request not found' },
})
}
if (session.customer_id !== request.customer.id) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Payment request does not belong to this customer' },
})
}
// Phase 5: surface chat_session_id (and status) when the server-driven pairing
// subscriber has already started pairing for this confirmed payment. Lets the
// app skip its legacy POST /chat/request call and just move to the searching state.
const { getDb } = await import('../../db/client.js')
const sqlClient = getDb()
const [chat] = await sqlClient`
SELECT id, status FROM chat_sessions WHERE payment_request_id = ${session.id} LIMIT 1
`
return reply.send({
success: true,
data: {
...session,
chat_session_id: chat?.id ?? null,
chat_session_status: chat?.status ?? null,
},
})
})
}