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

@@ -48,7 +48,7 @@ export const internalTestRoutes = async (fastify) => {
await sql`DELETE FROM chat_request_notifications WHERE session_id IN (SELECT id FROM chat_sessions WHERE customer_id = ${id})`
await sql`DELETE FROM customer_transactions WHERE customer_id = ${id}`
await sql`DELETE FROM chat_sessions WHERE customer_id = ${id}`
await sql`DELETE FROM payment_sessions WHERE customer_id = ${id}`
await sql`DELETE FROM payment_requests WHERE customer_id = ${id}`
await sql`DELETE FROM auth_sessions WHERE user_id = ${id} AND user_type = 'customer'`
}
await sql`DELETE FROM customers WHERE phone = ${phone}`
@@ -65,7 +65,7 @@ export const internalTestRoutes = async (fastify) => {
let target
if (latest === true) {
const [row] = await sql`
SELECT id FROM payment_sessions
SELECT id FROM payment_requests
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 1
@@ -78,7 +78,7 @@ export const internalTestRoutes = async (fastify) => {
return reply.code(400).send({ error: 'latest:true required in body' })
}
const [updated] = await sql`
UPDATE payment_sessions
UPDATE payment_requests
SET status = 'confirmed', confirmed_at = NOW()
WHERE id = ${target} AND status = 'pending'
RETURNING id, customer_id, status, mode, duration_minutes, is_first_session_discount, targeted_mitra_id
@@ -105,7 +105,7 @@ export const internalTestRoutes = async (fastify) => {
let target
if (latest === true) {
const [row] = await sql`
SELECT id FROM payment_sessions
SELECT id FROM payment_requests
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 1
@@ -120,7 +120,7 @@ export const internalTestRoutes = async (fastify) => {
return reply.code(400).send({ error: 'payment_id or latest:true required in body' })
}
const [updated] = await sql`
UPDATE payment_sessions
UPDATE payment_requests
SET status = 'expired', expires_at = NOW() - INTERVAL '1 minute'
WHERE id = ${target} AND status = 'pending'
RETURNING id, status
@@ -168,7 +168,7 @@ export const internalTestRoutes = async (fastify) => {
const [linked] = await sql`
SELECT ps.targeted_mitra_id
FROM chat_sessions cs
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
WHERE cs.id = ${target}
LIMIT 1
`
@@ -268,7 +268,7 @@ export const internalTestRoutes = async (fastify) => {
return { ok: true, session_id: session.id, mitra_id: mitra.id, mitra_name: mitra.display_name }
})
// Seed a payment_sessions row in `pending` status for the customer linked
// Seed a payment_requests row in `pending` status for the customer linked
// to `phone`, with expires_at safely in the future. Used by Maestro Stage
// 10 flow (09_chat_tab.yaml) to populate the Pembayaran sub-tab without
// walking the multi-screen S6 paywall → method → duration → method flow.
@@ -297,7 +297,7 @@ export const internalTestRoutes = async (fastify) => {
return reply.code(404).send({ error: 'no_customer_for_phone', phone })
}
const [row] = await sql`
INSERT INTO payment_sessions (
INSERT INTO payment_requests (
customer_id, amount, duration_minutes, is_first_session_discount,
is_extension, status, mode, expires_at
) VALUES (