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

@@ -9,7 +9,7 @@ export const db = () => getDb()
/**
* Truncate Phase 3.7-relevant tables between tests.
*
* Order matters: pairing_failures FK → payment_sessions; chat_request_notifications
* Order matters: pairing_failures FK → payment_requests; chat_request_notifications
* FK → chat_sessions; customer_transactions FK → chat_sessions; etc. Use CASCADE so
* we don't have to maintain the topological order when tables get added.
*
@@ -19,7 +19,7 @@ export const db = () => getDb()
*/
const TRUNCATE_TABLES = [
'pairing_failures',
'payment_sessions',
'payment_requests',
'chat_request_notifications',
'session_extensions',
'session_closures',
@@ -70,7 +70,7 @@ export const resetAppConfig = async () => {
['extension_timeout_seconds', { value: 60 }],
['early_end_mitra_enabled', { value: false }],
['early_end_customer_enabled', { value: false }],
['payment_session_timeout_minutes', { value: 20 }],
['payment_request_timeout_minutes', { value: 20 }],
['returning_chat_confirmation_timeout_seconds', { value: 20 }],
['extension_default_action_on_timeout', { value: 'auto_approve' }],
['pairing_blast_timeout_seconds', { value: 60 }],

View File

@@ -20,9 +20,9 @@ const { buildPublic } = await import('../helpers/server.js')
const { resetDb, resetAppConfig, db } = await import('../helpers/db.js')
const { createCustomer } = await import('../helpers/fixtures.js')
const { customerJwt, authHeader } = await import('../helpers/jwt.js')
const { PaymentSessionStatus } = await import('../../src/constants.js')
const { PaymentRequestStatus } = await import('../../src/constants.js')
describe('POST /api/client/payment-sessions', () => {
describe('POST /api/client/payment-requests', () => {
let app
let customer
let token
@@ -49,7 +49,7 @@ describe('POST /api/client/payment-sessions', () => {
it('happy path returns 201 + a pending payment-session row at the discounted price for an eligible customer', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/client/payment-sessions',
url: '/api/client/payment-requests',
headers: authHeader(token),
// Discount duration default is 12 minutes (config seed). Eligible customer →
// amount forced to actual_price_idr (2000), is_first_session_discount=true.
@@ -59,7 +59,7 @@ describe('POST /api/client/payment-sessions', () => {
expect(res.statusCode).toBe(201)
const body = res.json()
expect(body.success).toBe(true)
expect(body.data.status).toBe(PaymentSessionStatus.PENDING)
expect(body.data.status).toBe(PaymentRequestStatus.PENDING)
expect(body.data.duration_minutes).toBe(12)
expect(body.data.is_first_session_discount).toBe(true)
expect(body.data.amount).toBe(2000)
@@ -68,7 +68,7 @@ describe('POST /api/client/payment-sessions', () => {
// Verify persistence
const sql = db()
const [row] = await sql`SELECT * FROM payment_sessions WHERE id = ${body.data.id}`
const [row] = await sql`SELECT * FROM payment_requests WHERE id = ${body.data.id}`
expect(row).toBeDefined()
expect(row.customer_id).toBe(customer.id)
})
@@ -83,7 +83,7 @@ describe('POST /api/client/payment-sessions', () => {
const res = await app.inject({
method: 'POST',
url: '/api/client/payment-sessions',
url: '/api/client/payment-requests',
headers: authHeader(token),
payload: { duration_minutes: 12 },
})
@@ -99,19 +99,19 @@ describe('POST /api/client/payment-sessions', () => {
// Use a non-discount tier (5 min @ 5000 IDR) so we exercise the regular confirm path.
const createRes = await app.inject({
method: 'POST',
url: '/api/client/payment-sessions',
url: '/api/client/payment-requests',
headers: authHeader(token),
payload: { duration_minutes: 5 },
})
expect(createRes.statusCode).toBe(201)
const created = createRes.json().data
expect(created.status).toBe(PaymentSessionStatus.PENDING)
expect(created.status).toBe(PaymentRequestStatus.PENDING)
expect(created.is_first_session_discount).toBe(false)
expect(created.amount).toBe(5000)
const confirmRes = await app.inject({
method: 'POST',
url: `/api/client/payment-sessions/${created.id}/confirm`,
url: `/api/client/payment-requests/${created.id}/confirm`,
headers: authHeader(token),
payload: {},
})
@@ -119,7 +119,7 @@ describe('POST /api/client/payment-sessions', () => {
expect(confirmRes.statusCode).toBe(200)
const confirmed = confirmRes.json().data
expect(confirmed.id).toBe(created.id)
expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED)
expect(confirmed.status).toBe(PaymentRequestStatus.CONFIRMED)
expect(confirmed.confirmed_at).toBeTruthy()
})
@@ -127,7 +127,7 @@ describe('POST /api/client/payment-sessions', () => {
// 20-minute call tier in Phase 4 = 17000 IDR.
const res = await app.inject({
method: 'POST',
url: '/api/client/payment-sessions',
url: '/api/client/payment-requests',
headers: authHeader(token),
payload: { duration_minutes: 20, mode: 'call' },
})

View File

@@ -0,0 +1,190 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'
// Standard WS/notification mocks (same as the other public-app route tests).
vi.mock('../../src/plugins/websocket.js', () => ({
sendToUser: vi.fn(() => false),
sendToSessionParticipant: vi.fn(() => false),
registerWebSocketPlugin: vi.fn(async () => {}),
registerWebSocketRoute: vi.fn(),
isUserOnlineWs: vi.fn(() => false),
getSessionConnections: vi.fn(() => ({})),
}))
vi.mock('../../src/services/notification.service.js', () => ({
sendPushNotification: vi.fn(async () => true),
registerDeviceToken: vi.fn(async () => {}),
}))
const { buildPublic } = await import('../helpers/server.js')
const { resetDb, resetAppConfig, db } = await import('../helpers/db.js')
const { createCustomer } = await import('../helpers/fixtures.js')
const { PaymentRequestStatus } = await import('../../src/constants.js')
const { requestPayment } = await import('../../src/services/payment.service.js')
const WEBHOOK_TOKEN = 'test-webhook-token-abcdefghijklmnop'
const fireWebhook = (app, body, token = WEBHOOK_TOKEN) =>
app.inject({
method: 'POST',
url: '/api/shared/payment/webhooks/xendit',
headers: { 'x-callback-token': token, 'content-type': 'application/json' },
payload: body,
})
describe('POST /api/shared/payment/webhooks/xendit', () => {
let app
let customer
beforeAll(async () => {
vi.stubEnv('XENDIT_WEBHOOK_TOKEN', WEBHOOK_TOKEN)
await resetAppConfig()
app = await buildPublic()
})
beforeEach(async () => {
await resetDb()
const phone = `+628${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}`
customer = await createCustomer({ callName: 'XenditTester', phone })
})
afterAll(async () => {
await app?.close()
vi.unstubAllEnvs()
})
it('401s when x-callback-token is missing or wrong', async () => {
const session = await requestPayment({
productType: 'chat_session',
customerId: customer.id,
durationMinutes: 12,
amount: 50_000,
})
const wrong = await fireWebhook(app, {
id: 'inv_x', external_id: session.id, status: 'PAID', amount: 50_000,
}, 'totally-wrong-token-of-same-shape-len')
expect(wrong.statusCode).toBe(401)
const missing = await app.inject({
method: 'POST',
url: '/api/shared/payment/webhooks/xendit',
payload: { id: 'inv_x', external_id: session.id, status: 'PAID', amount: 50_000 },
})
expect(missing.statusCode).toBe(401)
})
it('PAID confirms pending request and stamps xendit_* columns', async () => {
const session = await requestPayment({
productType: 'chat_session',
customerId: customer.id,
durationMinutes: 12,
amount: 50_000,
})
const res = await fireWebhook(app, {
id: 'inv_abc', external_id: session.id, status: 'PAID', amount: 50_000, payment_method: 'BCA',
})
expect(res.statusCode).toBe(200)
expect(res.json().ok).toBe(true)
const [row] = await db()`
SELECT status, confirmed_at, xendit_invoice_id, xendit_payment_method, xendit_paid_amount
FROM payment_requests WHERE id = ${session.id}
`
expect(row.status).toBe(PaymentRequestStatus.CONFIRMED)
expect(row.confirmed_at).not.toBeNull()
expect(row.xendit_invoice_id).toBe('inv_abc')
expect(row.xendit_payment_method).toBe('BCA')
expect(row.xendit_paid_amount).toBe(50_000)
})
it('PAID with amount mismatch returns 409 and leaves row pending', async () => {
const session = await requestPayment({
productType: 'chat_session',
customerId: customer.id, durationMinutes: 12, amount: 50_000,
})
const res = await fireWebhook(app, {
id: 'inv_bad', external_id: session.id, status: 'PAID', amount: 999,
})
expect(res.statusCode).toBe(409)
expect(res.json().error).toBe('amount_mismatch')
const [row] = await db()`SELECT status, xendit_invoice_id FROM payment_requests WHERE id = ${session.id}`
expect(row.status).toBe(PaymentRequestStatus.PENDING)
expect(row.xendit_invoice_id).toBeNull()
})
it('idempotent: second PAID delivery for the same row ACKs without erroring', async () => {
const session = await requestPayment({
productType: 'chat_session',
customerId: customer.id, durationMinutes: 12, amount: 50_000,
})
const first = await fireWebhook(app, {
id: 'inv_dup', external_id: session.id, status: 'PAID', amount: 50_000, payment_method: 'BCA',
})
expect(first.statusCode).toBe(200)
const second = await fireWebhook(app, {
id: 'inv_dup', external_id: session.id, status: 'PAID', amount: 50_000, payment_method: 'BCA',
})
expect(second.statusCode).toBe(200)
expect(second.json().ok).toBe(true)
const [row] = await db()`SELECT status FROM payment_requests WHERE id = ${session.id}`
expect(row.status).toBe(PaymentRequestStatus.CONFIRMED)
})
it('EXPIRED flips pending → expired (idempotent on repeat)', async () => {
const session = await requestPayment({
productType: 'chat_session',
customerId: customer.id, durationMinutes: 12, amount: 50_000,
})
const res = await fireWebhook(app, {
id: 'inv_exp', external_id: session.id, status: 'EXPIRED',
})
expect(res.statusCode).toBe(200)
const [row] = await db()`SELECT status FROM payment_requests WHERE id = ${session.id}`
expect(row.status).toBe(PaymentRequestStatus.EXPIRED)
// Second delivery is a no-op (WHERE status = 'pending' filters it out)
const repeat = await fireWebhook(app, {
id: 'inv_exp', external_id: session.id, status: 'EXPIRED',
})
expect(repeat.statusCode).toBe(200)
})
it('unknown external_id ACKs without 5xx so Xendit stops retrying', async () => {
const res = await fireWebhook(app, {
id: 'inv_orphan',
external_id: '00000000-0000-0000-0000-000000000000',
status: 'PAID',
amount: 50_000,
})
expect(res.statusCode).toBe(200)
expect(res.json().ignored).toBe('unknown_payment_request')
})
it('missing external_id ACKs (forward-compat for non-Invoice event types)', async () => {
const res = await fireWebhook(app, { id: 'evt_x', status: 'SOMETHING_ELSE' })
expect(res.statusCode).toBe(200)
expect(res.json().ignored).toBe('no_external_id')
})
it('unhandled status ACKs as ignored', async () => {
const session = await requestPayment({
productType: 'chat_session',
customerId: customer.id, durationMinutes: 12, amount: 50_000,
})
const res = await fireWebhook(app, {
id: 'inv_partial', external_id: session.id, status: 'PARTIAL_REFUND', amount: 50_000,
})
expect(res.statusCode).toBe(200)
expect(res.json().ignored).toBe('PARTIAL_REFUND')
const [row] = await db()`SELECT status FROM payment_requests WHERE id = ${session.id}`
expect(row.status).toBe(PaymentRequestStatus.PENDING)
})
})

View File

@@ -70,7 +70,7 @@ describe('extension.service — EXTENSION_RESPONSE payload', () => {
// Pending extension row tied to that payment.
const [extension] = await sql`
INSERT INTO session_extensions (
session_id, requested_duration_minutes, requested_price, status, payment_session_id
session_id, requested_duration_minutes, requested_price, status, payment_request_id
)
VALUES (${session.id}, 10, 9000, ${ExtensionStatus.PENDING}, ${extPay.id})
RETURNING id
@@ -119,7 +119,7 @@ describe('extension.service — EXTENSION_RESPONSE payload', () => {
await confirmPaymentSession(extPay.id, customer.id)
const [extension] = await sql`
INSERT INTO session_extensions (
session_id, requested_duration_minutes, requested_price, status, payment_session_id
session_id, requested_duration_minutes, requested_price, status, payment_request_id
)
VALUES (${session.id}, 10, 9000, ${ExtensionStatus.PENDING}, ${extPay.id})
RETURNING id

View File

@@ -36,7 +36,7 @@ const { createPaymentSession, confirmPaymentSession } = await import('../../src/
const {
WsMessage,
PairingFailureCause,
PaymentSessionStatus,
PaymentRequestStatus,
SessionStatus,
} = await import('../../src/constants.js')
const { db, resetDb, resetAppConfig } = await import('../helpers/db.js')
@@ -72,7 +72,7 @@ describe('pairing.service', () => {
// Act: customer fires the general blast — only one mitra is online.
const session = await createPairingRequest(customer.id, {
paymentSessionId: pay.id,
paymentRequestId: pay.id,
})
expect(session.status).toBe(SessionStatus.PENDING_ACCEPTANCE)
@@ -83,15 +83,15 @@ describe('pairing.service', () => {
// Assert: pairing_failures audit row carries ALL_MITRAS_REJECTED.
const sql = db()
const failures = await sql`
SELECT cause_tag FROM pairing_failures WHERE payment_session_id = ${pay.id}
SELECT cause_tag FROM pairing_failures WHERE payment_request_id = ${pay.id}
`
expect(failures).toHaveLength(1)
expect(failures[0].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED)
// Payment session stays CONFIRMED — the customer can re-blast on the same
// payment via the S7 Timeout "coba cari lagi" CTA.
const [paySession] = await sql`SELECT status FROM payment_sessions WHERE id = ${pay.id}`
expect(paySession.status).toBe(PaymentSessionStatus.CONFIRMED)
const [payRequest] = await sql`SELECT status FROM payment_requests WHERE id = ${pay.id}`
expect(payRequest.status).toBe(PaymentRequestStatus.CONFIRMED)
// Customer was notified with PAIRING_FAILED carrying is_terminal=false so
// the client renders the retryable variant of the S7 timeout screen.
@@ -112,7 +112,7 @@ describe('pairing.service', () => {
})
await confirmPaymentSession(pay.id, customer.id)
const session = await createPairingRequest(customer.id, {
paymentSessionId: pay.id,
paymentRequestId: pay.id,
})
// Act: customer cancels.
@@ -131,7 +131,7 @@ describe('pairing.service', () => {
// Payment session is still terminated (CUSTOMER_CANCELLED) — the failure row exists
// for ops accounting, just no real-time push to the customer who initiated the cancel.
const sql = db()
const failures = await sql`SELECT cause_tag FROM pairing_failures WHERE payment_session_id = ${pay.id}`
const failures = await sql`SELECT cause_tag FROM pairing_failures WHERE payment_request_id = ${pay.id}`
expect(failures).toHaveLength(1)
expect(failures[0].cause_tag).toBe(PairingFailureCause.CUSTOMER_CANCELLED)
})

View File

@@ -5,7 +5,7 @@ import {
getPaymentSession,
getCustomerPendingPayments,
} from '../../src/services/payment.service.js'
import { PaymentSessionStatus, SessionStatus } from '../../src/constants.js'
import { PaymentRequestStatus, SessionStatus } from '../../src/constants.js'
import { resetDb, resetAppConfig, db } from '../helpers/db.js'
import { createCustomer, createMitra } from '../helpers/fixtures.js'
@@ -35,7 +35,7 @@ describe('payment.service', () => {
amount: 30000,
})
expect(session.status).toBe(PaymentSessionStatus.PENDING)
expect(session.status).toBe(PaymentRequestStatus.PENDING)
expect(session.customer_id).toBe(customer.id)
expect(session.duration_minutes).toBe(15)
expect(session.amount).toBe(30000)
@@ -47,7 +47,7 @@ describe('payment.service', () => {
// Verify it's actually persisted (not just returned from the INSERT)
const reloaded = await getPaymentSession(session.id)
expect(reloaded.id).toBe(session.id)
expect(reloaded.status).toBe(PaymentSessionStatus.PENDING)
expect(reloaded.status).toBe(PaymentRequestStatus.PENDING)
})
it('confirmPaymentSession transitions pending → confirmed', async () => {
@@ -56,11 +56,11 @@ describe('payment.service', () => {
durationMinutes: 30,
amount: 60000,
})
expect(session.status).toBe(PaymentSessionStatus.PENDING)
expect(session.status).toBe(PaymentRequestStatus.PENDING)
const confirmed = await confirmPaymentSession(session.id, customer.id)
expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED)
expect(confirmed.status).toBe(PaymentRequestStatus.CONFIRMED)
expect(confirmed.confirmed_at).toBeTruthy()
expect(new Date(confirmed.confirmed_at).getTime()).toBeGreaterThan(0)
})
@@ -81,7 +81,7 @@ describe('payment.service', () => {
// Row should still be pending — the failed confirm must not have side effects.
const reloaded = await getPaymentSession(session.id)
expect(reloaded.status).toBe(PaymentSessionStatus.PENDING)
expect(reloaded.status).toBe(PaymentRequestStatus.PENDING)
expect(reloaded.confirmed_at).toBeNull()
})
@@ -148,7 +148,7 @@ describe('payment.service', () => {
await sql`
INSERT INTO session_extensions (
session_id, requested_duration_minutes, requested_price, status, payment_session_id
session_id, requested_duration_minutes, requested_price, status, payment_request_id
)
VALUES (${chatSession.id}, 10, 2500, 'pending', ${extPay.id})
`
@@ -188,7 +188,7 @@ describe('payment.service', () => {
isExtension: true,
})
await sql`
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_session_id)
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_request_id)
VALUES (${chatSession.id}, 10, 2500, 'pending', ${extension.id})
`
@@ -209,7 +209,7 @@ describe('payment.service', () => {
// Manually move expires_at into the past — leaves status pending so this
// simulates the gap between TTL expiry and the next sweep tick.
await sql`
UPDATE payment_sessions
UPDATE payment_requests
SET expires_at = NOW() - INTERVAL '1 second'
WHERE id = ${pay.id}
`