Phase 5 Xendit: Stages 1-7 (XENDIT_ENABLED=false; Stage 8 pending creds)

Backend
- payment_sessions → payment_requests rename across DB schema + 29 files
- payment.service.js becomes product-agnostic owner: EventEmitter +
  Xendit wrapper + requestPayment / confirmPayment public API; legacy
  aliases retained for existing chat callers
- Webhook handler at POST /api/shared/payment/webhooks/xendit, with
  constant-time token verification (8 vitest cases)
- Server-driven pairing: payment.service emits
  payment_request.confirmed → pairing subscriber starts the blast.
  Legacy POST /chat/request still works during the cutover.
- Reconciliation sweeper extended (re-emits events for confirmed rows
  with no chat session)
- SIGTERM drain + startup reconciliation pass in server.js

Customer app
- waiting_payment_screen opens xendit_invoice_url via
  LaunchMode.inAppBrowserView
- searching / no-bestie / targeted-waiting / pairing-notifier updated
  to consume the new payment_request_id contract
- pending_payments_provider + bestie-unavailable dialog migrated

Dev / testing
- XENDIT_ENABLED=false is the safe default; .env.example documents the
  four new vars
- backend/.dev/xendit-fake-webhook.sh exercises the handler without
  ngrok
- 90/92 backend tests pass (two pre-existing session-timer flakes,
  unrelated); client_app analyzer clean
- requirement/phase5-xendit-plan.md is the canonical reference

Stage 8 (live E2E) blocked on Xendit test-mode keys. The dashboard's
single-webhook-URL constraint will be worked around via a self-poll
script next session.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:52:33 +08:00
parent e6d991373e
commit 3fff4b1c6e
37 changed files with 2805 additions and 515 deletions

View File

@@ -47,16 +47,16 @@ export const clientChatRoutes = async (app) => {
/**
* Start a general-blast pairing search.
*
* Body MUST include `payment_session_id` (a confirmed payment_session owned by the caller).
* Body MUST include `payment_request_id` (a confirmed payment_request owned by the caller).
* Pricing/duration/free-trial values are sourced from the payment session, NOT from the client.
*/
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { payment_session_id, topic_sensitivity } = request.body ?? {}
const { payment_request_id, topic_sensitivity } = request.body ?? {}
if (!payment_session_id) {
if (!payment_request_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
error: { code: 'BAD_REQUEST', message: 'payment_request_id is required' },
})
}
@@ -68,7 +68,7 @@ export const clientChatRoutes = async (app) => {
}
const session = await createPairingRequest(request.customer.id, {
paymentSessionId: payment_session_id,
paymentRequestId: payment_request_id,
topic_sensitivity,
})
return reply.code(201).send({ success: true, data: session })
@@ -77,18 +77,18 @@ export const clientChatRoutes = async (app) => {
/**
* Start a targeted "Curhat lagi" pairing request.
*
* Body: { payment_session_id, mitra_id, topic_sensitivity? }
* Body: { payment_request_id, mitra_id, topic_sensitivity? }
* Returns 409 with reason: 'targeted_mitra_offline' if the targeted mitra is unreachable
* or at capacity. The payment session stays `confirmed` in that case so the customer
* can fall back to general blast on the same payment.
*/
app.post('/chat-requests/returning', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { payment_session_id, mitra_id, topic_sensitivity } = request.body ?? {}
const { payment_request_id, mitra_id, topic_sensitivity } = request.body ?? {}
if (!payment_session_id) {
if (!payment_request_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
error: { code: 'BAD_REQUEST', message: 'payment_request_id is required' },
})
}
if (!mitra_id) {
@@ -103,7 +103,7 @@ export const clientChatRoutes = async (app) => {
: TopicSensitivity.REGULAR
const session = await createTargetedPairingRequest(request.customer.id, {
paymentSessionId: payment_session_id,
paymentRequestId: payment_request_id,
targetedMitraId: mitra_id,
topic_sensitivity: resolvedTopic,
})
@@ -113,26 +113,26 @@ export const clientChatRoutes = async (app) => {
/**
* Customer-initiated cancel during searching/waiting.
*
* Body: { payment_session_id }
* Terminal — payment session moves to failed_pairing with cause = customer_cancelled.
* Body: { payment_request_id }
* Terminal — payment session moves to failed_delivery with cause = customer_cancelled.
*/
app.post('/chat-requests/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { payment_session_id } = request.body ?? {}
if (!payment_session_id) {
const { payment_request_id } = request.body ?? {}
if (!payment_request_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
error: { code: 'BAD_REQUEST', message: 'payment_request_id is required' },
})
}
const result = await cancelPaymentSearch(payment_session_id, request.customer.id)
const result = await cancelPaymentSearch(payment_request_id, request.customer.id)
return reply.send({ success: true, data: result })
})
/**
* After a returning-chat fail, customer taps "Chat dengan bestie lain".
* Reuses the same payment_session_id (no double-charge), runs general blast.
* Reuses the same payment_request_id (no double-charge), runs general blast.
*/
app.post('/chat-requests/:paymentSessionId/fallback-to-blast', {
app.post('/chat-requests/:paymentRequestId/fallback-to-blast', {
preHandler: [authenticate, resolveCustomer],
}, async (request, reply) => {
const { topic_sensitivity } = request.body ?? {}
@@ -140,7 +140,7 @@ export const clientChatRoutes = async (app) => {
? TopicSensitivity.SENSITIVE
: TopicSensitivity.REGULAR
const session = await fallbackToGeneralBlast(
request.params.paymentSessionId,
request.params.paymentRequestId,
request.customer.id,
{ topic_sensitivity: resolvedTopic },
)
@@ -150,7 +150,7 @@ export const clientChatRoutes = async (app) => {
/**
* Cancel-by-session-id retained for in-flight chat_session cancels (e.g. cancel
* during the 20s targeted wait after a chat_session has been created). Customer cancel
* via payment_session_id should prefer POST /chat-requests/cancel above.
* via payment_request_id should prefer POST /chat-requests/cancel above.
*/
app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await cancelPairingRequest(request.params.sessionId, request.customer.id)
@@ -173,17 +173,17 @@ export const clientChatRoutes = async (app) => {
})
/**
* Extension request REQUIRES `extension_payment_session_id`.
* Extension request REQUIRES `extension_payment_request_id`.
* The payment session must be is_extension=true and is_first_session_discount=false.
* Pricing/duration come from the payment session via the extension service.
*/
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { duration_minutes, price, extension_payment_session_id } = request.body ?? {}
const { duration_minutes, price, extension_payment_request_id } = request.body ?? {}
if (!extension_payment_session_id) {
if (!extension_payment_request_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'extension_payment_session_id is required' },
error: { code: 'BAD_REQUEST', message: 'extension_payment_request_id is required' },
})
}
if (!duration_minutes || price === undefined) {
@@ -196,7 +196,7 @@ export const clientChatRoutes = async (app) => {
const extension = await requestExtension(request.params.sessionId, request.customer.id, {
duration_minutes,
price,
extension_payment_session_id,
extension_payment_request_id,
})
return reply.send({ success: true, data: extension })
})