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:
@@ -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' },
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user