- 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>
263 lines
9.7 KiB
JavaScript
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,
|
|
},
|
|
})
|
|
})
|
|
}
|