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