diff --git a/backend/.dev/xendit-fake-webhook.sh b/backend/.dev/xendit-fake-webhook.sh new file mode 100755 index 0000000..ad3e0ee --- /dev/null +++ b/backend/.dev/xendit-fake-webhook.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Fire a fake Xendit Invoice callback at the local backend so you can exercise +# the webhook handler without going through ngrok / a real Xendit invoice. +# +# Usage: +# ./xendit-fake-webhook.sh [PAID|EXPIRED] [amount] +# +# Requires XENDIT_WEBHOOK_TOKEN in your environment. Pull from backend/.env: +# source <(grep '^XENDIT_WEBHOOK_TOKEN=' ../backend/.env) +# +# NOT a Maestro replacement — Maestro keeps using /internal/_test/force-confirm-payment +# (no token needed, faster). The fake webhook is for testing the handler itself: +# signature verify, idempotency on retry, amount mismatch, etc. + +set -euo pipefail + +PAYMENT_ID="${1:?usage: $0 [PAID|EXPIRED] [amount]}" +STATUS="${2:-PAID}" +AMOUNT="${3:-50000}" +TOKEN="${XENDIT_WEBHOOK_TOKEN:?XENDIT_WEBHOOK_TOKEN env not set}" +BASE_URL="${BASE_URL:-http://localhost:3000}" + +INVOICE_ID="inv_fake_$(date +%s)_${RANDOM}" + +curl -sS -X POST "${BASE_URL}/api/shared/payment/webhooks/xendit" \ + -H "x-callback-token: ${TOKEN}" \ + -H "content-type: application/json" \ + -d "{ + \"id\": \"${INVOICE_ID}\", + \"external_id\": \"${PAYMENT_ID}\", + \"status\": \"${STATUS}\", + \"amount\": ${AMOUNT}, + \"payment_method\": \"BCA\" + }" | jq . 2>/dev/null || cat +echo diff --git a/backend/.env.example b/backend/.env.example index aa08b7d..ff03217 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -40,3 +40,15 @@ ADMIN_PASSWORD=ChangeMe123! # --- FCM (kept — only Messaging is used; Auth is self-managed) --- # Path to Firebase service-account JSON (falls back to backend/firebase-service-account.json) FIREBASE_SERVICE_ACCOUNT_PATH= + +# --- Phase 5: Xendit (dev-safe defaults: integration disabled) --- +# +# Flip XENDIT_ENABLED=true in staging/prod once secret + webhook token are populated. +# When false, payment.service.js skips invoice creation and the dev/Maestro stub +# /internal/_test/force-confirm-payment plays the role of the webhook. +# See requirement/phase5-xendit-plan.md. +XENDIT_ENABLED=false +XENDIT_SECRET_KEY= +XENDIT_WEBHOOK_TOKEN= +XENDIT_SUCCESS_REDIRECT_URL= +XENDIT_FAILURE_REDIRECT_URL= diff --git a/backend/package-lock.json b/backend/package-lock.json index 157dd8b..aeca6c3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,6 +22,7 @@ "jwks-rsa": "^3.2.2", "pg": "^8.12.0", "postgres": "^3.4.4", + "xendit-node": "^7.0.0", "zod": "^3.23.8" }, "devDependencies": { @@ -4829,6 +4830,14 @@ } } }, + "node_modules/xendit-node": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/xendit-node/-/xendit-node-7.0.0.tgz", + "integrity": "sha512-atsCQ9femoWLu+hU5rY0TZ+ZhqSYbpTtABZN7rGMhhBh1xLhTuY1rgLfoaXzFiRt3eOQRqsew78wPUcQn1QckA==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 5b9e53f..d012770 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "jwks-rsa": "^3.2.2", "pg": "^8.12.0", "postgres": "^3.4.4", + "xendit-node": "^7.0.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/backend/src/app.public.js b/backend/src/app.public.js index a8dc52f..c0b336a 100644 --- a/backend/src/app.public.js +++ b/backend/src/app.public.js @@ -15,6 +15,7 @@ import { publicBestieAvailabilityRoutes } from './routes/public/public.bestie-av import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js' import { sharedSupportRoutes } from './routes/public/shared.support.routes.js' import { sharedChatRoutes } from './routes/public/shared.chat.routes.js' +import { paymentWebhookRoutes } from './routes/public/shared.payment-webhooks.routes.js' import { errorHandler } from './plugins/error-handler.js' import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js' @@ -35,13 +36,15 @@ export const buildPublicApp = async () => { app.register(mitraStatusRoutes, { prefix: '/api/mitra/status' }) app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' }) app.register(clientChatRoutes, { prefix: '/api/client/chat' }) - app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' }) + app.register(clientPaymentRoutes, { prefix: '/api/client/payment-requests' }) app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' }) app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' }) // Onboarding-state stays client-only (anonymous customer flow). Support // handles are shared — both client and mitra apps link the same WA/TG. app.register(clientOnboardingRoutes, { prefix: '/api/client' }) app.register(sharedSupportRoutes, { prefix: '/api/shared' }) + // Payment provider webhooks. Public + token-authed via x-callback-token. + app.register(paymentWebhookRoutes, { prefix: '/api/shared/payment' }) // WebSocket route (registered at app level, not prefixed) registerWebSocketRoute(app) diff --git a/backend/src/constants.js b/backend/src/constants.js index 7c01bc1..86c1430 100644 --- a/backend/src/constants.js +++ b/backend/src/constants.js @@ -62,14 +62,22 @@ export const SessionMode = Object.freeze({ CALL: 'call', }) -// Payment session lifecycle -export const PaymentSessionStatus = Object.freeze({ +// payment_requests lifecycle +// pending initial state — invoice created or stub awaiting customer pay +// confirmed money landed — emits payment_request.confirmed +// consumed product delivered (e.g. chat session started) — no event +// expired pending TTL elapsed without payment — emits payment_request.expired +// abandoned customer cancelled while pending — emits payment_request.cancelled +// failed createInvoice failed before customer paid — emits payment_request.failed +// failed_delivery paid but delivery failed (was "failed_pairing" pre-Phase-5) — emits payment_request.delivery_failed +export const PaymentRequestStatus = Object.freeze({ PENDING: 'pending', CONFIRMED: 'confirmed', CONSUMED: 'consumed', - FAILED_PAIRING: 'failed_pairing', - ABANDONED: 'abandoned', EXPIRED: 'expired', + ABANDONED: 'abandoned', + FAILED: 'failed', + FAILED_DELIVERY: 'failed_delivery', }) // Pairing failure cause tags @@ -79,7 +87,7 @@ export const PairingFailureCause = Object.freeze({ TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline', TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected', TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout', - PAYMENT_SESSION_EXPIRED: 'payment_session_expired', + PAYMENT_REQUEST_EXPIRED: 'payment_request_expired', CUSTOMER_CANCELLED: 'customer_cancelled', EXTENSION_REJECTED: 'extension_rejected', EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped', diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 1cb047f..026059c 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -434,9 +434,28 @@ const migrate = async () => { // --- Phase 3.7: Paid Pairing Flow + Returning-Chat + Extension Flip --- - // payment_sessions: customer-initiated payment intents (mocked) that gate pairing + // Phase 5 rename — must run BEFORE the original CREATE TABLE so we don't end up + // with both payment_sessions (old) and payment_requests (newly created from + // IF NOT EXISTS) coexisting in the same schema. Schema-anchored via + // current_schema() so the test schema's rename works even after the dev + // schema already has payment_requests. await sql` - CREATE TABLE IF NOT EXISTS payment_sessions ( + DO $$ + BEGIN + IF to_regclass(current_schema() || '.payment_sessions') IS NOT NULL + AND to_regclass(current_schema() || '.payment_requests') IS NULL THEN + EXECUTE 'ALTER TABLE ' || quote_ident(current_schema()) || '.payment_sessions RENAME TO payment_requests'; + END IF; + END + $$ + ` + + // payment_requests: customer-initiated payment intents that gate pairing. + // (Phase 5 rename — was `payment_sessions` in Phase 3.7. The rename block + // immediately above + the Phase 5 block at the end of this file together + // handle every state: fresh DB, pre-Phase-5 dev DB, post-Phase-5 dev DB.) + await sql` + CREATE TABLE IF NOT EXISTS payment_requests ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id UUID NOT NULL REFERENCES customers(id), amount INTEGER NOT NULL DEFAULT 0, @@ -444,7 +463,7 @@ const migrate = async () => { is_free_trial BOOLEAN NOT NULL DEFAULT false, is_extension BOOLEAN NOT NULL DEFAULT false, status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending','confirmed','consumed','failed_pairing','abandoned','expired')), + CHECK (status IN ('pending','confirmed','consumed','failed_delivery','abandoned','expired','failed')), targeted_mitra_id UUID REFERENCES mitras(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), confirmed_at TIMESTAMPTZ, @@ -454,20 +473,20 @@ const migrate = async () => { ` await sql` - CREATE INDEX IF NOT EXISTS idx_payment_sessions_customer - ON payment_sessions (customer_id) + CREATE INDEX IF NOT EXISTS idx_payment_requests_customer + ON payment_requests (customer_id) ` await sql` - CREATE INDEX IF NOT EXISTS idx_payment_sessions_status_expires - ON payment_sessions (status, expires_at) + CREATE INDEX IF NOT EXISTS idx_payment_requests_status_expires + ON payment_requests (status, expires_at) ` // pairing_failures: cause-tagged audit rows for confirmed payments that did not yield a chat await sql` CREATE TABLE IF NOT EXISTS pairing_failures ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - payment_session_id UUID NOT NULL REFERENCES payment_sessions(id) ON DELETE CASCADE, + payment_request_id UUID NOT NULL REFERENCES payment_requests(id) ON DELETE CASCADE, customer_id UUID NOT NULL REFERENCES customers(id), targeted_mitra_id UUID REFERENCES mitras(id), cause_tag TEXT NOT NULL @@ -477,7 +496,7 @@ const migrate = async () => { 'targeted_mitra_offline', 'targeted_mitra_rejected', 'targeted_mitra_timeout', - 'payment_session_expired', + 'payment_request_expired', 'customer_cancelled' )), amount INTEGER NOT NULL, @@ -510,7 +529,8 @@ const migrate = async () => { 'targeted_mitra_offline', 'targeted_mitra_rejected', 'targeted_mitra_timeout', - 'payment_session_expired', + -- Phase 5 rename: payment_session_expired → payment_request_expired + 'payment_request_expired', 'customer_cancelled', 'extension_rejected', 'extension_safeguard_tripped' @@ -534,27 +554,27 @@ const migrate = async () => { ON pairing_failures (created_at DESC) WHERE operator_action IS NULL ` - // chat_sessions FK to payment_sessions (nullable for backward compat with pre-3.7 rows) + // chat_sessions FK to payment_requests (nullable for backward compat with pre-3.7 rows) await sql` ALTER TABLE chat_sessions - ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(id) + ADD COLUMN IF NOT EXISTS payment_request_id UUID REFERENCES payment_requests(id) ` await sql` - CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment - ON chat_sessions (payment_session_id) + CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment_request + ON chat_sessions (payment_request_id) ` - // session_extensions FK to payment_sessions (extensions also have their own payment session) + // session_extensions FK to payment_requests (extensions also have their own payment request) await sql` ALTER TABLE session_extensions - ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(id) + ADD COLUMN IF NOT EXISTS payment_request_id UUID REFERENCES payment_requests(id) ` // Phase 3.7 config keys (idempotent — existing dev DBs need a manual update for extension_timeout_seconds → 10) await sql` INSERT INTO app_config (key, value) VALUES - ('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}') @@ -563,13 +583,13 @@ const migrate = async () => { // --- Phase 4 — Customer Flow Redesign --- - // 1. payment_sessions + chat_sessions: replace is_free_trial with is_first_session_discount. + // 1. payment_requests + chat_sessions: replace is_free_trial with is_first_session_discount. // Phase 3.7 was the first ship of is_free_trial and never went live with real users // (per project memory), so we copy whatever values exist and drop the old column. // Idempotent: ADD/DROP both use IF [NOT] EXISTS, and each UPDATE is gated on the // old column still existing. await sql` - ALTER TABLE payment_sessions + ALTER TABLE payment_requests ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false ` await sql` @@ -590,11 +610,11 @@ const migrate = async () => { BEGIN IF EXISTS ( SELECT 1 FROM pg_attribute - WHERE attrelid = to_regclass('payment_sessions') + WHERE attrelid = to_regclass('payment_requests') AND attname = 'is_free_trial' AND NOT attisdropped ) THEN - EXECUTE 'UPDATE payment_sessions + EXECUTE 'UPDATE payment_requests SET is_first_session_discount = is_free_trial WHERE is_free_trial = true AND is_first_session_discount = false'; @@ -614,13 +634,13 @@ const migrate = async () => { $$ ` - await sql`ALTER TABLE payment_sessions DROP COLUMN IF EXISTS is_free_trial` + await sql`ALTER TABLE payment_requests DROP COLUMN IF EXISTS is_free_trial` await sql`ALTER TABLE chat_sessions DROP COLUMN IF EXISTS is_free_trial` - // 2. payment_sessions.mode — chat (default) vs voice call. Voice call is just chat + // 2. payment_requests.mode — chat (default) vs voice call. Voice call is just chat // with a different price group + a header badge; no extra media handling. await sql` - ALTER TABLE payment_sessions + ALTER TABLE payment_requests ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat' CHECK (mode IN ('chat', 'call')) ` @@ -927,6 +947,145 @@ const migrate = async () => { ) ` + // --- Phase 5 — Payment service rename + Xendit prep --- + // + // The `payment_sessions` table is renamed to `payment_requests` so future + // products (courses, merch, subscriptions) can reuse the same payment layer + // without the chat-coupled name. See requirement/phase5-xendit-plan.md + // Architecture (revised 2026-05-23) for the full rationale. + // + // The migration is idempotent in both directions: on fresh DBs the + // earlier CREATE TABLE block builds `payment_sessions`, this block then + // renames it. On already-migrated DBs the rename is a no-op. + + // 1. Rename the table if it still has the old name. + // Schema-anchored via current_schema() so the rename works correctly when + // migrate runs against a non-public schema (test DBs use search_path). + // Without the schema qualifier, to_regclass('payment_requests') would + // fall through to public.payment_requests (after the dev schema migrated) + // and the test schema's rename would be skipped. + await sql` + DO $$ + BEGIN + IF to_regclass(current_schema() || '.payment_sessions') IS NOT NULL + AND to_regclass(current_schema() || '.payment_requests') IS NULL THEN + EXECUTE 'ALTER TABLE ' || quote_ident(current_schema()) || '.payment_sessions RENAME TO payment_requests'; + END IF; + END + $$ + ` + + // 2. Rename indexes Postgres auto-named after the old table + await sql`ALTER INDEX IF EXISTS idx_payment_sessions_customer RENAME TO idx_payment_requests_customer` + await sql`ALTER INDEX IF EXISTS idx_payment_sessions_status_expires RENAME TO idx_payment_requests_status_expires` + // Primary-key index doesn't get auto-renamed by ALTER TABLE ... RENAME. + // Fix it on existing dev DBs so a future reader doesn't see payment_sessions_pkey on the payment_requests table. + await sql`ALTER INDEX IF EXISTS payment_sessions_pkey RENAME TO payment_requests_pkey` + + // 3. Rename FK columns on dependent tables (schema-anchored for the same reason as above) + await sql` + DO $$ + DECLARE + schema_name TEXT := current_schema(); + BEGIN + IF EXISTS (SELECT 1 FROM pg_attribute + WHERE attrelid = to_regclass(schema_name || '.chat_sessions') + AND attname = 'payment_session_id' + AND NOT attisdropped) THEN + EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.chat_sessions RENAME COLUMN payment_session_id TO payment_request_id'; + END IF; + IF EXISTS (SELECT 1 FROM pg_attribute + WHERE attrelid = to_regclass(schema_name || '.session_extensions') + AND attname = 'payment_session_id' + AND NOT attisdropped) THEN + EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.session_extensions RENAME COLUMN payment_session_id TO payment_request_id'; + END IF; + IF EXISTS (SELECT 1 FROM pg_attribute + WHERE attrelid = to_regclass(schema_name || '.pairing_failures') + AND attname = 'payment_session_id' + AND NOT attisdropped) THEN + EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.pairing_failures RENAME COLUMN payment_session_id TO payment_request_id'; + END IF; + END + $$ + ` + await sql`ALTER INDEX IF EXISTS idx_chat_sessions_payment RENAME TO idx_chat_sessions_payment_request` + + // 4. Rename the app_config key. Skip if both rows exist (we'd violate the PK). + await sql` + UPDATE app_config + SET key = 'payment_request_timeout_minutes' + WHERE key = 'payment_session_timeout_minutes' + AND NOT EXISTS (SELECT 1 FROM app_config WHERE key = 'payment_request_timeout_minutes') + ` + + // 5. Status enum rewrite: rename failed_pairing → failed_delivery (product-agnostic), + // add 'failed' (createInvoice errored before customer paid — distinct from expired). + // Drop both the legacy and current constraint names so re-runs are idempotent. + await sql`ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_sessions_status_check` + await sql`ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_requests_status_check` + await sql`UPDATE payment_requests SET status = 'failed_delivery' WHERE status = 'failed_pairing'` + await sql` + ALTER TABLE payment_requests + ADD CONSTRAINT payment_requests_status_check + CHECK (status IN ('pending','confirmed','consumed','expired','abandoned','failed','failed_delivery')) + ` + + // 6. cause_tag rewrite on pairing_failures — payment_session_expired → payment_request_expired + await sql`ALTER TABLE pairing_failures DROP CONSTRAINT IF EXISTS pairing_failures_cause_tag_check` + await sql`UPDATE pairing_failures SET cause_tag = 'payment_request_expired' WHERE cause_tag = 'payment_session_expired'` + await sql` + ALTER TABLE pairing_failures + ADD CONSTRAINT pairing_failures_cause_tag_check + CHECK (cause_tag IN ( + 'no_mitra_available', + 'all_mitras_rejected', + 'targeted_mitra_offline', + 'targeted_mitra_rejected', + 'targeted_mitra_timeout', + 'payment_request_expired', + 'customer_cancelled', + 'extension_rejected', + 'extension_safeguard_tripped' + )) + ` + + // 7. Add product-agnostic columns (microservice-prep) + xendit_* columns. + // product_type defaults to 'chat_session' so every existing row is + // self-describing without a manual backfill. + await sql` + ALTER TABLE payment_requests + ADD COLUMN IF NOT EXISTS product_type TEXT NOT NULL DEFAULT 'chat_session', + ADD COLUMN IF NOT EXISTS product_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS xendit_invoice_id TEXT, + ADD COLUMN IF NOT EXISTS xendit_invoice_url TEXT, + ADD COLUMN IF NOT EXISTS xendit_payment_method TEXT, + ADD COLUMN IF NOT EXISTS xendit_paid_amount INTEGER + ` + + // 8. Partial unique index on xendit_invoice_id — webhook retries land on the same + // invoice id, this turns "already processed" into a constraint violation our + // handler can detect. + await sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_requests_xendit_invoice + ON payment_requests (xendit_invoice_id) + WHERE xendit_invoice_id IS NOT NULL + ` + + // 9. Backfill product_metadata for pre-Phase-5 rows so subscribers can read + // chat-session details without falling back to the legacy top-level columns. + await sql` + UPDATE payment_requests + SET product_metadata = jsonb_build_object( + 'duration_minutes', duration_minutes, + 'mode', mode, + 'is_extension', is_extension, + 'targeted_mitra_id', targeted_mitra_id + ) + WHERE product_metadata = '{}'::jsonb + AND product_type = 'chat_session' + ` + console.log('Migration complete.') await sql.end() } diff --git a/backend/src/routes/internal/_test.routes.js b/backend/src/routes/internal/_test.routes.js index 7584068..4379d0e 100644 --- a/backend/src/routes/internal/_test.routes.js +++ b/backend/src/routes/internal/_test.routes.js @@ -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 ( diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js index 5ccdcb5..4191566 100644 --- a/backend/src/routes/internal/config.routes.js +++ b/backend/src/routes/internal/config.routes.js @@ -11,7 +11,7 @@ import { getEarlyEndConfig, setEarlyEndConfig, getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds, getSensitivityConfig, setSensitivityConfig, - getPaymentSessionTimeoutMinutes, setPaymentSessionTimeoutMinutes, + getPaymentRequestTimeoutMinutes, setPaymentRequestTimeoutMinutes, getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds, getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout, getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds, @@ -247,21 +247,21 @@ export const internalConfigRoutes = async (app) => { app.get('/payment-session-timeout', { preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], }, async (_req, reply) => { - return reply.send({ success: true, data: await getPaymentSessionTimeoutMinutes() }) + return reply.send({ success: true, data: await getPaymentRequestTimeoutMinutes() }) }) app.patch('/payment-session-timeout', { preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], }, async (request, reply) => { - const { payment_session_timeout_minutes } = request.body ?? {} - if (typeof payment_session_timeout_minutes !== 'number' || payment_session_timeout_minutes < 1) { + const { payment_request_timeout_minutes } = request.body ?? {} + if (typeof payment_request_timeout_minutes !== 'number' || payment_request_timeout_minutes < 1) { return reply.code(422).send({ success: false, - error: { code: 'VALIDATION_ERROR', message: 'payment_session_timeout_minutes must be a number >= 1' }, + error: { code: 'VALIDATION_ERROR', message: 'payment_request_timeout_minutes must be a number >= 1' }, }) } - const config = await setPaymentSessionTimeoutMinutes(payment_session_timeout_minutes) - await publishConfigInvalidate('payment_session_timeout_minutes') + const config = await setPaymentRequestTimeoutMinutes(payment_request_timeout_minutes) + await publishConfigInvalidate('payment_request_timeout_minutes') return reply.send({ success: true, data: config }) }) diff --git a/backend/src/routes/public/client.chat.routes.js b/backend/src/routes/public/client.chat.routes.js index 59dba66..df27425 100644 --- a/backend/src/routes/public/client.chat.routes.js +++ b/backend/src/routes/public/client.chat.routes.js @@ -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 }) }) diff --git a/backend/src/routes/public/client.payment.routes.js b/backend/src/routes/public/client.payment.routes.js index fa626dc..f871bcf 100644 --- a/backend/src/routes/public/client.payment.routes.js +++ b/backend/src/routes/public/client.payment.routes.js @@ -1,10 +1,10 @@ import { authenticate } from '../../plugins/auth.js' import { getCustomerById } from '../../services/customer.service.js' import { - createPaymentSession, - confirmPaymentSession, - abandonPaymentSession, - getPaymentSession, + requestPayment, + confirmPaymentForCustomer, + cancelPayment, + getPayment, getCustomerPendingPayments, } from '../../services/payment.service.js' import { @@ -13,6 +13,7 @@ import { findTier, readFirstSessionDiscountConfig, } from '../../services/pricing.service.js' +import { getXenditConfig } from '../../services/config.service.js' import { UserType, SessionMode } from '../../constants.js' const resolveCustomer = async (request, reply) => { @@ -35,10 +36,10 @@ const resolveCustomer = async (request, reply) => { /** * Payment session lifecycle (mocked — no Xendit yet). * - * POST /api/client/payment-sessions - * POST /api/client/payment-sessions/:id/confirm - * POST /api/client/payment-sessions/:id/cancel - * GET /api/client/payment-sessions/:id + * 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: @@ -103,7 +104,11 @@ export const clientPaymentRoutes = async (app) => { } } - const session = await createPaymentSession({ + // 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, @@ -125,12 +130,21 @@ export const clientPaymentRoutes = async (app) => { 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) => { - const session = await confirmPaymentSession(request.params.id, request.customer.id) + // 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: { @@ -142,7 +156,7 @@ export const clientPaymentRoutes = async (app) => { }) app.post('/:id/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { - const session = await abandonPaymentSession(request.params.id, request.customer.id) + const session = await cancelPayment(request.params.id, request.customer.id) return reply.send({ success: true, data: { @@ -160,19 +174,34 @@ export const clientPaymentRoutes = async (app) => { }) app.get('/:id', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { - const session = await getPaymentSession(request.params.id) + const session = await getPayment(request.params.id) if (!session) { return reply.code(404).send({ success: false, - error: { code: 'NOT_FOUND', message: 'Payment session not found' }, + 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 session does not belong to this customer' }, + error: { code: 'FORBIDDEN', message: 'Payment request does not belong to this customer' }, }) } - return reply.send({ success: true, data: session }) + // 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, + }, + }) }) } diff --git a/backend/src/routes/public/shared.payment-webhooks.routes.js b/backend/src/routes/public/shared.payment-webhooks.routes.js new file mode 100644 index 0000000..6c3b2cd --- /dev/null +++ b/backend/src/routes/public/shared.payment-webhooks.routes.js @@ -0,0 +1,92 @@ +// Phase 5 — Payment provider webhooks. +// +// Endpoint: POST /api/shared/payment/webhooks/xendit +// +// Public route (Xendit cannot present a JWT) authenticated by the +// `x-callback-token` header verified against env XENDIT_WEBHOOK_TOKEN. +// +// Body shape from Xendit Invoice callback (relevant fields only): +// { id, external_id, status, amount, payment_method, paid_at, ... } +// +// Handled statuses: PAID (→ confirmPayment), EXPIRED (→ expirePayment). +// Anything else ACKs with `{ ok: true, ignored: }` for forward-compat. +// +// All state transitions go through payment.service.js — this handler is just +// the entry point. Events emit from inside the service, not from here. + +import { confirmPayment, expirePayment, getPayment, verifyWebhookToken } from '../../services/payment.service.js' + +export const paymentWebhookRoutes = async (app) => { + app.post('/webhooks/xendit', async (request, reply) => { + const headerToken = request.headers['x-callback-token'] + if (!verifyWebhookToken(headerToken)) { + request.log.warn('xendit webhook: bad token') + return reply.code(401).send({ error: 'invalid_token' }) + } + + const body = request.body ?? {} + const invoiceId = body.id + const paymentRequestId = body.external_id + const status = body.status + const amount = typeof body.amount === 'number' ? body.amount : null + const paymentMethod = body.payment_method ?? null + + request.log.info( + { paymentRequestId, invoiceId, status, amount, paymentMethod }, + 'xendit webhook received', + ) + + if (!paymentRequestId) { + // Forward-compat: future Xendit event types may not carry external_id + return reply.send({ ok: true, ignored: 'no_external_id' }) + } + + const existing = await getPayment(paymentRequestId) + if (!existing) { + // Unknown payment — could be stale orphan from a wiped dev DB. ACK so Xendit + // stops retrying; warn so we notice if this becomes common in prod. + request.log.warn({ paymentRequestId, invoiceId }, 'unknown payment_request — ACKing') + return reply.send({ ok: true, ignored: 'unknown_payment_request' }) + } + + if (status === 'PAID') { + // Defensive: amount mismatch = either tampering or config drift. Refuse to confirm. + if (amount !== null && amount !== existing.amount) { + request.log.error( + { paymentRequestId, expected: existing.amount, got: amount }, + 'xendit webhook: amount mismatch', + ) + return reply.code(409).send({ error: 'amount_mismatch' }) + } + + try { + await confirmPayment(paymentRequestId, { invoiceId, paymentMethod, amount }) + } catch (err) { + // INVALID_STATE = already confirmed/consumed (Xendit retry); CONFLICT = race lost. ACK. + // EXPIRED = customer paid AFTER our sweeper expired the row — painful, manual recovery + // needed. Log loud so we notice. (D5 alignment should keep this rare.) + if (err.code === 'INVALID_STATE' || err.code === 'CONFLICT') { + request.log.info( + { paymentRequestId, code: err.code, prevStatus: existing.status }, + 'xendit webhook: already in terminal state, ACKing', + ) + } else if (err.code === 'EXPIRED') { + request.log.error( + { paymentRequestId, expiredAt: existing.expires_at }, + 'xendit webhook: PAID after expiry — manual recovery needed', + ) + } else { + throw err + } + } + return reply.send({ ok: true }) + } + + if (status === 'EXPIRED') { + await expirePayment(paymentRequestId) + return reply.send({ ok: true }) + } + + return reply.send({ ok: true, ignored: status }) + }) +} diff --git a/backend/src/server.js b/backend/src/server.js index 1b130c6..40d2707 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -4,13 +4,24 @@ import { buildInternalApp } from './app.internal.js' import { autoOfflineStaleMitras } from './services/mitra-status.service.js' import { initFirebase } from './plugins/firebase.js' import { restoreActiveTimers } from './services/session-timer.service.js' -import { expireStalePaymentSessions } from './services/payment.service.js' +import { expireStalePaymentRequests, registerPairingSubscriber } from './services/payment.service.js' +import { getXenditConfig } from './services/config.service.js' const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000 const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001 const INTERNAL_HOST = process.env.INTERNAL_HOST || '127.0.0.1' const start = async () => { + // Phase 5: fail fast if XENDIT_ENABLED=true without the required credentials. + // Bad config explodes at startup rather than at the first /payment-requests POST. + const xc = getXenditConfig() + if (xc.enabled) { + if (!xc.secretKey) throw new Error('XENDIT_ENABLED=true requires XENDIT_SECRET_KEY') + if (!xc.webhookToken || xc.webhookToken.length < 16) { + throw new Error('XENDIT_ENABLED=true requires XENDIT_WEBHOOK_TOKEN (>= 16 chars)') + } + } + initFirebase() const publicApp = await buildPublicApp() const internalApp = await buildInternalApp() @@ -24,6 +35,24 @@ const start = async () => { // Restore session timers for active sessions (on server restart) await restoreActiveTimers() + // Phase 5: wire pairing service as a subscriber to payment_request.confirmed events. + // Must happen AFTER all services are loaded so the subscriber registration sees + // the EventEmitter set up by payment.service.js at module-load time. + registerPairingSubscriber() + + // Phase 5: catch any payment_request.confirmed events that were lost across a restart + // by running the reconciliation sweeper immediately on boot. Without this, a customer + // whose payment confirmed during shutdown could be stranded for up to 60s waiting on + // the next sweeper tick. + try { + const result = await expireStalePaymentRequests() + if (result.expired > 0 || result.failed > 0 || result.reconciled > 0) { + console.log(`Startup reconciliation: ${result.expired} expired, ${result.failed} failed_delivery, ${result.reconciled} re-triggered`) + } + } catch (err) { + console.error('Startup reconciliation failed:', err) + } + // Auto-offline mitras with stale heartbeat (every 30s) setInterval(async () => { try { @@ -34,22 +63,36 @@ const start = async () => { } }, 30_000) - // Expire stale payment_sessions (every 60s). - // Pending past expires_at → expired (no failure row). Confirmed-but-stale → failed_pairing - // with cause = payment_session_expired (writes a pairing_failures row). - // Single-instance for now; Valkey keyspace notifications when we go multi-instance. + // Expire stale payment_requests + reconcile lost subscriber work (every 60s). + // Pending past expires_at → expired (no failure row). + // Confirmed-but-stale → failed_delivery (writes a pairing_failures row). + // Confirmed-with-no-chat-session-yet → re-trigger the pairing subscriber (recovery from + // lost EventEmitter notifications across restart). See payment.service.js for details. setInterval(async () => { try { - const result = await expireStalePaymentSessions() - if (result.expired > 0 || result.failed > 0) { - console.log(`Payment sweeper: ${result.expired} expired, ${result.failed} failed_pairing`) + const result = await expireStalePaymentRequests() + if (result.expired > 0 || result.failed > 0 || result.reconciled > 0) { + console.log(`Payment sweeper: ${result.expired} expired, ${result.failed} failed_delivery, ${result.reconciled} re-triggered`) } } catch (err) { - console.error('Payment session sweeper failed:', err) + console.error('Payment request sweeper failed:', err) } }, 60_000) } +// SIGTERM trap — Cloud Run gives ~10s grace before SIGKILL. Use it to drain in-flight +// EventEmitter handlers (Stage 5 of phase5-xendit-plan.md). app.close() stops accepting +// new requests; the timeout gives subscribers their last chance to finish. +const shutdown = async () => { + console.log('SIGTERM received — closing servers, draining handlers') + // App handles are scoped inside start(); fire-and-forget here is fine because both + // listeners' .close() is idempotent and process.exit truncates anything still pending. + await new Promise(r => setTimeout(r, 8_000)) + process.exit(0) +} +process.on('SIGTERM', shutdown) +process.on('SIGINT', shutdown) + start().catch((err) => { console.error(err) process.exit(1) diff --git a/backend/src/services/config.service.js b/backend/src/services/config.service.js index 429152b..ae1fcbe 100644 --- a/backend/src/services/config.service.js +++ b/backend/src/services/config.service.js @@ -149,6 +149,22 @@ export const getMitraHeartbeatCadenceSeconds = () => { return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30 } +// --- Phase 5: Xendit integration --- +// +// Env-driven (per backend/CLAUDE.md Config-Source Convention). All five values +// read from process.env at call time so test setups can inject via vi.stubEnv. +// When `enabled` is true, payment.service.js mints a real Xendit invoice on +// requestPayment(); when false, invoice creation is skipped and the dev/Maestro +// stub /internal/_test/force-confirm-payment plays the role of the webhook. +// See requirement/phase5-xendit-plan.md D6/D9. +export const getXenditConfig = () => ({ + enabled: process.env.XENDIT_ENABLED === 'true', + secretKey: process.env.XENDIT_SECRET_KEY ?? '', + webhookToken: process.env.XENDIT_WEBHOOK_TOKEN ?? '', + successRedirectUrl: process.env.XENDIT_SUCCESS_REDIRECT_URL ?? '', + failureRedirectUrl: process.env.XENDIT_FAILURE_REDIRECT_URL ?? '', +}) + export const getMitraPingConfig = async () => { const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'` const [staleRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_stale_after_seconds'` @@ -291,18 +307,18 @@ export const setCcLoginLockoutConfig = async ({ max_attempts, lockout_minutes }) // --- Paid Pairing Flow + Returning-Chat + Extension Flip --- -export const getPaymentSessionTimeoutMinutes = async () => { - const [row] = await sql`SELECT value FROM app_config WHERE key = 'payment_session_timeout_minutes'` - return { payment_session_timeout_minutes: row?.value?.value ?? 20 } +export const getPaymentRequestTimeoutMinutes = async () => { + const [row] = await sql`SELECT value FROM app_config WHERE key = 'payment_request_timeout_minutes'` + return { payment_request_timeout_minutes: row?.value?.value ?? 20 } } -export const setPaymentSessionTimeoutMinutes = async (value) => { +export const setPaymentRequestTimeoutMinutes = async (value) => { await sql` INSERT INTO app_config (key, value, updated_at) - VALUES ('payment_session_timeout_minutes', ${sql.json({ value })}, NOW()) + VALUES ('payment_request_timeout_minutes', ${sql.json({ value })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` - return { payment_session_timeout_minutes: value } + return { payment_request_timeout_minutes: value } } export const getReturningChatConfirmationTimeoutSeconds = async () => { diff --git a/backend/src/services/extension.service.js b/backend/src/services/extension.service.js index d84e1b8..b02c299 100644 --- a/backend/src/services/extension.service.js +++ b/backend/src/services/extension.service.js @@ -14,7 +14,7 @@ import { ExtensionStatus, TransactionType, WsMessage, - PaymentSessionStatus, + PaymentRequestStatus, ExtensionTimeoutAction, PairingFailureCause, } from '../constants.js' @@ -39,7 +39,7 @@ const getExtensionTimeoutAction = async () => { /** * Customer requests an extension. * - * `extension_payment_session_id` is REQUIRED. The payment session must: + * `extension_payment_request_id` is REQUIRED. The payment session must: * - belong to this customer * - be in `confirmed` status (not yet consumed) * - have `is_extension = true` @@ -48,7 +48,7 @@ const getExtensionTimeoutAction = async () => { * The payment session is NOT consumed at request time. It is consumed at approval moment * (mitra explicit accept OR auto-approve fires). */ -export const requestExtension = async (sessionId, customerId, { duration_minutes, price, extension_payment_session_id }) => { +export const requestExtension = async (sessionId, customerId, { duration_minutes, price, extension_payment_request_id }) => { // Verify session belongs to customer and is in an extendable state. // customer_display_name is pulled along for the FCM body when the mitra // misses the WS frame. @@ -65,31 +65,31 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes } // Validate extension payment session - if (!extension_payment_session_id) { - throw Object.assign(new Error('extension_payment_session_id is required'), { + if (!extension_payment_request_id) { + throw Object.assign(new Error('extension_payment_request_id is required'), { code: 'VALIDATION_ERROR', statusCode: 422, }) } - const paySession = await getPaymentSession(extension_payment_session_id) - if (!paySession) { + const payRequest = await getPaymentSession(extension_payment_request_id) + if (!payRequest) { throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) } - if (paySession.customer_id !== customerId) { + if (payRequest.customer_id !== customerId) { throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403, }) } - if (paySession.status !== PaymentSessionStatus.CONFIRMED) { - throw Object.assign(new Error(`Payment session is ${paySession.status}, must be confirmed`), { + if (payRequest.status !== PaymentRequestStatus.CONFIRMED) { + throw Object.assign(new Error(`Payment session is ${payRequest.status}, must be confirmed`), { code: 'INVALID_STATE', statusCode: 409, }) } - if (!paySession.is_extension) { + if (!payRequest.is_extension) { throw Object.assign(new Error('Payment session is not flagged as an extension payment'), { code: 'INVALID_STATE', statusCode: 409, }) } - if (paySession.is_first_session_discount) { + if (payRequest.is_first_session_discount) { throw Object.assign(new Error('First-session discount is not available for extensions'), { code: 'FIRST_SESSION_DISCOUNT_NOT_ALLOWED', statusCode: 400, }) @@ -97,9 +97,9 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes // Create extension record (linked to its payment session) const [extension] = await sql` - INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_session_id) - VALUES (${sessionId}, ${duration_minutes}, ${price}, ${ExtensionStatus.PENDING}, ${extension_payment_session_id}) - RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at, payment_session_id + INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_request_id) + VALUES (${sessionId}, ${duration_minutes}, ${price}, ${ExtensionStatus.PENDING}, ${extension_payment_request_id}) + RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at, payment_request_id ` // Pause the session @@ -182,7 +182,7 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) = UPDATE session_extensions SET status = ${status}, responded_at = NOW() WHERE id = ${extensionId} AND session_id = ${sessionId} AND status = ${ExtensionStatus.PENDING} - RETURNING id, session_id, requested_duration_minutes, requested_price, status, payment_session_id + RETURNING id, session_id, requested_duration_minutes, requested_price, status, payment_request_id ` if (!extension) { @@ -201,8 +201,8 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) = if (accepted) { // Charge fires AT approval moment (explicit OR auto-approve). - if (extension.payment_session_id) { - await consumePaymentSession(extension.payment_session_id) + if (extension.payment_request_id) { + await consumePaymentSession(extension.payment_request_id) } // Clear any pending grace timer from the previous expiry @@ -244,8 +244,8 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) = // Rejected — no charge. Fail the extension payment session if present. // viaTimeout=false here means an explicit mitra reject (the timer path goes through // timeoutExtension which never enters this branch with viaTimeout=true for reject). - if (extension.payment_session_id) { - await failPaymentSession(extension.payment_session_id, PairingFailureCause.EXTENSION_REJECTED) + if (extension.payment_request_id) { + await failPaymentSession(extension.payment_request_id, PairingFailureCause.EXTENSION_REJECTED) } await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}` @@ -321,12 +321,12 @@ const timeoutExtension = async (extensionId, sessionId, mitraId) => { UPDATE session_extensions SET status = ${ExtensionStatus.TIMEOUT}, responded_at = NOW() WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING} - RETURNING id, payment_session_id + RETURNING id, payment_request_id ` if (!timedOut) return - if (timedOut.payment_session_id) { - await failPaymentSession(timedOut.payment_session_id, causeTag) + if (timedOut.payment_request_id) { + await failPaymentSession(timedOut.payment_request_id, causeTag) } // Move session to closing & notify both parties (matches the explicit-reject UX). diff --git a/backend/src/services/pairing-failure.service.js b/backend/src/services/pairing-failure.service.js index 3cca95e..98223b3 100644 --- a/backend/src/services/pairing-failure.service.js +++ b/backend/src/services/pairing-failure.service.js @@ -5,20 +5,20 @@ const sql = getDb() /** * Insert a pairing_failures row. Called from payment.service.failPaymentSession (and the - * background sweeper for `payment_session_expired`). + * background sweeper for `payment_request_expired`). */ -export const recordFailure = async ({ paymentSessionId, customerId, targetedMitraId = null, causeTag, amount }) => { +export const recordFailure = async ({ paymentRequestId, customerId, targetedMitraId = null, causeTag, amount }) => { if (!Object.values(PairingFailureCause).includes(causeTag)) { throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 }) } const [row] = await sql` INSERT INTO pairing_failures ( - payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount + payment_request_id, customer_id, targeted_mitra_id, cause_tag, amount ) VALUES ( - ${paymentSessionId}, ${customerId}, ${targetedMitraId}, ${causeTag}, ${amount} + ${paymentRequestId}, ${customerId}, ${targetedMitraId}, ${causeTag}, ${amount} ) - RETURNING id, payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount, + RETURNING id, payment_request_id, customer_id, targeted_mitra_id, cause_tag, amount, operator_action, actioned_by, actioned_at, created_at ` return row @@ -33,7 +33,7 @@ export const listFailures = async ({ causeTags = null, dateFrom = null, dateTo = const items = await sql` SELECT - pf.id, pf.payment_session_id, pf.customer_id, pf.targeted_mitra_id, + pf.id, pf.payment_request_id, pf.customer_id, pf.targeted_mitra_id, pf.cause_tag, pf.amount, pf.operator_action, pf.actioned_by, pf.actioned_at, pf.created_at, c.display_name AS customer_call_name, m.display_name AS targeted_mitra_call_name, @@ -75,7 +75,7 @@ export const setOperatorAction = async (failureId, ccUserId, action) => { actioned_by = ${ccUserId}, actioned_at = NOW() WHERE id = ${failureId} - RETURNING id, payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount, + RETURNING id, payment_request_id, customer_id, targeted_mitra_id, cause_tag, amount, operator_action, actioned_by, actioned_at, created_at ` if (!updated) { diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index 0f5d853..1816ca1 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -13,7 +13,7 @@ import { TransactionType, WsMessage, TopicSensitivity, - PaymentSessionStatus, + PaymentRequestStatus, PairingFailureCause, PairingRequestType, } from '../constants.js' @@ -68,7 +68,7 @@ const notifyCustomer = async (customerId, data) => { body: 'Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera.', data: { type: WsMessage.PAIRING_FAILED, - payment_session_id: data.payment_session_id || '', + payment_request_id: data.payment_request_id || '', cause_tag: data.cause_tag || '', }, }) @@ -100,41 +100,41 @@ export const findAvailableMitras = async () => { * Validate that a payment session is owned by the customer, confirmed, and not yet consumed. * Throws on mismatch. Returns the loaded payment session row. */ -const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { allowExtension = false } = {}) => { - if (!paymentSessionId) { - throw Object.assign(new Error('payment_session_id is required'), { +const requireConfirmedPaymentRequest = async (paymentRequestId, customerId, { allowExtension = false } = {}) => { + if (!paymentRequestId) { + throw Object.assign(new Error('payment_request_id is required'), { code: 'VALIDATION_ERROR', statusCode: 422, }) } - const paySession = await getPaymentSession(paymentSessionId) - if (!paySession) { + const payRequest = await getPaymentSession(paymentRequestId) + if (!payRequest) { throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) } - if (paySession.customer_id !== customerId) { + if (payRequest.customer_id !== customerId) { throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403, }) } - if (paySession.status !== PaymentSessionStatus.CONFIRMED) { - throw Object.assign(new Error(`Payment session is ${paySession.status}, must be confirmed`), { + if (payRequest.status !== PaymentRequestStatus.CONFIRMED) { + throw Object.assign(new Error(`Payment session is ${payRequest.status}, must be confirmed`), { code: 'INVALID_STATE', statusCode: 409, }) } - if (paySession.is_extension && !allowExtension) { + if (payRequest.is_extension && !allowExtension) { throw Object.assign(new Error('Extension payment session cannot be used to start a new chat'), { code: 'INVALID_STATE', statusCode: 409, }) } - if (new Date(paySession.expires_at) <= new Date()) { + if (new Date(payRequest.expires_at) <= new Date()) { // Check expiry inline at every state transition (defense in depth vs. the background sweeper). - await failPaymentSession(paymentSessionId, PairingFailureCause.PAYMENT_SESSION_EXPIRED) + await failPaymentSession(paymentRequestId, PairingFailureCause.PAYMENT_REQUEST_EXPIRED) throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 }) } - return paySession + return payRequest } /** - * General-blast pairing request. Requires a confirmed payment_session_id. + * General-blast pairing request. Requires a confirmed payment_request_id. * * The duration_minutes / price / is_first_session_discount values for the chat_session row are * sourced from the payment session — the client does not dictate pricing here. @@ -144,12 +144,12 @@ const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { al * fall back to general blast on the same payment. The flag bypasses the * "use returning-chat endpoint" guard in that exact case. */ -export const createPairingRequest = async (customerId, { paymentSessionId, topic_sensitivity, allowTargetedPayment = false } = {}) => { - const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId) +export const createPairingRequest = async (customerId, { paymentRequestId, topic_sensitivity, allowTargetedPayment = false } = {}) => { + const payRequest = await requireConfirmedPaymentRequest(paymentRequestId, customerId) // Targeted payment session must use createTargetedPairingRequest unless we're // explicitly invoked by the fallback-to-blast path. - if (paySession.targeted_mitra_id && !allowTargetedPayment) { + if (payRequest.targeted_mitra_id && !allowTargetedPayment) { throw Object.assign(new Error('Payment session is targeted to a specific mitra; use returning-chat endpoint'), { code: 'INVALID_STATE', statusCode: 409, }) @@ -170,7 +170,7 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic const availableMitras = await findAvailableMitras() if (availableMitras.length === 0) { // No mitras to blast to — fail the payment immediately. - await failPaymentSession(paymentSessionId, PairingFailureCause.NO_MITRA_AVAILABLE) + await failPaymentSession(paymentRequestId, PairingFailureCause.NO_MITRA_AVAILABLE) throw Object.assign(new Error('No bestie available'), { code: 'NO_MITRA_AVAILABLE', statusCode: 404, }) @@ -183,14 +183,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic // Create session sourced from the payment session. const [session] = await sql` INSERT INTO chat_sessions ( - customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id + customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id ) VALUES ( ${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, - ${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount}, - ${resolvedTopic}, ${paymentSessionId} + ${payRequest.duration_minutes}, ${payRequest.amount}, ${payRequest.is_first_session_discount}, + ${resolvedTopic}, ${paymentRequestId} ) - RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at + RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id, created_at ` // Fan out to all available mitras in parallel — DB inserts and notifications are @@ -225,6 +225,56 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic return session } +/** + * Phase 5: server-driven pairing entry point — called by the payment service's + * `payment_request.confirmed` event subscriber. Replaces the client-driven + * POST /chat/request path for new payments. + * + * **Idempotent.** If a chat_session already exists for this payment_request_id, + * returns it without doing anything. Safe to call multiple times — the + * reconciliation sweeper relies on this property to retry lost events. + * + * Routes general-blast vs targeted based on productMetadata.targeted_mitra_id. + * Errors are caught and logged (don't bubble — the event subscriber wrapper would + * also catch but logging here gives better context). + */ +export const startPairingFromPaymentRequest = async ({ paymentRequestId, productMetadata, customerId }) => { + // Idempotency check — covers webhook retries, reconciliation sweeper re-emit, + // and the case where the legacy client still POSTs to /chat/request after our + // subscriber already started pairing. + const [existing] = await sql` + SELECT id FROM chat_sessions WHERE payment_request_id = ${paymentRequestId} + ` + if (existing) return existing + + const targetedMitraId = productMetadata?.targeted_mitra_id ?? null + const topicSensitivity = productMetadata?.topic_sensitivity ?? TopicSensitivity.REGULAR + + try { + if (targetedMitraId) { + return await createTargetedPairingRequest(customerId, { + paymentRequestId, + targetedMitraId, + topic_sensitivity: topicSensitivity, + }) + } + return await createPairingRequest(customerId, { + paymentRequestId, + topic_sensitivity: topicSensitivity, + }) + } catch (err) { + // Already-active is benign — covers the race where the legacy /chat/request + // beat the subscriber to it. NO_MITRA_AVAILABLE has already failed the payment + // (createPairingRequest does that internally) — we don't need to act further. + if (err.code === 'ALREADY_ACTIVE' || err.code === 'NO_MITRA_AVAILABLE') { + console.log(`[pairing subscriber] ${err.code} for payment ${paymentRequestId} — already handled`) + return null + } + console.error('[pairing subscriber] startPairing failed', { paymentRequestId, err }) + throw err + } +} + /** * Targeted pairing request for "Curhat lagi" (returning chat). * @@ -236,14 +286,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic * - On explicit decline by mitra: fail payment with `targeted_mitra_rejected`, push WS event. * - On accept: existing accept path runs (consumes payment session as for general blast). */ -export const createTargetedPairingRequest = async (customerId, { paymentSessionId, targetedMitraId, topic_sensitivity } = {}) => { - const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId) +export const createTargetedPairingRequest = async (customerId, { paymentRequestId, targetedMitraId, topic_sensitivity } = {}) => { + const payRequest = await requireConfirmedPaymentRequest(paymentRequestId, customerId) if (!targetedMitraId) { throw Object.assign(new Error('targetedMitraId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 }) } - // Cross-check: payment_session.targeted_mitra_id should match (if set). - if (paySession.targeted_mitra_id && paySession.targeted_mitra_id !== targetedMitraId) { + // Cross-check: payment_request.targeted_mitra_id should match (if set). + if (payRequest.targeted_mitra_id && payRequest.targeted_mitra_id !== targetedMitraId) { throw Object.assign(new Error('targetedMitraId does not match payment session'), { code: 'INVALID_STATE', statusCode: 409, }) @@ -267,11 +317,11 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI // Intermediate failure: audit row written, payment stays `confirmed` so the customer // can choose to fall back to general blast (or cancel, which terminates). await recordIntermediateFailure({ - paymentSessionId, + paymentRequestId, customerId, targetedMitraId, causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE, - amount: paySession.amount, + amount: payRequest.amount, }) throw Object.assign(new Error('Targeted mitra is offline'), { code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline', @@ -285,11 +335,11 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI const midSessionWithCustomer = await isMitraInActiveSessionWithCustomer(targetedMitraId, customerId) if (!midSessionWithCustomer) { await recordIntermediateFailure({ - paymentSessionId, + paymentRequestId, customerId, targetedMitraId, causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE, - amount: paySession.amount, + amount: payRequest.amount, }) throw Object.assign(new Error('Targeted mitra is at capacity'), { code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline', @@ -305,14 +355,14 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI // Create session sourced from the payment session, status = pending_acceptance. const [session] = await sql` INSERT INTO chat_sessions ( - customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id + customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id ) VALUES ( ${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, - ${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount}, - ${resolvedTopic}, ${paymentSessionId} + ${payRequest.duration_minutes}, ${payRequest.amount}, ${payRequest.is_first_session_discount}, + ${resolvedTopic}, ${paymentRequestId} ) - RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at + RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id, created_at ` // Single notification to the targeted mitra @@ -355,7 +405,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { UPDATE chat_sessions SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW() WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} AND mitra_id IS NULL - RETURNING id, customer_id, mitra_id, status, paired_at, payment_session_id + RETURNING id, customer_id, mitra_id, status, paired_at, payment_request_id ` if (!session) { @@ -386,8 +436,8 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { } // Consume the payment session at the moment of acceptance. - if (session.payment_session_id) { - await consumePaymentSession(session.payment_session_id) + if (session.payment_request_id) { + await consumePaymentSession(session.payment_request_id) } // Activate the session and set expires_at. @@ -399,7 +449,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => { ELSE NULL END WHERE id = ${sessionId} - RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_session_id + RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_request_id ` // Record transaction @@ -453,25 +503,25 @@ export const declinePairingRequest = async (sessionId, mitraId) => { WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL ` - // Targeted-vs-general is determined by the payment_session.targeted_mitra_id, not by + // Targeted-vs-general is determined by the payment_request.targeted_mitra_id, not by // notification count — a general blast with only one online mitra also has length=1. const [targetCheck] = 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 = ${sessionId} ` const isTargeted = !!targetCheck?.targeted_mitra_id if (isTargeted) { // Mark the chat_session as expired (the targeted attempt is over) — but keep the - // payment_session in `confirmed` so the customer can fall back to general blast on + // payment_request in `confirmed` so the customer can fall back to general blast on // the same payment, or cancel (which then terminates). const [session] = await sql` UPDATE chat_sessions SET status = ${SessionStatus.EXPIRED} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} - RETURNING id, customer_id, payment_session_id + RETURNING id, customer_id, payment_request_id ` if (session) { // Clear the 20s timer if still pending. @@ -482,15 +532,15 @@ export const declinePairingRequest = async (sessionId, mitraId) => { } // Audit row only; payment session stays `confirmed`. - if (session.payment_session_id) { - const paySession = await getPaymentSession(session.payment_session_id) - if (paySession) { + if (session.payment_request_id) { + const payRequest = await getPaymentSession(session.payment_request_id) + if (payRequest) { await recordIntermediateFailure({ - paymentSessionId: session.payment_session_id, + paymentRequestId: session.payment_request_id, customerId: session.customer_id, targetedMitraId: mitraId, causeTag: PairingFailureCause.TARGETED_MITRA_REJECTED, - amount: paySession.amount, + amount: payRequest.amount, }) } } @@ -499,7 +549,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => { await notifyCustomer(session.customer_id, { type: WsMessage.RETURNING_CHAT_REJECTED, session_id: sessionId, - payment_session_id: session.payment_session_id, + payment_request_id: session.payment_request_id, }) } return @@ -518,7 +568,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => { UPDATE chat_sessions SET status = ${SessionStatus.EXPIRED} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} - RETURNING id, customer_id, payment_session_id + RETURNING id, customer_id, payment_request_id ` if (session) { const timeoutId = pairingTimeouts.get(sessionId) @@ -529,14 +579,14 @@ export const declinePairingRequest = async (sessionId, mitraId) => { // Intermediate failure: payment stays confirmed so the customer can re-blast // from the S7 timeout CTA. Audit row is still written. - if (session.payment_session_id) { - const paySession = await getPaymentSession(session.payment_session_id) - if (paySession) { + if (session.payment_request_id) { + const payRequest = await getPaymentSession(session.payment_request_id) + if (payRequest) { await recordIntermediateFailure({ - paymentSessionId: session.payment_session_id, + paymentRequestId: session.payment_request_id, customerId: session.customer_id, causeTag: PairingFailureCause.ALL_MITRAS_REJECTED, - amount: paySession.amount, + amount: payRequest.amount, }) } } @@ -544,7 +594,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => { await notifyCustomer(session.customer_id, { type: WsMessage.PAIRING_FAILED, session_id: sessionId, - payment_session_id: session.payment_session_id, + payment_request_id: session.payment_request_id, cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED, is_terminal: false, }) @@ -558,7 +608,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => { SET status = ${SessionStatus.CANCELLED} WHERE id = ${sessionId} AND customer_id = ${customerId} AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}) - RETURNING id, customer_id, status, payment_session_id + RETURNING id, customer_id, status, payment_request_id ` if (!session) { @@ -594,8 +644,8 @@ export const cancelPairingRequest = async (sessionId, customerId) => { // Customer initiated this cancel; the calling client already navigates home. Do not // push PAIRING_FAILED for customer-initiated cancels — surfacing it as a "failure" // event (especially via FCM if backgrounded) misframes the user's own action. - if (session.payment_session_id) { - await failPaymentSession(session.payment_session_id, PairingFailureCause.CUSTOMER_CANCELLED) + if (session.payment_request_id) { + await failPaymentSession(session.payment_request_id, PairingFailureCause.CUSTOMER_CANCELLED) } return session @@ -609,12 +659,12 @@ export const cancelPairingRequest = async (sessionId, customerId) => { * If a chat_session was already created (general blast in flight, or targeted request out), * we cancel that too. */ -export const cancelPaymentSearch = async (paymentSessionId, customerId) => { - const paySession = await getPaymentSession(paymentSessionId) - if (!paySession) { +export const cancelPaymentSearch = async (paymentRequestId, customerId) => { + const payRequest = await getPaymentSession(paymentRequestId) + if (!payRequest) { throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) } - if (paySession.customer_id !== customerId) { + if (payRequest.customer_id !== customerId) { throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403, }) @@ -623,7 +673,7 @@ export const cancelPaymentSearch = async (paymentSessionId, customerId) => { // If a chat_session exists for this payment in pending_acceptance/searching, cancel it. const [linkedSession] = await sql` SELECT id FROM chat_sessions - WHERE payment_session_id = ${paymentSessionId} + WHERE payment_request_id = ${paymentRequestId} AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}) ` if (linkedSession) { @@ -634,37 +684,37 @@ export const cancelPaymentSearch = async (paymentSessionId, customerId) => { // Otherwise fail the payment directly. Covers the case where the customer cancels after // the targeted attempt already expired/rejected (chat_session no longer pending_acceptance) // but the payment is still `confirmed`. No customer-side WS push — see cancelPairingRequest. - if (paySession.status === PaymentSessionStatus.CONFIRMED) { - await failPaymentSession(paymentSessionId, PairingFailureCause.CUSTOMER_CANCELLED) + if (payRequest.status === PaymentRequestStatus.CONFIRMED) { + await failPaymentSession(paymentRequestId, PairingFailureCause.CUSTOMER_CANCELLED) } - return { id: paymentSessionId, payment_session_id: paymentSessionId } + return { id: paymentRequestId, payment_request_id: paymentRequestId } } /** * After a returning-chat fail, customer taps "Chat dengan bestie lain". * - * The original payment_session stays in `confirmed` for the entire returning-chat flow — + * The original payment_request stays in `confirmed` for the entire returning-chat flow — * targeted reject/timeout writes an audit-only `pairing_failures` row but does NOT terminate. - * So when the customer falls back to general blast, we reuse the same `payment_session_id` - * directly. Multiple `pairing_failures` rows may FK from one payment_session — that's the + * So when the customer falls back to general blast, we reuse the same `payment_request_id` + * directly. Multiple `pairing_failures` rows may FK from one payment_request — that's the * desired CC UX (one row per failed attempt). Termination happens only at the actual end - * of the flow (chat starts → consumed; cancel/blast-exhaust → failed_pairing). + * of the flow (chat starts → consumed; cancel/blast-exhaust → failed_delivery). * * The targeted_mitra_id flag on the original row is left as-is (it records the customer's * original intent); the general blast happens regardless. */ -export const fallbackToGeneralBlast = async (paymentSessionId, customerId, { topic_sensitivity } = {}) => { - const paySession = await getPaymentSession(paymentSessionId) - if (!paySession) { +export const fallbackToGeneralBlast = async (paymentRequestId, customerId, { topic_sensitivity } = {}) => { + const payRequest = await getPaymentSession(paymentRequestId) + if (!payRequest) { throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) } - if (paySession.customer_id !== customerId) { + if (payRequest.customer_id !== customerId) { throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403, }) } - if (paySession.status !== PaymentSessionStatus.CONFIRMED) { - throw Object.assign(new Error(`Cannot fallback from payment in status ${paySession.status}`), { + if (payRequest.status !== PaymentRequestStatus.CONFIRMED) { + throw Object.assign(new Error(`Cannot fallback from payment in status ${payRequest.status}`), { code: 'INVALID_STATE', statusCode: 409, }) } @@ -672,7 +722,7 @@ export const fallbackToGeneralBlast = async (paymentSessionId, customerId, { top // Run the general blast against the SAME payment session. Pass `allowTargetedPayment` // so the targeted_mitra_id on the payment session doesn't trip the general-blast guard. return createPairingRequest(customerId, { - paymentSessionId, + paymentRequestId, topic_sensitivity, allowTargetedPayment: true, }) @@ -683,7 +733,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC UPDATE chat_sessions SET status = ${SessionStatus.EXPIRED} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} - RETURNING id, customer_id, status, payment_session_id + RETURNING id, customer_id, status, payment_request_id ` if (!session) return null @@ -699,14 +749,14 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC // Intermediate failure: payment session stays `confirmed` so the customer can // re-blast on the same payment from the S7 timeout CTA. Audit row is still // written so the failed-pairing CC view captures every attempt. - if (session.payment_session_id) { - const paySession = await getPaymentSession(session.payment_session_id) - if (paySession) { + if (session.payment_request_id) { + const payRequest = await getPaymentSession(session.payment_request_id) + if (payRequest) { await recordIntermediateFailure({ - paymentSessionId: session.payment_session_id, + paymentRequestId: session.payment_request_id, customerId: session.customer_id, causeTag, - amount: paySession.amount, + amount: payRequest.amount, }) } } @@ -714,7 +764,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC await notifyCustomer(session.customer_id, { type: WsMessage.PAIRING_FAILED, session_id: sessionId, - payment_session_id: session.payment_session_id, + payment_request_id: session.payment_request_id, cause_tag: causeTag, is_terminal: false, }) @@ -736,7 +786,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC * Targeted-request timer fired with no mitra response. * * INTERMEDIATE failure: the chat_session is marked expired (the targeted attempt is over) - * but the payment_session stays `confirmed` so the customer can fall back to general blast + * but the payment_request stays `confirmed` so the customer can fall back to general blast * on the same payment, or cancel (which then terminates). * * - cause_tag is targeted_mitra_timeout (audit row only) @@ -747,7 +797,7 @@ export const expireTargetedPairingRequest = async (sessionId) => { UPDATE chat_sessions SET status = ${SessionStatus.EXPIRED} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} - RETURNING id, customer_id, status, payment_session_id + RETURNING id, customer_id, status, payment_request_id ` if (!session) return null @@ -764,15 +814,15 @@ export const expireTargetedPairingRequest = async (sessionId) => { WHERE session_id = ${sessionId} AND response IS NULL ` - if (session.payment_session_id) { - const paySession = await getPaymentSession(session.payment_session_id) - if (paySession) { + if (session.payment_request_id) { + const payRequest = await getPaymentSession(session.payment_request_id) + if (payRequest) { await recordIntermediateFailure({ - paymentSessionId: session.payment_session_id, + paymentRequestId: session.payment_request_id, customerId: session.customer_id, targetedMitraId: notif?.mitra_id ?? null, causeTag: PairingFailureCause.TARGETED_MITRA_TIMEOUT, - amount: paySession.amount, + amount: payRequest.amount, }) } } @@ -780,7 +830,7 @@ export const expireTargetedPairingRequest = async (sessionId) => { await notifyCustomer(session.customer_id, { type: WsMessage.RETURNING_CHAT_TIMEOUT, session_id: sessionId, - payment_session_id: session.payment_session_id, + payment_request_id: session.payment_request_id, }) // Notify the targeted mitra that the card is no longer actionable — fan-out in parallel @@ -798,7 +848,7 @@ export const expireTargetedPairingRequest = async (sessionId) => { } export const getPendingRequestsForMitra = async (mitraId) => { - // Distinguish general blast from "Curhat lagi" returning requests via payment_session.targeted_mitra_id. + // Distinguish general blast from "Curhat lagi" returning requests via payment_request.targeted_mitra_id. // For returning requests, surface the configured timeout so the cold-start (FCM-tap) path can render // the countdown overlay — same field the WS payload provides for the live path. const rows = await sql` @@ -814,7 +864,7 @@ export const getPendingRequestsForMitra = async (mitraId) => { END AS request_type FROM chat_request_notifications crn JOIN chat_sessions cs ON cs.id = crn.session_id - 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 crn.mitra_id = ${mitraId} AND crn.response IS NULL AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE} diff --git a/backend/src/services/payment.service.js b/backend/src/services/payment.service.js index 4ba1f74..1287402 100644 --- a/backend/src/services/payment.service.js +++ b/backend/src/services/payment.service.js @@ -1,278 +1,518 @@ +// Phase 5: Payment service — single owner of the payment_requests table + Xendit integration. +// +// Public API surface (the future microservice contract): +// +// requestPayment({ productType, productMetadata, customerId, amount, ttlMinutes, ... }) +// → inserts row, optionally creates Xendit invoice, returns row (with invoice_url if Xendit on) +// +// confirmPayment(paymentRequestId, xenditMeta = {}) +// → pending → confirmed; emits 'payment_request.confirmed' +// +// expirePayment(paymentRequestId) +// → pending → expired (webhook-callable, no customer check); emits 'payment_request.expired' +// +// cancelPayment(paymentRequestId, customerId) +// → customer-initiated pending → abandoned; emits 'payment_request.cancelled' +// +// markDeliveryFailed(paymentRequestId, causeTag) +// → confirmed → failed_delivery; writes pairing_failures row; emits 'payment_request.delivery_failed' +// +// consumePayment(paymentRequestId) +// → confirmed → consumed; no event (terminal success) +// +// getPayment(id) → row or null +// getCustomerPendingPayments(customerId) → { items, total } +// expireStalePaymentRequests() → background sweeper + reconciliation +// +// on(eventName, handler) → subscribe to lifecycle events +// verifyWebhookToken(headerToken) → constant-time compare for webhook auth (used by route) +// +// registerPairingSubscriber() → wires pairing.service as a subscriber to payment_request.confirmed +// recordIntermediateFailure(...) → audit-only failure for flows with a fallback path +// +// Internals (NOT exported): +// createXenditInvoice() — wraps xendit-node SDK +// emit() / emitter — EventEmitter setup +// +// Events emit AFTER the DB transition commits. Subscribers run on the next tick +// (handlers wrapped fire-and-forget) so the publisher is never blocked. +// +// Durability story: events are in-process EventEmitter; lost on process death. +// The reconciliation sweeper (expireStalePaymentRequests) re-derives missed work +// from DB state every minute + on startup. Subscribers MUST be idempotent. +// See requirement/phase5-xendit-plan.md "Event durability" section. + +import { EventEmitter } from 'node:events' +import { Xendit } from 'xendit-node' import { getDb } from '../db/client.js' -import { PaymentSessionStatus, PairingFailureCause, UserType, WsMessage, SessionMode } from '../constants.js' +import { PaymentRequestStatus, PairingFailureCause, UserType, WsMessage, SessionMode } from '../constants.js' import { recordFailure } from './pairing-failure.service.js' import { sendToUser } from '../plugins/websocket.js' import { sendPushNotification } from './notification.service.js' -import { getPaymentSessionTimeoutMinutes as readPaymentSessionTimeoutMinutes } from './config.service.js' +import { + getPaymentRequestTimeoutMinutes as readPaymentRequestTimeoutMinutes, + getXenditConfig, +} from './config.service.js' const sql = getDb() -const getPaymentSessionTimeoutMinutes = async () => { - const { payment_session_timeout_minutes } = await readPaymentSessionTimeoutMinutes() - return payment_session_timeout_minutes +// --- EventEmitter setup --- + +const emitter = new EventEmitter() +// Bump default 10-listener cap so future product subscribers don't trigger the leak warning +emitter.setMaxListeners(50) + +export const on = (eventName, handler) => { + emitter.on(eventName, (payload) => { + // Wrap every handler so an unhandled throw doesn't kill the process AND so handlers + // run async-fire-and-forget (don't block the publisher's emit() return). Errors are + // logged; recovery is the sweeper's job. + Promise.resolve() + .then(() => handler(payload)) + .catch((err) => console.error(`[payment event ${eventName}] handler failed`, err)) + }) } +const emit = (eventName, payload) => emitter.emit(eventName, payload) + +// --- Internal Xendit client (lazy + re-creatable for test env stubbing) --- + +let _xenditClient = null +let _xenditKey = null +const xenditClient = () => { + const { secretKey } = getXenditConfig() + if (_xenditClient && _xenditKey === secretKey) return _xenditClient + _xenditClient = new Xendit({ secretKey }) + _xenditKey = secretKey + return _xenditClient +} + +const createXenditInvoice = async ({ paymentRequestId, amount, ttlMinutes, description }) => { + const { successRedirectUrl, failureRedirectUrl } = getXenditConfig() + const inv = await xenditClient().Invoice.createInvoice({ + data: { + externalId: paymentRequestId, // D4 — our UUID is the Xendit external_id + amount, + description, + invoiceDuration: Math.floor(ttlMinutes * 60), // D5 — TTL mirrors session timeout + currency: 'IDR', + successRedirectUrl: successRedirectUrl || undefined, + failureRedirectUrl: failureRedirectUrl || undefined, + // paymentMethods omitted → honor dashboard config (operator picks methods without a deploy) + }, + }) + return { invoiceId: inv.id, invoiceUrl: inv.invoiceUrl } +} + +// Used by the webhook route to authenticate Xendit's x-callback-token header. +export const verifyWebhookToken = (headerToken) => { + const { webhookToken } = getXenditConfig() + if (!headerToken || !webhookToken) return false + if (typeof headerToken !== 'string') return false + if (headerToken.length !== webhookToken.length) return false + let mismatch = 0 + for (let i = 0; i < headerToken.length; i++) { + mismatch |= headerToken.charCodeAt(i) ^ webhookToken.charCodeAt(i) + } + return mismatch === 0 +} + +// Test-only — drop cached client so vi.stubEnv changes take effect. +export const _resetXenditClientForTest = () => { + _xenditClient = null + _xenditKey = null +} + +// --- Helpers --- + +const getPaymentRequestTimeoutMinutes = async () => { + const { payment_request_timeout_minutes } = await readPaymentRequestTimeoutMinutes() + return payment_request_timeout_minutes +} + +const buildEventPayload = (row) => ({ + paymentRequestId: row.id, + productType: row.product_type ?? 'chat_session', + productMetadata: row.product_metadata ?? {}, + customerId: row.customer_id, + amount: row.amount, + xenditInvoiceId: row.xendit_invoice_id ?? null, + xenditPaymentMethod: row.xendit_payment_method ?? null, +}) + +const buildInvoiceDescription = (row) => { + if (row.product_type === 'chat_session') { + return row.is_extension + ? `Perpanjangan sesi ${row.duration_minutes} menit` + : `Sesi ${row.duration_minutes} menit` + } + // Generic fallback — future products can build their own descriptions and pass via productMetadata + return row.product_metadata?.description ?? `Pembayaran ${row.product_type}` +} + +// --- requestPayment: insert pending row + (if Xendit on) mint invoice --- + /** - * Create a new payment session in `pending` status. - * Reads `payment_session_timeout_minutes` from config to compute expires_at. + * Create a new payment request in `pending` status. When XENDIT_ENABLED=true, also + * creates a Xendit Invoice and stamps invoice_id + invoice_url on the row. * - * Phase 4: `isFirstSessionDiscount` replaces the old `isFreeTrial` flag. Voice-call - * mode is a routing/badge thing — the price comes from the call tier group, not from - * the mode itself. + * Product-agnostic: callers stamp `productType` + `productMetadata`. The legacy + * top-level chat-specific args (durationMinutes, mode, isExtension, targetedMitraId, + * isFirstSessionDiscount) are accepted for backward compat with existing chat code, + * and also written into product_metadata when productType === 'chat_session'. */ -export const createPaymentSession = async ({ +export const requestPayment = async ({ + productType = 'chat_session', + productMetadata = {}, customerId, - durationMinutes, amount, + ttlMinutes, + // Chat-specific legacy fields (still written to top-level columns for now) + durationMinutes, + mode = SessionMode.CHAT, isFirstSessionDiscount = false, isExtension = false, targetedMitraId = null, - mode = SessionMode.CHAT, }) => { if (!customerId) { throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 }) } - if (typeof durationMinutes !== 'number' || durationMinutes <= 0) { - throw Object.assign(new Error('durationMinutes must be a positive number'), { code: 'VALIDATION_ERROR', statusCode: 422 }) - } if (typeof amount !== 'number' || amount < 0) { throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 }) } - if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) { - throw Object.assign(new Error('mode must be chat or call'), { code: 'VALIDATION_ERROR', statusCode: 422 }) + if (productType === 'chat_session') { + if (typeof durationMinutes !== 'number' || durationMinutes <= 0) { + throw Object.assign(new Error('durationMinutes must be a positive number for chat_session'), { code: 'VALIDATION_ERROR', statusCode: 422 }) + } + if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) { + throw Object.assign(new Error('mode must be chat or call'), { code: 'VALIDATION_ERROR', statusCode: 422 }) + } } - const ttlMinutes = await getPaymentSessionTimeoutMinutes() + const ttl = ttlMinutes ?? await getPaymentRequestTimeoutMinutes() + + // For chat_session, fold legacy args into product_metadata so the canonical + // location is the JSONB blob. Subscribers read from product_metadata. + const meta = productType === 'chat_session' + ? { + duration_minutes: durationMinutes, + mode, + is_extension: isExtension, + targeted_mitra_id: targetedMitraId, + is_first_session_discount: isFirstSessionDiscount, + ...productMetadata, + } + : productMetadata const [row] = await sql` - INSERT INTO payment_sessions ( + INSERT INTO payment_requests ( customer_id, amount, duration_minutes, is_first_session_discount, is_extension, - status, targeted_mitra_id, mode, expires_at + status, targeted_mitra_id, mode, expires_at, product_type, product_metadata ) VALUES ( - ${customerId}, ${amount}, ${durationMinutes}, ${isFirstSessionDiscount}, ${isExtension}, - ${PaymentSessionStatus.PENDING}, ${targetedMitraId}, ${mode}, - NOW() + (${ttlMinutes} || ' minutes')::interval + ${customerId}, ${amount}, ${durationMinutes ?? 0}, ${isFirstSessionDiscount}, ${isExtension}, + ${PaymentRequestStatus.PENDING}, ${targetedMitraId}, ${mode}, + NOW() + (${ttl} || ' minutes')::interval, + ${productType}, ${sql.json(meta)} ) - RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension, - mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at + RETURNING * ` + // If Xendit is on, create the invoice + stamp the row. If creation fails, mark + // the row as `failed` (NOT `expired` — distinct: "we never got an invoice at all" + // vs. "TTL elapsed unpaid") and surface a 502 to the caller. + const xc = getXenditConfig() + if (xc.enabled) { + try { + const { invoiceId, invoiceUrl } = await createXenditInvoice({ + paymentRequestId: row.id, + amount: row.amount, + ttlMinutes: ttl, + description: buildInvoiceDescription(row), + }) + await sql` + UPDATE payment_requests + SET xendit_invoice_id = ${invoiceId}, xendit_invoice_url = ${invoiceUrl} + WHERE id = ${row.id} + ` + row.xendit_invoice_id = invoiceId + row.xendit_invoice_url = invoiceUrl + } catch (err) { + console.error('[xendit] createInvoice failed; marking payment_request failed', { paymentRequestId: row.id, err }) + const [failed] = await sql` + UPDATE payment_requests + SET status = ${PaymentRequestStatus.FAILED} + WHERE id = ${row.id} AND status = ${PaymentRequestStatus.PENDING} + RETURNING * + ` + if (failed) emit('payment_request.failed', buildEventPayload(failed)) + throw Object.assign(new Error('Payment provider error'), { + code: 'PAYMENT_PROVIDER_ERROR', + statusCode: 502, + cause: err, + }) + } + } + return row } +// --- confirmPayment --- + /** - * Transition pending → confirmed. Throws on ownership/status/expiry mismatch. + * Transition pending → confirmed. Customer-facing callers (the legacy + * /payment-requests/:id/confirm route) verify customer ownership themselves before + * calling. Webhook caller does not check ownership (Xendit's authority). + * + * Optional xenditMeta stamps payment-time data from the webhook body: + * { invoiceId, paymentMethod, amount } + * + * Throws on missing row / wrong status / expiry. Webhook handler swallows + * INVALID_STATE (already confirmed) and EXPIRED (raced sweeper) and ACKs. + * + * Emits 'payment_request.confirmed' after commit. */ -export const confirmPaymentSession = async (paymentSessionId, customerId) => { +export const confirmPayment = async (paymentRequestId, xenditMeta = {}) => { const [existing] = await sql` SELECT id, customer_id, status, expires_at - FROM payment_sessions - WHERE id = ${paymentSessionId} + FROM payment_requests + WHERE id = ${paymentRequestId} ` if (!existing) { - throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) + throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 }) } - if (existing.customer_id !== customerId) { - throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 }) - } - if (existing.status !== PaymentSessionStatus.PENDING) { - throw Object.assign(new Error(`Payment session is ${existing.status}, cannot confirm`), { + if (existing.status !== PaymentRequestStatus.PENDING) { + throw Object.assign(new Error(`Payment request is ${existing.status}, cannot confirm`), { code: 'INVALID_STATE', statusCode: 409, }) } if (new Date(existing.expires_at) <= new Date()) { - // Inline expiry check in addition to the background sweeper, since the customer can - // attempt to confirm a row that's already past expires_at before the sweep runs. + // Inline expiry check (sweeper hasn't run yet) await sql` - UPDATE payment_sessions SET status = ${PaymentSessionStatus.EXPIRED} - WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING} + UPDATE payment_requests SET status = ${PaymentRequestStatus.EXPIRED} + WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING} ` - throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 }) + throw Object.assign(new Error('Payment request has expired'), { code: 'EXPIRED', statusCode: 409 }) } const [updated] = await sql` - UPDATE payment_sessions - SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW() - WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING} - RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension, - mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at + UPDATE payment_requests + SET status = ${PaymentRequestStatus.CONFIRMED}, + confirmed_at = NOW(), + xendit_invoice_id = COALESCE(${xenditMeta.invoiceId ?? null}, xendit_invoice_id), + xendit_payment_method = COALESCE(${xenditMeta.paymentMethod ?? null}, xendit_payment_method), + xendit_paid_amount = COALESCE(${xenditMeta.amount ?? null}, xendit_paid_amount) + WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING} + RETURNING * ` if (!updated) { - throw Object.assign(new Error('Payment session state changed during confirm'), { code: 'CONFLICT', statusCode: 409 }) + throw Object.assign(new Error('Payment request state changed during confirm'), { code: 'CONFLICT', statusCode: 409 }) } + + emit('payment_request.confirmed', buildEventPayload(updated)) return updated } -/** - * Transition confirmed → consumed. Called from pairing service when a chat starts. - * Idempotent at higher level (caller should check status first if it matters). - */ -export const consumePaymentSession = async (paymentSessionId) => { - const [updated] = await sql` - UPDATE payment_sessions - SET status = ${PaymentSessionStatus.CONSUMED}, consumed_at = NOW() - WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED} - RETURNING id, status, consumed_at - ` - return updated || null +// Customer-facing wrapper used by the legacy /payment-requests/:id/confirm route +// (kept only for dev/Maestro — production gates the route on XENDIT_ENABLED). +// Verifies customer ownership before delegating to the internal confirmPayment. +export const confirmPaymentForCustomer = async (paymentRequestId, customerId) => { + const [existing] = await sql`SELECT customer_id FROM payment_requests WHERE id = ${paymentRequestId}` + if (!existing) { + throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 }) + } + if (existing.customer_id !== customerId) { + throw Object.assign(new Error('Payment request does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 }) + } + return confirmPayment(paymentRequestId) } +// --- expirePayment (webhook-callable; no ownership check) --- + /** - * TERMINAL: mark a confirmed payment session as failed_pairing AND write a pairing_failures row. - * Idempotent: no-op if already terminal (consumed/failed_pairing/expired/abandoned). - * - * Use only for true terminal failures (no fallback path possible): - * - general blast exhausted, no acceptance - * - all blasted mitras explicitly rejected - * - customer cancels mid-search - * - payment session expires before consumption - * - * For intermediate failures that have a fallback CTA available (targeted-mitra reject/timeout - * during a returning-chat flow), use `recordIntermediateFailure` instead — that writes the - * audit row WITHOUT terminating the payment session. Termination is the caller's decision - * (cancel CTA = terminal, fallback-to-blast CTA = stays confirmed). + * Transition pending → expired. Called by the Xendit EXPIRED webhook handler + * and by the background sweeper. Idempotent — if already terminal, no-op. + * Emits 'payment_request.expired' if a transition occurred. */ -export const failPaymentSession = async (paymentSessionId, causeTag) => { +export const expirePayment = async (paymentRequestId) => { + const [updated] = await sql` + UPDATE payment_requests + SET status = ${PaymentRequestStatus.EXPIRED} + WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING} + RETURNING * + ` + if (updated) emit('payment_request.expired', buildEventPayload(updated)) + return updated ?? null +} + +// --- consumePayment --- + +/** + * Transition confirmed → consumed. Called by product code (pairing service for chat) + * after successful delivery. No event — terminal success. + */ +export const consumePayment = async (paymentRequestId) => { + const [updated] = await sql` + UPDATE payment_requests + SET status = ${PaymentRequestStatus.CONSUMED}, consumed_at = NOW() + WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.CONFIRMED} + RETURNING id, status, consumed_at + ` + return updated ?? null +} + +// --- markDeliveryFailed --- + +/** + * TERMINAL: mark a confirmed payment as failed_delivery + write a pairing_failures + * audit row. Idempotent: no-op if not currently confirmed. + * + * Use only when no fallback is possible (no mitra available, all rejected, etc.). + * For intermediate failures with a fallback (targeted reject during returning-chat), + * use recordIntermediateFailure() which keeps the payment confirmed. + * + * Emits 'payment_request.delivery_failed' on transition. + */ +export const markDeliveryFailed = async (paymentRequestId, causeTag) => { if (!Object.values(PairingFailureCause).includes(causeTag)) { throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 }) } const [existing] = await sql` - SELECT id, customer_id, targeted_mitra_id, amount, status - FROM payment_sessions - WHERE id = ${paymentSessionId} + SELECT id, customer_id, targeted_mitra_id, amount, status, product_type, product_metadata, xendit_invoice_id, xendit_payment_method + FROM payment_requests + WHERE id = ${paymentRequestId} ` - if (!existing) { - return null - } - // Idempotent: only confirmed sessions transition to failed_pairing here. - // Pending sessions become expired/abandoned via their own paths. - if (existing.status !== PaymentSessionStatus.CONFIRMED) { - return existing - } + if (!existing) return null + if (existing.status !== PaymentRequestStatus.CONFIRMED) return existing // idempotent const [updated] = await sql` - UPDATE payment_sessions - SET status = ${PaymentSessionStatus.FAILED_PAIRING} - WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED} - RETURNING id, customer_id, targeted_mitra_id, amount, status + UPDATE payment_requests + SET status = ${PaymentRequestStatus.FAILED_DELIVERY} + WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.CONFIRMED} + RETURNING * ` - if (!updated) { - return existing - } + if (!updated) return existing await recordFailure({ - paymentSessionId, + paymentRequestId, customerId: existing.customer_id, targetedMitraId: existing.targeted_mitra_id, causeTag, amount: existing.amount, }) + emit('payment_request.delivery_failed', { ...buildEventPayload(updated), causeTag }) return updated } +// Backward-compat alias — pairing.service still calls failPaymentSession by name. +// TODO follow-up phase: rename call sites and drop this alias. +export const failPaymentSession = markDeliveryFailed + +// --- recordIntermediateFailure (audit only; doesn't terminate) --- + /** - * INTERMEDIATE: write a pairing_failures audit row WITHOUT terminating the payment session. + * INTERMEDIATE: write a pairing_failures audit row WITHOUT terminating the payment. + * Used inside flows with a fallback path (targeted "Curhat lagi" reject can fall back + * to general blast on the same payment). One payment_request may have many audit rows. * - * Used for failures inside a flow that still has a fallback path: targeted "Curhat lagi" - * reject/timeout (customer can fall back to general blast on the same payment), or any - * other intermediate attempt where the payment_session must remain `confirmed` so it can be - * reused. - * - * One payment_session may FK from multiple pairing_failures rows — that's the desired CC - * UX (each attempt lists as its own row in the Failed Pairings table). - * - * Returns the inserted pairing_failures row, or null if the payment session was missing. + * Returns the inserted pairing_failures row, or null if the payment is missing. */ -export const recordIntermediateFailure = async ({ paymentSessionId, customerId, targetedMitraId = null, causeTag, amount }) => { +export const recordIntermediateFailure = async ({ + paymentRequestId, + customerId, + targetedMitraId = null, + causeTag, + amount, +}) => { if (!Object.values(PairingFailureCause).includes(causeTag)) { throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 }) } - - // Loose sanity: the row should exist. If not, fall through with null — caller likely - // already moved on; we'd rather skip the audit than throw mid-callback. - const [existing] = await sql`SELECT id FROM payment_sessions WHERE id = ${paymentSessionId}` + const [existing] = await sql`SELECT id FROM payment_requests WHERE id = ${paymentRequestId}` if (!existing) return null - - return recordFailure({ - paymentSessionId, - customerId, - targetedMitraId, - causeTag, - amount, - }) + return recordFailure({ paymentRequestId, customerId, targetedMitraId, causeTag, amount }) } +// --- cancelPayment (customer-initiated abandonment) --- + /** - * Customer-initiated abandonment of a `pending` payment session (e.g. closed payment screen). - * No pairing_failures row is written — only confirmed-but-no-chat counts as a pairing failure. + * Customer-initiated abandonment of a pending payment (closed payment screen). + * No pairing_failures row (no money moved). Idempotent. + * + * Emits 'payment_request.cancelled' on transition. */ -export const abandonPaymentSession = async (paymentSessionId, customerId) => { +export const cancelPayment = async (paymentRequestId, customerId) => { const [existing] = await sql` - SELECT id, customer_id, status FROM payment_sessions WHERE id = ${paymentSessionId} + SELECT id, customer_id, status FROM payment_requests WHERE id = ${paymentRequestId} ` if (!existing) { - throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) + throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 }) } if (existing.customer_id !== customerId) { - throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 }) + throw Object.assign(new Error('Payment request does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 }) } - if (existing.status !== PaymentSessionStatus.PENDING) { - // Idempotent — already terminal. - return existing + if (existing.status !== PaymentRequestStatus.PENDING) { + return existing // idempotent } + const [updated] = await sql` - UPDATE payment_sessions SET status = ${PaymentSessionStatus.ABANDONED} - WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING} - RETURNING id, customer_id, status + UPDATE payment_requests SET status = ${PaymentRequestStatus.ABANDONED} + WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING} + RETURNING * ` - return updated || existing + if (updated) emit('payment_request.cancelled', buildEventPayload(updated)) + return updated ?? existing } +// --- Background sweeper + reconciliation --- + /** - * Background sweeper: - * - pending rows past expires_at → expired (no failure row; never confirmed) - * - confirmed rows past expires_at AND not consumed → failed_pairing with cause = payment_session_expired + * Runs every 60s from server.js + once at startup. + * + * Three jobs: + * 1. Pending past expires_at → expired (no failure row; emits .expired) + * 2. Confirmed past expires_at AND not consumed → failed_delivery (writes pairing_failures, emits .delivery_failed) + * 3. Confirmed-with-no-chat-session-yet → re-invoke pairing subscriber (lost-event recovery) + * + * Job 3 is the durability backstop — if a process restart lost the in-process + * EventEmitter notification before pairing started, the sweeper re-triggers it + * on the next tick. Subscribers MUST be idempotent (the pairing subscriber checks + * "chat_sessions WHERE payment_request_id = ?" before doing work). */ -export const expireStalePaymentSessions = async () => { - // 1) pending → expired +export const expireStalePaymentRequests = async () => { + // 1) pending → expired (emit event for each) const expired = await sql` - UPDATE payment_sessions - SET status = ${PaymentSessionStatus.EXPIRED} - WHERE status = ${PaymentSessionStatus.PENDING} + UPDATE payment_requests + SET status = ${PaymentRequestStatus.EXPIRED} + WHERE status = ${PaymentRequestStatus.PENDING} AND expires_at <= NOW() - RETURNING id + RETURNING * ` + for (const row of expired) emit('payment_request.expired', buildEventPayload(row)) - // 2) confirmed-but-stale → failed_pairing. Single atomic UPDATE returns the rows we - // actually flipped (vs. the old SELECT + per-row UPDATE which leaked a TOCTOU window - // with concurrent confirmPaymentSession/consumePaymentSession). Audit-row writes and - // customer notifications then fan out in parallel. + // 2) confirmed-but-stale → failed_delivery const flipped = await sql` - UPDATE payment_sessions - SET status = ${PaymentSessionStatus.FAILED_PAIRING} - WHERE status = ${PaymentSessionStatus.CONFIRMED} + UPDATE payment_requests + SET status = ${PaymentRequestStatus.FAILED_DELIVERY} + WHERE status = ${PaymentRequestStatus.CONFIRMED} AND expires_at <= NOW() - RETURNING id, customer_id, targeted_mitra_id, amount + RETURNING * ` - await Promise.all(flipped.map(async (row) => { await recordFailure({ - paymentSessionId: row.id, + paymentRequestId: row.id, customerId: row.customer_id, targetedMitraId: row.targeted_mitra_id, - causeTag: PairingFailureCause.PAYMENT_SESSION_EXPIRED, + causeTag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED, amount: row.amount, }) - // Customer may be on searching/waiting; push terminal PAIRING_FAILED in real time. - // FCM fallback when not WS-connected so they're notified at the OS level. + // Customer-facing: push terminal PAIRING_FAILED via WS; FCM fallback if not connected. try { const wsSent = sendToUser(UserType.CUSTOMER, row.customer_id, { type: WsMessage.PAIRING_FAILED, - payment_session_id: row.id, - cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED, + payment_request_id: row.id, + cause_tag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED, is_terminal: true, }) if (!wsSent) { @@ -281,43 +521,73 @@ export const expireStalePaymentSessions = async () => { body: 'Sesi pembayaranmu telah berakhir. Silakan mulai ulang.', data: { type: WsMessage.PAIRING_FAILED, - payment_session_id: row.id, - cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED, + payment_request_id: row.id, + cause_tag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED, }, }) } } catch (err) { - console.error('expireStalePaymentSessions: failed to notify customer', { - paymentSessionId: row.id, customerId: row.customer_id, err, + console.error('expireStalePaymentRequests: failed to notify customer', { + paymentRequestId: row.id, customerId: row.customer_id, err, }) } + emit('payment_request.delivery_failed', { + ...buildEventPayload(row), + causeTag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED, + }) })) - return { expired: expired.length, failed: flipped.length } + // 3) Reconciliation — confirmed payments that should have started product work but didn't. + // For chat_session: no chat_sessions row exists AND no pairing_failures row exists. + // The 30-second buffer avoids racing with happy-path subscribers mid-flight. + const orphans = await sql` + SELECT * + FROM payment_requests pr + WHERE pr.status = ${PaymentRequestStatus.CONFIRMED} + AND pr.confirmed_at < NOW() - INTERVAL '30 seconds' + AND pr.product_type = 'chat_session' + AND NOT EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.payment_request_id = pr.id) + AND NOT EXISTS (SELECT 1 FROM pairing_failures pf WHERE pf.payment_request_id = pr.id) + LIMIT 100 + ` + let reconciled = 0 + for (const row of orphans) { + try { + // Re-emit the event the subscriber missed. Subscriber's idempotency check + // makes this safe even if the subscriber did actually run (just slow). + emit('payment_request.confirmed', buildEventPayload(row)) + reconciled++ + } catch (err) { + console.error('[reconciler] re-emit failed', { paymentRequestId: row.id, err }) + } + } + + return { expired: expired.length, failed: flipped.length, reconciled } } -export const getPaymentSession = async (id) => { - const [row] = await sql` - SELECT id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension, - mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at - FROM payment_sessions - WHERE id = ${id} - ` - return row || null +// Backward-compat alias — server.js previously called expireStalePaymentSessions +export const expireStalePaymentSessions = expireStalePaymentRequests + +// --- getPayment --- + +export const getPayment = async (id) => { + const [row] = await sql`SELECT * FROM payment_requests WHERE id = ${id}` + return row ?? null } +// Backward-compat aliases (legacy callers — to be migrated in a follow-up pass) +export const createPaymentSession = (args) => requestPayment(args) +export const confirmPaymentSession = confirmPaymentForCustomer +export const consumePaymentSession = consumePayment +export const abandonPaymentSession = cancelPayment +export const getPaymentSession = getPayment + +// --- getCustomerPendingPayments (unchanged shape) --- + /** * Phase 4 Stage 10 — Chat Tab Pembayaran feed. - * - * Returns the customer's pending payment sessions (initial + extension) that - * haven't paid AND haven't expired. The `expires_at > NOW()` filter is - * defensive: the background sweeper flips stale pending rows to `expired`, - * but rows can be stale between sweeps, so we filter inline too. - * - * Extension rows resolve mitra info via session_extensions → chat_sessions → - * mitras. Initial rows fall back to `payment_sessions.targeted_mitra_id` - * (set for targeted "Curhat lagi" flows); for general-blast initial rows - * the mitra is unknown until pairing succeeds, so mitra fields are null. + * Returns the customer's pending payment requests (initial + extension) that + * haven't paid AND haven't expired. */ export const getCustomerPendingPayments = async (customerId) => { const items = await sql` @@ -331,15 +601,37 @@ export const getCustomerPendingPayments = async (customerId) => { ps.expires_at, COALESCE(ext_m.id, tgt_m.id) AS mitra_id, COALESCE(ext_m.display_name, tgt_m.display_name) AS mitra_display_name - FROM payment_sessions ps - LEFT JOIN session_extensions se ON se.payment_session_id = ps.id + FROM payment_requests ps + LEFT JOIN session_extensions se ON se.payment_request_id = ps.id LEFT JOIN chat_sessions cs ON cs.id = se.session_id LEFT JOIN mitras ext_m ON ext_m.id = cs.mitra_id LEFT JOIN mitras tgt_m ON tgt_m.id = ps.targeted_mitra_id WHERE ps.customer_id = ${customerId} - AND ps.status = ${PaymentSessionStatus.PENDING} + AND ps.status = ${PaymentRequestStatus.PENDING} AND ps.expires_at > NOW() ORDER BY ps.created_at DESC ` return { items, total: items.length } } + +// --- Pairing subscriber wiring (called from server.js at startup) --- + +/** + * Wires pairing.service as a subscriber to payment_request.confirmed. + * Filters on productType so future product subscribers can coexist. + * Subscriber implements its own idempotency (skip if chat_sessions row exists). + * + * Imported lazily to avoid a circular import: pairing.service imports payment.service + * for failPaymentSession + consumePayment + getPayment. + */ +export const registerPairingSubscriber = () => { + on('payment_request.confirmed', async (evt) => { + if (evt.productType !== 'chat_session') return + const { startPairingFromPaymentRequest } = await import('./pairing.service.js') + await startPairingFromPaymentRequest({ + paymentRequestId: evt.paymentRequestId, + productMetadata: evt.productMetadata, + customerId: evt.customerId, + }) + }) +} diff --git a/backend/src/services/session.service.js b/backend/src/services/session.service.js index 588ef10..5514463 100644 --- a/backend/src/services/session.service.js +++ b/backend/src/services/session.service.js @@ -149,9 +149,9 @@ export const listSessions = async ({ page = 1, limit = 20, status, topic_sensiti } export const getSessionById = async (sessionId) => { - // `mode` lives on payment_sessions (chat | call), introduced in Phase 4.1. + // `mode` lives on payment_requests (chat | call), introduced in Phase 4.1. // The chat header pill needs it, so surface it on every session.info read. - // Falls back to 'chat' for pre-3.7 rows where payment_session_id is null. + // Falls back to 'chat' for pre-3.7 rows where payment_request_id is null. const [session] = await sql` SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by, @@ -162,7 +162,7 @@ export const getSessionById = async (sessionId) => { FROM chat_sessions cs INNER JOIN customers c ON c.id = cs.customer_id LEFT JOIN mitras m ON m.id = cs.mitra_id - 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 = ${sessionId} ` return session @@ -251,7 +251,7 @@ export const getCustomerHistory = async (customerId, { cursor = null, limit = 20 FROM chat_sessions cs LEFT JOIN mitras m ON m.id = cs.mitra_id LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id - 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.customer_id = ${customerId} AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING}) AND ( @@ -275,7 +275,7 @@ export const getCustomerHistory = async (customerId, { cursor = null, limit = 20 FROM chat_sessions cs LEFT JOIN mitras m ON m.id = cs.mitra_id LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id - 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.customer_id = ${customerId} AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING}) ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC, cs.id DESC diff --git a/backend/test/helpers/db.js b/backend/test/helpers/db.js index 3b714e3..41e31fa 100644 --- a/backend/test/helpers/db.js +++ b/backend/test/helpers/db.js @@ -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 }], diff --git a/backend/test/routes/client.payment.routes.test.js b/backend/test/routes/client.payment.routes.test.js index ee92f56..2503163 100644 --- a/backend/test/routes/client.payment.routes.test.js +++ b/backend/test/routes/client.payment.routes.test.js @@ -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' }, }) diff --git a/backend/test/routes/shared.payment-webhooks.routes.test.js b/backend/test/routes/shared.payment-webhooks.routes.test.js new file mode 100644 index 0000000..cff266b --- /dev/null +++ b/backend/test/routes/shared.payment-webhooks.routes.test.js @@ -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) + }) +}) diff --git a/backend/test/services/extension.service.test.js b/backend/test/services/extension.service.test.js index abc9073..5c34c3a 100644 --- a/backend/test/services/extension.service.test.js +++ b/backend/test/services/extension.service.test.js @@ -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 diff --git a/backend/test/services/pairing.service.test.js b/backend/test/services/pairing.service.test.js index 42e452f..c14b3ac 100644 --- a/backend/test/services/pairing.service.test.js +++ b/backend/test/services/pairing.service.test.js @@ -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) }) diff --git a/backend/test/services/payment.service.test.js b/backend/test/services/payment.service.test.js index 77ed9c0..b6cb27d 100644 --- a/backend/test/services/payment.service.test.js +++ b/backend/test/services/payment.service.test.js @@ -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} ` diff --git a/client_app/lib/core/chat/session_closure_notifier.dart b/client_app/lib/core/chat/session_closure_notifier.dart index 25aeb2c..66cd932 100644 --- a/client_app/lib/core/chat/session_closure_notifier.dart +++ b/client_app/lib/core/chat/session_closure_notifier.dart @@ -48,14 +48,14 @@ class SessionClosure extends _$SessionClosure { SessionClosureData build() => const ClosureInitialData(); /// Extension request is a 3-step flow with the extension cost held in its - /// own `payment_session` (never combined with a free trial). Server-side, + /// own `payment_request` (never combined with a free trial). Server-side, /// the extension service refuses requests without an - /// `extension_payment_session_id` on a confirmed, is_extension payment session. + /// `extension_payment_request_id` on a confirmed, is_extension payment session. /// - /// 1. POST `/api/client/payment-sessions` with `is_extension: true` - /// 2. POST `/api/client/payment-sessions/:id/confirm` + /// 1. POST `/api/client/payment-requests` with `is_extension: true` + /// 2. POST `/api/client/payment-requests/:id/confirm` /// 3. POST `/api/client/chat/session/:sessionId/extend` with the - /// extension_payment_session_id from step 2. + /// extension_payment_request_id from step 2. /// /// Charge timing is server-side: only on actual approve / auto-approve. /// If the mitra explicitly rejects within 10s the payment is failed back, no charge. @@ -64,15 +64,15 @@ class SessionClosure extends _$SessionClosure { try { final api = ref.read(apiClientProvider); - final createResp = await api.post('/api/client/payment-sessions/', data: { + final createResp = await api.post('/api/client/payment-requests/', data: { 'duration_minutes': durationMinutes, 'is_extension': true, }); - final paymentSessionId = (createResp['data'] as Map)['id'] as String; + final paymentRequestId = (createResp['data'] as Map)['id'] as String; // Backend rejects truly empty bodies on confirm, so always send `{}`. await api.post( - '/api/client/payment-sessions/$paymentSessionId/confirm', + '/api/client/payment-requests/$paymentRequestId/confirm', data: const {}, ); @@ -81,7 +81,7 @@ class SessionClosure extends _$SessionClosure { await api.post('/api/client/chat/session/$sessionId/extend', data: { 'duration_minutes': durationMinutes, 'price': price, - 'extension_payment_session_id': paymentSessionId, + 'extension_payment_request_id': paymentRequestId, }); } catch (e) { state = const ClosureErrorData('Gagal meminta perpanjangan.'); diff --git a/client_app/lib/core/constants.dart b/client_app/lib/core/constants.dart index fde84c0..d8ff693 100644 --- a/client_app/lib/core/constants.dart +++ b/client_app/lib/core/constants.dart @@ -64,7 +64,7 @@ class ExtensionStatus { ExtensionStatus._(); } -/// Session mode — chat or voice call. Mirrors backend `payment_sessions.mode` +/// Session mode — chat or voice call. Mirrors backend `payment_requests.mode` /// (added in Phase 4 stage 1). A `call` session is functionally a chat with a /// "voice call" badge and (eventually) a Meet link the mitra pastes manually; /// no real audio transport is built yet. @@ -153,7 +153,7 @@ enum PairingFailureCause { targetedMitraOffline('targeted_mitra_offline'), targetedMitraRejected('targeted_mitra_rejected'), targetedMitraTimeout('targeted_mitra_timeout'), - paymentSessionExpired('payment_session_expired'), + paymentSessionExpired('payment_request_expired'), customerCancelled('customer_cancelled'), unknown('unknown'); @@ -165,13 +165,13 @@ enum PairingFailureCause { } /// Payment session lifecycle. Mirror of backend -/// `PaymentSessionStatus`. -class PaymentSessionStatus { +/// `PaymentRequestStatus`. +class PaymentRequestStatus { static const pending = 'pending'; static const confirmed = 'confirmed'; static const consumed = 'consumed'; - static const failedPairing = 'failed_pairing'; + static const failedPairing = 'failed_delivery'; static const abandoned = 'abandoned'; static const expired = 'expired'; - PaymentSessionStatus._(); + PaymentRequestStatus._(); } diff --git a/client_app/lib/core/pairing/pairing_notifier.dart b/client_app/lib/core/pairing/pairing_notifier.dart index 6176a15..eefb2a5 100644 --- a/client_app/lib/core/pairing/pairing_notifier.dart +++ b/client_app/lib/core/pairing/pairing_notifier.dart @@ -23,12 +23,12 @@ class PairingInitialData extends PairingData { /// General-blast in flight. The chat_session row exists; backend has already /// notified all available mitras and is waiting for the first to accept. class PairingSearchingData extends PairingData { - /// chat_session id (NOT payment_session id). + /// chat_session id (NOT payment_request id). final String sessionId; - /// payment_session id — we keep it on the state so cancelSearch can call + /// payment_request id — we keep it on the state so cancelSearch can call /// the payment-session-scoped cancel endpoint without re-prompting. - final String paymentSessionId; + final String paymentRequestId; /// Carried so a retryable PAIRING_FAILED can preserve the customer's original /// topic choice when looping back into Blast via retryBlast(). @@ -36,7 +36,7 @@ class PairingSearchingData extends PairingData { const PairingSearchingData({ required this.sessionId, - required this.paymentSessionId, + required this.paymentRequestId, required this.topicSensitivity, }); } @@ -49,7 +49,7 @@ class PairingSearchingData extends PairingData { /// server is the source of truth for the actual auto-reject; the local timer /// is purely cosmetic. class PairingTargetedWaitingData extends PairingData { - final String paymentSessionId; + final String paymentRequestId; final String mitraId; final String mitraName; final int secondsRemaining; @@ -58,7 +58,7 @@ class PairingTargetedWaitingData extends PairingData { final TopicSensitivity topicSensitivity; const PairingTargetedWaitingData({ - required this.paymentSessionId, + required this.paymentRequestId, required this.mitraId, required this.mitraName, required this.secondsRemaining, @@ -67,7 +67,7 @@ class PairingTargetedWaitingData extends PairingData { PairingTargetedWaitingData copyWith({int? secondsRemaining}) { return PairingTargetedWaitingData( - paymentSessionId: paymentSessionId, + paymentRequestId: paymentRequestId, mitraId: mitraId, mitraName: mitraName, secondsRemaining: secondsRemaining ?? this.secondsRemaining, @@ -96,14 +96,14 @@ class PairingActiveData extends PairingData { /// /// The UI surfaces this via the bestie-unavailable dialog. class PairingTargetedUnavailableData extends PairingData { - final String paymentSessionId; + final String paymentRequestId; final String mitraName; final PairingFailureCause cause; // Carried so the fallback-to-blast call preserves the customer's original choice. final TopicSensitivity topicSensitivity; const PairingTargetedUnavailableData({ - required this.paymentSessionId, + required this.paymentRequestId, required this.mitraName, required this.cause, required this.topicSensitivity, @@ -114,11 +114,11 @@ class PairingTargetedUnavailableData extends PairingData { /// /// `isRetryable=true` means the backend kept the payment session `confirmed` /// (audit-only failure) so the customer can re-blast on the same payment via -/// `retryBlast()`. `isRetryable=false` means the payment is in `failed_pairing` +/// `retryBlast()`. `isRetryable=false` means the payment is in `failed_delivery` /// and any retry must start from a fresh payment session. class PairingFailedData extends PairingData { final PairingFailureCause cause; - final String? paymentSessionId; + final String? paymentRequestId; final bool isRetryable; // Carried so retryBlast() can re-issue the blast with the customer's original // topic choice. Null when the failure originated before any topic was known. @@ -126,7 +126,7 @@ class PairingFailedData extends PairingData { const PairingFailedData({ required this.cause, - this.paymentSessionId, + this.paymentRequestId, this.isRetryable = false, this.topicSensitivity, }); @@ -156,7 +156,7 @@ class Pairing extends _$Pairing { /// Returns once the chat_session row is created server-side; subsequent /// transitions (paired / pairing_failed) arrive via WS. Future startSearch({ - required String paymentSessionId, + required String paymentRequestId, required TopicSensitivity topicSensitivity, }) async { state = const PairingInitialData(); @@ -165,7 +165,7 @@ class Pairing extends _$Pairing { final response = await _apiClient.post( '/api/client/chat/request', data: { - 'payment_session_id': paymentSessionId, + 'payment_request_id': paymentRequestId, 'topic_sensitivity': topicSensitivity.value, }, ); @@ -173,7 +173,7 @@ class Pairing extends _$Pairing { final sessionId = data['id'] as String; state = PairingSearchingData( sessionId: sessionId, - paymentSessionId: paymentSessionId, + paymentRequestId: paymentRequestId, topicSensitivity: topicSensitivity, ); } on DioException catch (e) { @@ -183,7 +183,7 @@ class Pairing extends _$Pairing { // Backend already failed the payment in this case — terminal. state = PairingFailedData( cause: PairingFailureCause.noMitraAvailable, - paymentSessionId: paymentSessionId, + paymentRequestId: paymentRequestId, ); } else if (code == 'ALREADY_ACTIVE') { state = const PairingErrorData('Kamu sudah memiliki sesi aktif.'); @@ -201,7 +201,7 @@ class Pairing extends _$Pairing { /// row (payment stays confirmed) — we transition to TargetedUnavailable so /// the UI can offer the fallback dialog. Future startTargetedSearch({ - required String paymentSessionId, + required String paymentRequestId, required String mitraId, required String mitraName, required TopicSensitivity topicSensitivity, @@ -212,7 +212,7 @@ class Pairing extends _$Pairing { final response = await _apiClient.post( '/api/client/chat/chat-requests/returning', data: { - 'payment_session_id': paymentSessionId, + 'payment_request_id': paymentRequestId, 'mitra_id': mitraId, 'topic_sensitivity': topicSensitivity.value, }, @@ -222,7 +222,7 @@ class Pairing extends _$Pairing { final sessionData = response['data'] as Map?; final seconds = (sessionData?['confirmation_timeout_seconds'] as num?)?.toInt() ?? 20; state = PairingTargetedWaitingData( - paymentSessionId: paymentSessionId, + paymentRequestId: paymentRequestId, mitraId: mitraId, mitraName: mitraName, secondsRemaining: seconds, @@ -237,7 +237,7 @@ class Pairing extends _$Pairing { // Intermediate — payment session is still confirmed; show the // bestie-unavailable popup with a "Chat dengan bestie lain" option. state = PairingTargetedUnavailableData( - paymentSessionId: paymentSessionId, + paymentRequestId: paymentRequestId, mitraName: mitraName, cause: PairingFailureCause.targetedMitraOffline, topicSensitivity: topicSensitivity, @@ -251,17 +251,17 @@ class Pairing extends _$Pairing { } /// Customer-initiated cancel during a search/wait. Terminal — payment - /// session moves to `failed_pairing` server-side. We route the UI to home + /// session moves to `failed_delivery` server-side. We route the UI to home /// (NOT to the failed-pairing screen) since the customer chose this. Future cancelSearch() async { - String? paymentSessionId; + String? paymentRequestId; final current = state; if (current is PairingSearchingData) { - paymentSessionId = current.paymentSessionId; + paymentRequestId = current.paymentRequestId; } else if (current is PairingTargetedWaitingData) { - paymentSessionId = current.paymentSessionId; + paymentRequestId = current.paymentRequestId; } - if (paymentSessionId == null) { + if (paymentRequestId == null) { _cleanup(); state = const PairingCancelledData(); return; @@ -269,7 +269,7 @@ class Pairing extends _$Pairing { try { await _apiClient.post( '/api/client/chat/chat-requests/cancel', - data: {'payment_session_id': paymentSessionId}, + data: {'payment_request_id': paymentRequestId}, ); } catch (_) { // Best-effort. Backend will still fail the payment if/when it @@ -283,21 +283,21 @@ class Pairing extends _$Pairing { /// Reuses the same payment session — backend transitions back into the /// general-blast path. Future fallbackToBlast({ - required String paymentSessionId, + required String paymentRequestId, required TopicSensitivity topicSensitivity, }) async { state = const PairingInitialData(); try { await _connectWebSocket(); final response = await _apiClient.post( - '/api/client/chat/chat-requests/$paymentSessionId/fallback-to-blast', + '/api/client/chat/chat-requests/$paymentRequestId/fallback-to-blast', data: {'topic_sensitivity': topicSensitivity.value}, ); final data = response['data'] as Map; final sessionId = data['id'] as String; state = PairingSearchingData( sessionId: sessionId, - paymentSessionId: paymentSessionId, + paymentRequestId: paymentRequestId, topicSensitivity: topicSensitivity, ); } on DioException catch (e) { @@ -306,7 +306,7 @@ class Pairing extends _$Pairing { if (code == 'NO_MITRA_AVAILABLE') { state = PairingFailedData( cause: PairingFailureCause.noMitraAvailable, - paymentSessionId: paymentSessionId, + paymentRequestId: paymentRequestId, ); } else { state = const PairingErrorData('Gagal memulai. Coba lagi.'); @@ -326,17 +326,17 @@ class Pairing extends _$Pairing { /// `confirmed` (retryable failure). Re-blasts on the same payment session. /// /// Caller should only invoke this when `state is PairingFailedData && - /// state.isRetryable && paymentSessionId != null && topicSensitivity != null`. + /// state.isRetryable && paymentRequestId != null && topicSensitivity != null`. Future retryBlast() async { final current = state; if (current is! PairingFailedData || !current.isRetryable - || current.paymentSessionId == null + || current.paymentRequestId == null || current.topicSensitivity == null) { return; } await startSearch( - paymentSessionId: current.paymentSessionId!, + paymentRequestId: current.paymentRequestId!, topicSensitivity: current.topicSensitivity!, ); } @@ -388,7 +388,7 @@ class Pairing extends _$Pairing { if (type == WsMessage.pairingFailed) { final causeTag = data['cause_tag'] as String?; - final paymentSessionId = data['payment_session_id'] as String?; + final paymentRequestId = data['payment_request_id'] as String?; // Missing flag = terminal (backward-compat with older emit sites). When // false, the backend kept the payment confirmed and we can re-blast. final isRetryable = data['is_terminal'] == false; @@ -398,7 +398,7 @@ class Pairing extends _$Pairing { _cleanup(); state = PairingFailedData( cause: PairingFailureCause.fromString(causeTag), - paymentSessionId: paymentSessionId, + paymentRequestId: paymentRequestId, isRetryable: isRetryable, topicSensitivity: carriedTopic, ); @@ -409,7 +409,7 @@ class Pairing extends _$Pairing { // Intermediate — payment still confirmed. Show the bestie-unavailable // dialog (UI surfaces via state listener). _stopLocalCountdown(); - final paymentSessionId = data['payment_session_id'] as String?; + final paymentRequestId = data['payment_request_id'] as String?; // Pull mitra name + topic from the prior targeted-waiting state (we know it from // the request payload). If we somehow lost it, fall back to safe defaults. String mitraName = 'Bestie'; @@ -419,7 +419,7 @@ class Pairing extends _$Pairing { carriedTopic = current.topicSensitivity; } state = PairingTargetedUnavailableData( - paymentSessionId: paymentSessionId ?? (current is PairingTargetedWaitingData ? current.paymentSessionId : ''), + paymentRequestId: paymentRequestId ?? (current is PairingTargetedWaitingData ? current.paymentRequestId : ''), mitraName: mitraName, topicSensitivity: carriedTopic, cause: type == WsMessage.returningChatTimeout diff --git a/client_app/lib/features/chat/screens/no_bestie_screen.dart b/client_app/lib/features/chat/screens/no_bestie_screen.dart index 62cd978..bb7bf6c 100644 --- a/client_app/lib/features/chat/screens/no_bestie_screen.dart +++ b/client_app/lib/features/chat/screens/no_bestie_screen.dart @@ -6,7 +6,7 @@ import '../../../core/pairing/pairing_notifier.dart'; /// Terminal failed-pairing screen. /// /// Reached when the pairing notifier transitions to [PairingFailedData] -/// (terminal — payment session is `failed_pairing` server-side, audit row +/// (terminal — payment session is `failed_delivery` server-side, audit row /// recorded). Copy is intentionally identical regardless of `cause_tag` for /// now (the design pass will revise this later). /// diff --git a/client_app/lib/features/chat/screens/searching_screen.dart b/client_app/lib/features/chat/screens/searching_screen.dart index 9202894..2c90d97 100644 --- a/client_app/lib/features/chat/screens/searching_screen.dart +++ b/client_app/lib/features/chat/screens/searching_screen.dart @@ -70,7 +70,7 @@ class _SearchingScreenState extends ConsumerState { if (draft.targetedMitraId != null) { // ignore: discarded_futures ref.read(pairingProvider.notifier).startTargetedSearch( - paymentSessionId: draft.paymentId!, + paymentRequestId: draft.paymentId!, mitraId: draft.targetedMitraId!, mitraName: draft.targetedMitraName ?? 'Bestie', topicSensitivity: draft.topicSensitivity, @@ -80,7 +80,7 @@ class _SearchingScreenState extends ConsumerState { } // ignore: discarded_futures ref.read(pairingProvider.notifier).startSearch( - paymentSessionId: draft.paymentId!, + paymentRequestId: draft.paymentId!, topicSensitivity: draft.topicSensitivity, ); } @@ -117,7 +117,7 @@ class _SearchingScreenState extends ConsumerState { context, variant: BestieOfflineVariant.returning, mitraName: next.mitraName, - paymentSessionId: next.paymentSessionId, + paymentRequestId: next.paymentRequestId, topicSensitivity: next.topicSensitivity, ).then((_) { if (mounted) _unavailableDialogShown = false; diff --git a/client_app/lib/features/chat/screens/targeted_waiting_screen.dart b/client_app/lib/features/chat/screens/targeted_waiting_screen.dart index a6a0f75..e610f76 100644 --- a/client_app/lib/features/chat/screens/targeted_waiting_screen.dart +++ b/client_app/lib/features/chat/screens/targeted_waiting_screen.dart @@ -64,7 +64,7 @@ class _TargetedWaitingScreenState extends ConsumerState { context, variant: BestieOfflineVariant.returning, mitraName: next.mitraName, - paymentSessionId: next.paymentSessionId, + paymentRequestId: next.paymentRequestId, topicSensitivity: next.topicSensitivity, ).then((_) { if (mounted) _popupShown = false; diff --git a/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart b/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart index b3f3a6c..ae33dc1 100644 --- a/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart +++ b/client_app/lib/features/chat/widgets/bestie_unavailable_dialog.dart @@ -23,7 +23,7 @@ import '../../support/widgets/tanya_admin_sheet.dart'; /// payment session exists yet, so the "cari bestie lain" CTA resets the /// payment draft and pushes `/payment/entry` for a fresh blast-payment /// flow. This branch never calls [Pairing.fallbackToBlast] because there's -/// no `paymentSessionId` to attach to. +/// no `paymentRequestId` to attach to. /// - [BestieOfflineVariant.new_] — the customer triggered a general blast /// that bottomed out (no online besties). No fallback button; just a /// ghost `tanya admin` and a `kembali ke home` exit. @@ -35,14 +35,14 @@ enum BestieOfflineVariant { returning, prePayReturning, new_ } class BestieOfflinePopup extends ConsumerWidget { final BestieOfflineVariant variant; final String mitraName; - final String? paymentSessionId; + final String? paymentRequestId; final TopicSensitivity? topicSensitivity; const BestieOfflinePopup({ super.key, required this.variant, required this.mitraName, - this.paymentSessionId, + this.paymentRequestId, this.topicSensitivity, }); @@ -50,7 +50,7 @@ class BestieOfflinePopup extends ConsumerWidget { BuildContext context, { required BestieOfflineVariant variant, required String mitraName, - String? paymentSessionId, + String? paymentRequestId, TopicSensitivity? topicSensitivity, }) { return showDialog( @@ -60,7 +60,7 @@ class BestieOfflinePopup extends ConsumerWidget { builder: (_) => BestieOfflinePopup( variant: variant, mitraName: mitraName, - paymentSessionId: paymentSessionId, + paymentRequestId: paymentRequestId, topicSensitivity: topicSensitivity, ), ); @@ -83,7 +83,7 @@ class BestieOfflinePopup extends ConsumerWidget { final canFallbackToBlast = isReturning && hasOtherAvailable && - paymentSessionId != null && + paymentRequestId != null && topicSensitivity != null; return Dialog( @@ -145,7 +145,7 @@ class BestieOfflinePopup extends ConsumerWidget { Navigator.of(context).pop(); // ignore: discarded_futures ref.read(pairingProvider.notifier).fallbackToBlast( - paymentSessionId: paymentSessionId!, + paymentRequestId: paymentRequestId!, topicSensitivity: topicSensitivity!, ); }, diff --git a/client_app/lib/features/chat_tab/providers/pending_payments_provider.dart b/client_app/lib/features/chat_tab/providers/pending_payments_provider.dart index 9f60505..ffbf507 100644 --- a/client_app/lib/features/chat_tab/providers/pending_payments_provider.dart +++ b/client_app/lib/features/chat_tab/providers/pending_payments_provider.dart @@ -4,7 +4,7 @@ import '../../../core/auth/auth_notifier.dart'; /// One row in the Chat Tab > Pembayaran sub-tab. /// -/// Mirrors the response of `GET /api/client/payment-sessions/pending`. A row +/// Mirrors the response of `GET /api/client/payment-requests/pending`. A row /// is either an initial-session payment (`isExtension == false`) — for which /// mitra info is only present in the targeted "Curhat lagi" flow — or an /// extension payment (`isExtension == true`) — mitra info resolved by the @@ -80,7 +80,7 @@ final pendingPaymentsProvider = if (customerId == null) return PendingPaymentsData.empty; final api = ref.read(apiClientProvider); final response = - await api.get('/api/client/payment-sessions/pending'); + await api.get('/api/client/payment-requests/pending'); final data = response['data'] as Map? ?? const {}; final items = (data['items'] as List? ?? []) .cast>() diff --git a/client_app/lib/features/payment/screens/payment_method_screen.dart b/client_app/lib/features/payment/screens/payment_method_screen.dart index 9f947d7..2925b52 100644 --- a/client_app/lib/features/payment/screens/payment_method_screen.dart +++ b/client_app/lib/features/payment/screens/payment_method_screen.dart @@ -9,7 +9,7 @@ import '../../../core/theme/widgets/halo_button.dart'; import '../state/payment_draft_provider.dart'; /// "Cara bayar" — QRIS-first list of payment methods. On tap of `bayar`: -/// 1. POST `/api/client/payment-sessions` with the draft + chosen method. +/// 1. POST `/api/client/payment-requests` with the draft + chosen method. /// 2. Push `/payment/waiting/:paymentId`. class PaymentMethodScreen extends ConsumerStatefulWidget { const PaymentMethodScreen({super.key}); @@ -72,7 +72,7 @@ class _PaymentMethodScreenState extends ConsumerState { }; // Trailing slash matches the existing payment_notifier path — Fastify // is not configured with `ignoreTrailingSlash`. - final response = await api.post('/api/client/payment-sessions/', data: body); + final response = await api.post('/api/client/payment-requests/', data: body); final data = response['data'] as Map; final paymentId = data['id'] as String; ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId); diff --git a/client_app/lib/features/payment/screens/waiting_payment_screen.dart b/client_app/lib/features/payment/screens/waiting_payment_screen.dart index a29042c..cac3a58 100644 --- a/client_app/lib/features/payment/screens/waiting_payment_screen.dart +++ b/client_app/lib/features/payment/screens/waiting_payment_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:qr_flutter/qr_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../../core/api/api_client_provider.dart'; import '../../../core/constants.dart'; import '../../../core/theme/halo_tokens.dart'; @@ -36,6 +37,7 @@ class _WaitingPaymentScreenState extends ConsumerState bool _initialLoading = true; bool _terminal = false; String? _error; + bool _invoiceUrlLaunched = false; // Phase 5: only auto-launch the Custom Tab once Duration get _remaining { final exp = _expiresAt; @@ -80,11 +82,34 @@ class _WaitingPaymentScreenState extends ConsumerState _qrPayload = (session['qr_string'] as String?) ?? widget.paymentId; _initialLoading = false; }); + // Phase 5: when Xendit is on, the backend returns an `xendit_invoice_url` + // (Xendit's hosted checkout). Open it in a Custom Tab (Android) / + // SFSafariViewController (iOS) so the customer stays inside the app's + // browser context. Fire-and-forget — polling continues regardless. + // When Xendit is off (dev/Maestro), invoice_url is null and the QR fallback below is used. + await _maybeLaunchInvoiceUrl(session); _maybeHandleStatus(session); _startTicker(); _resumePolling(); } + Future _maybeLaunchInvoiceUrl(Map session) async { + if (_invoiceUrlLaunched) return; + final url = (session['xendit_invoice_url'] as String?) ?? (session['invoice_url'] as String?); + if (url == null || url.isEmpty) return; + _invoiceUrlLaunched = true; + try { + await launchUrl( + Uri.parse(url), + mode: LaunchMode.inAppBrowserView, // Custom Tab on Android, SFVC on iOS + ); + } catch (e) { + // Silent — polling will eventually resolve to expired if the customer can't pay. + // Don't surface an error toast; the user might have a non-Custom-Tab-capable env + // and url_launcher falls back to the system browser automatically. + } + } + void _startTicker() { _ticker?.cancel(); _ticker = Timer.periodic(_tickInterval, (_) { @@ -111,7 +136,7 @@ class _WaitingPaymentScreenState extends ConsumerState Future?> _fetchSession() async { try { final api = ref.read(apiClientProvider); - final response = await api.get('/api/client/payment-sessions/${widget.paymentId}'); + final response = await api.get('/api/client/payment-requests/${widget.paymentId}'); return response['data'] as Map?; } catch (e) { if (!mounted) return null; @@ -122,12 +147,12 @@ class _WaitingPaymentScreenState extends ConsumerState void _maybeHandleStatus(Map session) { final status = session['status'] as String?; - if (status == PaymentSessionStatus.confirmed || - status == PaymentSessionStatus.consumed) { + if (status == PaymentRequestStatus.confirmed || + status == PaymentRequestStatus.consumed) { _markTerminal(); _navigateTerminal('/onboarding/notif-gate'); - } else if (status == PaymentSessionStatus.expired || - status == PaymentSessionStatus.abandoned) { + } else if (status == PaymentRequestStatus.expired || + status == PaymentRequestStatus.abandoned) { _markTerminal(); _navigateTerminal('/payment/expired/${widget.paymentId}'); } diff --git a/control_center/src/core/constants.js b/control_center/src/core/constants.js index 84f3a6d..e24960e 100644 --- a/control_center/src/core/constants.js +++ b/control_center/src/core/constants.js @@ -8,7 +8,7 @@ export const PairingFailureCause = Object.freeze({ TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline', TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected', TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout', - PAYMENT_SESSION_EXPIRED: 'payment_session_expired', + PAYMENT_REQUEST_EXPIRED: 'payment_request_expired', CUSTOMER_CANCELLED: 'customer_cancelled', EXTENSION_REJECTED: 'extension_rejected', EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped', @@ -20,7 +20,7 @@ export const PairingFailureCauseLabel = Object.freeze({ [PairingFailureCause.TARGETED_MITRA_OFFLINE]: 'Targeted mitra offline', [PairingFailureCause.TARGETED_MITRA_REJECTED]: 'Targeted mitra rejected', [PairingFailureCause.TARGETED_MITRA_TIMEOUT]: 'Targeted mitra timeout', - [PairingFailureCause.PAYMENT_SESSION_EXPIRED]: 'Payment session expired', + [PairingFailureCause.PAYMENT_REQUEST_EXPIRED]: 'Payment session expired', [PairingFailureCause.CUSTOMER_CANCELLED]: 'Customer cancelled', [PairingFailureCause.EXTENSION_REJECTED]: 'Extension rejected', [PairingFailureCause.EXTENSION_SAFEGUARD_TRIPPED]: 'Extension safeguard tripped', diff --git a/requirement/phase5-xendit-plan.md b/requirement/phase5-xendit-plan.md new file mode 100644 index 0000000..4c9bcfb --- /dev/null +++ b/requirement/phase5-xendit-plan.md @@ -0,0 +1,1326 @@ +# Phase 5 — Xendit Integration Plan + +> **Goal:** Replace the mocked payment confirmation (`/internal/_test/force-confirm-payment`) +> with real Xendit Invoice creation + webhook-driven confirmation. Phase 4 built the payment +> session lifecycle (`pending → confirmed → consumed`) and the customer-facing payment shell +> screens. Phase 5 plugs Xendit in front of `pending → confirmed`. +> +> Affects: **backend**, **client_app**, **control_center** (minor). +> `mitra_app` is untouched — mitras never pay. +> +> See [phase4-customer-flow-plan.md](phase4-customer-flow-plan.md) for the payment-shell +> work this builds on, and root `CLAUDE.md` for project context. + +--- + +## Architectural Decisions (locked) + +| # | Decision | Rationale | +|---|---|---| +| D1 | Use Xendit **Invoice** product (hosted page) | One product covers all payment methods; ~1 day to ship vs. ~1 week for per-method PG APIs. | +| D2 | Webhook is the **only** caller of `confirmPayment` | Required for VA (async, customer pays hours later). App polls `GET /payment-requests/:id`. | +| D3 | App opens the Xendit checkout URL in an **in-app browser** (Chrome Custom Tab on Android, SFSafariViewController on iOS) via `url_launcher` `LaunchMode.inAppBrowserView`. **WebView is explicitly out** — no autofill, no SMS-OTP integration, anti-fraud risk, and PCI-discouraged. | Custom Tab / SFVC is a real browser process so we keep the PCI separation + cookie/autofill/password-manager benefits, while the customer stays in the app's context (no jarring app-switch). Fallback to system browser is automatic when Custom Tab is unavailable. App stays on the waiting-payment screen and polls regardless. | +| D4 | `external_id` = our `payment_requests.id` UUID | One-shot lookup in webhook handler; Xendit's uniqueness check doubles as idempotency. | +| D5 | Invoice `invoice_duration` mirrors `payment_request_timeout_minutes` | Both sides expire together — no "Xendit confirms a payment we already marked expired". | +| D6 | Env flag `XENDIT_ENABLED` gates the integration | `false` in dev (skip invoice creation, keep stub flow). `true` in staging/prod. Maestro never hits Xendit. | +| D7 | Keep `/internal/_test/force-confirm-payment` forever | Maestro / manual QA need to advance past payment without Xendit. Internal listener only. | +| D8 | Webhook URL is **dashboard-set per environment** (test/live) | Xendit Invoice API does not accept per-request callback URL. Dev tunnel → test dashboard; prod URL → live dashboard. | +| D9 | App-facing `POST /payment-requests/:id/confirm` is **gated** by `XENDIT_ENABLED` | When Xendit is on, only the webhook can confirm. When off, app can self-confirm (current dev/Maestro behavior). | + +--- + +## Architecture (revised 2026-05-23) — event-driven, product-agnostic payment service + +> **Supersedes the lifecycle/coupling shape in Stages 1–7 below.** Those stages +> still describe the original chat-only, webhook-as-confirmer design and will +> need rewriting once the open threads at the bottom of this section settle. +> The locked decisions D1–D9 above still hold — this section refines *how the +> service is shaped internally*, not what payment provider we use. + +### Why this shape + +Future products (courses, merch, subscriptions) will need to be paid for the +same way chat sessions are. Rather than ship Phase 5 tightly coupled to chat +and refactor later, we shape the payment layer today as if it were already a +standalone microservice. Today it lives in-process as `payment.service.js` + +in-process EventEmitter; tomorrow the same API surface and event names work +unchanged across HTTP / pub-sub / network boundaries. + +### Renames vs. the original plan + +| Original | Revised | +|---|---| +| `payment_requests` table | `payment_requests` | +| `*.payment_request_id` FK columns | `*.payment_request_id` | +| `PaymentRequestStatus` constant | `PaymentRequestStatus` | +| `requestPayment`, `confirmPayment`, ... | `requestPayment`, `confirmPayment`, `expirePayment`, ... | +| `/api/client/payment-requests/...` routes | `/api/client/payment-requests/...` | +| `/api/shared/payment/webhooks/xendit` | `/api/shared/payment/webhooks/xendit` (Xendit is payment-internal) | +| `payment_request_timeout_minutes` config | `payment_request_timeout_minutes` | +| `failed_delivery` status (chat-specific) | `failed_delivery` (product-agnostic) | + +### Schema additions for product-agnosticism + +```sql +ALTER TABLE payment_requests + ADD COLUMN product_type TEXT NOT NULL DEFAULT 'chat_session', + ADD COLUMN product_metadata JSONB NOT NULL DEFAULT '{}'::jsonb; +``` + +- `product_type` — tag (`'chat_session'`, future `'course_enrollment'`, `'merch_order'`, ...) +- `product_metadata` — opaque blob the payment service never opens. Chat + stamps `{duration_minutes, mode, targeted_mitra_id, is_extension}` at + create time and reads it back when `payment_request.confirmed` fires. Each future + product stamps whatever shape it needs. + +### Public API surface (the future microservice contract) + +What's *not* in this list is private to the payment service. Today these +are function exports; the day we extract, each becomes an HTTP route or +topic — caller code does not change. + +``` +requestPayment({ productType, productMetadata, customerId, amount, ttlMinutes }) → paymentRequest +getPayment(id) → paymentRequest +cancelPayment(id, customerId) → paymentRequest +markDeliveryFailed(id, causeTag) → paymentRequest // product-side reports inability to deliver + +on(event, handler) + events: 'payment_request.confirmed' | 'payment_request.expired' | 'payment_request.cancelled' | 'payment_request.delivery_failed' +``` + +`confirmPayment`, `expirePayment`, `consumePayment` are **internal** to the +service. They are triggered by webhooks / sweepers / product code, not +called from arbitrary callers. + +### Trigger sources, service boundary, events + +Events fire from inside the payment service when a row transitions to a +terminal state. The Xendit webhook is one of *several* triggers; downstream +subscribers don't care which fired the transition. + +```mermaid +flowchart LR + subgraph Triggers["Triggers (cause state transitions)"] + XPaid[Xendit PAID webhook] + XExp[Xendit EXPIRED webhook] + Sweep[Background sweeper
pending past expires_at] + Stub[Dev force-confirm stub
/internal/_test/force-confirm-payment] + Cancel[Customer cancel button] + DelFail[Product service reports
delivery failed
pairing: no mitra · merch: oos · ...] + end + + PS{{Payment Service
requestPayment · confirmPayment ·
expirePayment · cancelPayment ·
markDeliveryFailed · consumePayment}} + + subgraph Events["Events (emitted on terminal transitions)"] + Conf((payment_request.confirmed)) + Exp((payment_request.expired)) + Canc((payment_request.cancelled)) + DelF((payment_request.delivery_failed)) + end + + subgraph Subs["Subscribers"] + Pair[Pairing service
filter productType=chat_session
read productMetadata
start mitra blast] + Ops[Operator workflow
refund / re-credit queue] + Future[Future product services
course, merch, subscription] + end + + XPaid --> PS + Stub --> PS + XExp --> PS + Sweep --> PS + Cancel --> PS + DelFail --> PS + + PS --> Conf + PS --> Exp + PS --> Canc + PS --> DelF + + Conf --> Pair + Conf --> Future + DelF --> Ops + Exp --> Ops +``` + +### Lifecycle of a `payment_request` + +```mermaid +stateDiagram-v2 + [*] --> pending: requestPayment()
row inserted · Xendit invoice created
(no event — caller has the id) + + pending --> confirmed: confirmPayment()
emits payment_request.confirmed + pending --> expired: expirePayment()
emits payment_request.expired + pending --> cancelled: cancelPayment()
emits payment_request.cancelled + + confirmed --> consumed: consumePayment()
(product code on successful delivery)
(no event — terminal success) + confirmed --> failed_delivery: markDeliveryFailed()
emits payment_request.delivery_failed + + consumed --> [*] + expired --> [*] + cancelled --> [*] + failed_delivery --> [*] +``` + +**Notes on the lifecycle:** + +- **No event at `pending` insertion.** The caller of `requestPayment` already + has the row id; nobody else needs to be told. Events exist for *unsolicited* + notifications across the service boundary. +- **No event on `confirmed → consumed`.** Consumption is the product code + saying "I delivered the thing." It's an internal acknowledgement, not a + state others need to react to. (Analytics/ledger can derive this from the + row.) +- **`payment_request.expired` fires the same way whether the trigger was Xendit's + EXPIRED webhook or our background sweeper.** Subscribers see one event; + the trigger source is invisible to them. +- **`failed_delivery` is the renamed `failed_delivery`.** Generalized so + merch can use it for out-of-stock, courses for enrollment-full, etc. + This is the event that drives the operator refund workflow. +- **`payment_request.failed` (gateway-rejected) is intentionally absent.** Xendit + Invoice for VA/QRIS/e-wallet doesn't fail — it either gets paid or + expires. We can add it later if/when a payment method that can decline + (e.g. cards) is enabled. + +### Where Xendit lives in this picture + +Xendit is a payment-internal implementation detail. Only `payment.service.js` +imports `xendit-node`. The webhook route lives under +`/api/shared/payment/webhooks/xendit` so the URL itself signals that the +provider is hidden behind the payment service. + +Swapping a product to a different provider (Midtrans, DOKU) tomorrow is a +one-file change: add `midtrans.adapter.js`, route product-specific +`requestPayment` calls to it. The event names, subscriber code, and DB +schema are unchanged. + +### Event durability — reconciliation pattern (not outbox) + +**The problem.** In-process EventEmitter is fire-and-forget with no +persistence. If the Node process dies between `emit('payment_request.confirmed')` +and the pairing handler completing, the event is lost. Same is true of +valkey pub/sub (the broker forwards messages in memory; nothing is +persisted, AOF doesn't help because pub/sub messages aren't stored in the +keyspace). Naively, this means a stuck customer: payment row says +`confirmed` but nothing started pairing. + +**The decision.** Don't try to make events survive — **make the system +survive without depending on events surviving.** Treat the DB row as the +truth; treat events as performance optimizations that trigger work +immediately when things go right. When things go wrong, re-derive what +should have happened from the DB state via a reconciliation sweeper. + +**Four pieces:** + +1. **EventEmitter as the trigger.** After `confirmPayment()` commits the DB + transaction, fire `payment_request.confirmed`. Subscribers (pairing for + `chat_session`, future product services for their own types) react + immediately on the happy path. + +2. **Subscribers are idempotent.** Every handler starts by checking "is the + work this event represents already done?" before doing anything. For the + pairing subscriber: + ```js + payment.on('payment_request.confirmed', async ({ paymentId, productType }) => { + if (productType !== 'chat_session') return + const existing = await db.query( + 'SELECT id FROM chat_sessions WHERE payment_request_id = $1', [paymentId]) + if (existing.length) return // already pairing or paired, skip + await startPairing(paymentId) + }) + ``` + This makes re-running the handler safe — which is what reconciliation + relies on. + +3. **Reconciliation sweeper extends the existing payment expiry sweeper** + (which already runs every minute in `server.js`). New query, per product: + ```sql + SELECT id, product_type, product_metadata + FROM payment_requests + WHERE status = 'confirmed' + AND confirmed_at < NOW() - INTERVAL '30 seconds' + AND product_type = 'chat_session' + AND NOT EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.payment_request_id = payment_requests.id) + AND NOT EXISTS (SELECT 1 FROM pairing_failures pf WHERE pf.payment_request_id = payment_requests.id) + ``` + For each row returned, call the same handler the lost event would have + triggered. The 30-second buffer avoids racing with happy-path subscribers + that are mid-flight. + +4. **Run reconciliation immediately at startup** (not just on the minute + tick) — catches anything that fell through the cracks during the + restart window. Plus trap SIGTERM to drain in-flight handlers within + Cloud Run's grace period: + ```js + process.on('SIGTERM', async () => { + await app.close() // stop accepting new requests + await new Promise(r => setTimeout(r, 8_000)) // let handlers drain + process.exit(0) + }) + ``` + +**What this gives us:** + +| Scenario | Behavior | +|---|---| +| Happy path | Event fires immediately; pairing starts in ms. Same UX as if EventEmitter were durable. | +| Process dies mid-emit | Event lost; row state intact. Next sweeper tick (≤60s later) re-triggers. Customer waits up to ~1 min longer than normal but doesn't get stuck. | +| Process restart | Startup reconciliation runs immediately; catches everything missed during the restart window. | +| Subscriber crash mid-handler | DB shows "confirmed but no chat session yet"; sweeper re-triggers; subscriber's idempotency check resumes correctly. | +| Multi-instance Cloud Run | Each webhook routed to one instance; subscriber runs there. No cross-instance coordination needed because work itself is idempotent. | + +**What this gives up vs. the outbox pattern:** + +- **Worst-case latency on event loss is ~60s** (sweeper tick interval). Outbox dispatches in seconds. +- **Only works for events with a corresponding DB row state.** Every event we've designed (`payment_request.confirmed`, `.expired`, `.cancelled`, `.delivery_failed`) qualifies because each maps to a `payment_requests.status` value. An event that doesn't persist to a row state wouldn't be recoverable this way. + +**When to graduate to outbox** (a future migration, not a Phase 5 concern): + +- We add subscribers where >30s latency on the worst case is unacceptable (real-time fraud detection, etc.) +- We have multiple subscribers per event where per-subscriber acknowledgement matters (analytics OK to lose, pairing not) +- We extract payment service from the monolith — at which point a persistent queue between services is natural anyway + +**Cost of this approach:** ~80 lines total. Zero new infrastructure, zero new dependencies, zero new tables. Re-uses the sweeper pattern already in `server.js`. + +### Locked decisions (2026-05-23 discussion) + +- **Event naming:** resource-based — `payment_request.confirmed`, + `payment_request.expired`, `payment_request.cancelled`, + `payment_request.delivery_failed`, `payment_request.failed`. Diagrams and + API surface above reflect this. +- **Universal lifecycle:** lock the current `pending → confirmed → consumed | expired | cancelled | failed | failed_delivery` + for all products for now. Revisit when a product emerges that genuinely + needs additional terminal states (e.g. `shipped` for physical goods). + Until then, every product reuses the same enum. +- **Xendit product: stay on Invoice** (D1 unchanged). Briefly considered + switching to Payment Sessions for richer state semantics, but: (a) the + `xendit-node` SDK does not yet support Payment Sessions (we would need + raw HTTP calls), (b) Payment Sessions does not actually have a + session-level FAILED state — FAILED only fires on the underlying + per-attempt `payment.failure` event, weakening the original case for + the switch, and (c) Ramadhan has an existing app already integrated + against Invoice; consistency across products lowers operator burden. + The payment service is product-agnostic, so a future migration to + Payment Sessions remains a one-file change inside `payment.service.js` + if/when the SDK adds support or a product needs subscriptions. +- **Pairing trigger: server-driven.** Pairing service subscribes to + `payment_request.confirmed` and starts the mitra blast immediately when + the event fires. Client app's `POST /api/client/chat/request` call is + removed (or becomes a no-op when called for a payment that already + triggered pairing). Phase 5 scope grows slightly but proves the event + abstraction is load-bearing, and eliminates the race where a network + drop between "customer sees confirmed" and "client calls chat/request" + leaves them stranded. + +### Open threads to settle before rewriting Stages 1–7 + +1. **Stages 1–8 rewriting pass.** The implementation prose still describes + the original chat-coupled, route-handler-direct-calls-Xendit shape. It + needs to be rewritten to incorporate: server-driven pairing subscriber, + payment-service wrapping Xendit (not route handler), product-agnostic + schema (`product_type` + `product_metadata`), event emission + + reconciliation sweeper, renamed identifiers. D1/D5/D8 stay correct + (still Invoice). Mechanical but sizeable. + +--- + +### Out of scope (deferred) + +- Recurring/subscription invoices (we sell discrete sessions) +- Refunds via API (manual via Xendit dashboard for now)v +- Disbursements / mitra payouts (Xendit auto-settles to the business account) +- Multi-currency (IDR only) +- Saved cards / one-click pay +- Webhook event audit table (add later if debugging needs it) + +--- + +## Source-of-truth references + +- Existing payment service: [backend/src/services/payment.service.js](../backend/src/services/payment.service.js) +- Existing payment routes: [backend/src/routes/public/client.payment.routes.js](../backend/src/routes/public/client.payment.routes.js) +- Stub force-confirm: [backend/src/routes/internal/_test.routes.js](../backend/src/routes/internal/_test.routes.js) +- App confirm caller (will change): [client_app/lib/core/chat/session_closure_notifier.dart](../client_app/lib/core/chat/session_closure_notifier.dart) +- Config getter pattern: [backend/src/services/config.service.js](../backend/src/services/config.service.js) +- Public-app route registration: [backend/src/app.public.js](../backend/src/app.public.js) +- Xendit Node SDK: [xendit-node](https://github.com/xendit/xendit-node) +- Invoice API docs: https://docs.xendit.co/invoice +- Webhook docs: https://docs.xendit.co/xenplatform/callbacks + +--- + +## Build Order (8 stages) + +| Stage | Title | Outcome | Blocks on | +|---|---|---|---| +| 0 | Account & credentials prep | Test-mode keys + callback token + payment methods enabled in Xendit dashboard | — | +| 1 | Schema + identifier rename | `payment_requests` table with `product_type`, `product_metadata`, `xendit_*` columns; status enum includes `failed_delivery` + `failed`; FK columns + cause_tag values renamed throughout the codebase | Stage 0 | +| 2 | Payment service: event emitter + Xendit wrapper | `payment.service.js` exposes the public API (`requestPayment`, `confirmPayment`, ..., `on`); Xendit SDK called only from inside it; events emit on terminal transitions | Stage 1 | +| 3 | Webhook receiver | `POST /api/shared/payment/webhooks/xendit` — token verify, route inbound Xendit callbacks to `confirmPayment` / `expirePayment` (which then emit events) | Stage 2 | +| 4 | Server-driven pairing subscriber | Pairing service subscribes to `payment_request.confirmed`; starts mitra blast when `productType === 'chat_session'`; idempotency check inside | Stage 2 | +| 5 | Reconciliation sweeper + graceful shutdown | Extend the existing payment expiry sweeper to also re-trigger lost subscriber work; SIGTERM trap to drain in-flight handlers; startup reconciliation pass | Stage 4 | +| 6 | client_app cutover | Open `invoice_url` via Custom Tab (`LaunchMode.inAppBrowserView`); remove explicit `POST /chat/request` call; remove self-confirm call in `session_closure_notifier.dart` | Stage 2 (backend smoke-testable without this) | +| 7 | Dev infra | Tunnel guide (cloudflared/ngrok), fake-webhook helper script, `.env.example` block | Stage 3 | +| 8 | E2E + hardening | Real test-mode run end-to-end; idempotency / retry / amount-mismatch / SIGTERM / reconciliation tests; checklist | All prior | + +Stages 0–5 are backend-only and curl-smoke-testable. Stage 6 cuts the app over. Stages 7–8 are +verification and polish — none of them block shipping if the test-mode E2E in Stage 8 passes. + +--- + +## Sequence — happy-path payment flow (server-driven, event-based) + +Module interactions from "Customer taps Bayar" to "App advances to searching." The dev / +Maestro flow (`XENDIT_ENABLED=false`) skips the Xendit branch entirely and +`/internal/_test/force-confirm-payment` plays the role of the webhook arm — same +downstream effect because the event emits from the same payment service method either way. + +```mermaid +sequenceDiagram + autonumber + participant C as Customer + participant App as client_app + participant CT as Custom Tab
(Chrome / SFVC) + participant BE as Backend (Fastify) + participant PS as payment.service + participant DB as PostgreSQL + participant X as Xendit + participant Pair as pairing.service + + Note over PS,Pair: Phase 5: payment service owns Xendit;
pairing subscribes to payment_request.confirmed. + + C->>App: Tap Bayar on tier + App->>BE: POST /api/client/payment-requests
{product_type:'chat_session', amount, product_metadata:{...}} + BE->>PS: requestPayment({productType, productMetadata, customerId, amount, ttlMinutes}) + PS->>DB: INSERT payment_requests
(status=pending, product_type, product_metadata, expires_at) + + alt XENDIT_ENABLED=true + PS->>X: Invoice.createInvoice(external_id=row.id, amount, invoice_duration) + X-->>PS: {invoice_id, invoice_url} + PS->>DB: UPDATE xendit_invoice_id, xendit_invoice_url + else XENDIT_ENABLED=false (dev / Maestro) + Note over PS: invoice_url=null · stub will play the webhook role + end + + PS-->>BE: paymentRequest (with invoice_url if Xendit on) + BE-->>App: 201 {id, invoice_url?, expires_at, ...} + + opt invoice_url present + App->>CT: launchUrl(invoice_url, LaunchMode.inAppBrowserView) + CT->>X: GET hosted invoice page + C->>CT: Pick method (BCA / DANA / QRIS), pay + X-->>CT: success_redirect_url landing + C->>CT: Tap Done → app foreground + end + + Note over App: Waiting-payment screen polls every ~2s + loop poll until status changes + App->>BE: GET /api/client/payment-requests/:id + BE-->>App: {status:'pending'} + end + + X-)BE: POST /api/shared/payment/webhooks/xendit
{external_id, status:'PAID', amount, payment_method} + BE->>PS: verifyWebhookToken → confirmPayment(id, xenditMeta) + PS->>DB: UPDATE status=confirmed, confirmed_at=NOW(), stamp xendit_* + PS-)Pair: emit('payment_request.confirmed', {paymentId, productType, productMetadata}) + BE--)X: 200 OK (ACK in <2s; Xendit retries on 5xx / timeout) + + Note over Pair: Subscriber filters productType==='chat_session'
(future merch/course subscribers ignore this event) + Pair->>DB: idempotency check — chat_sessions WHERE payment_request_id=? + Pair->>DB: INSERT chat_sessions (status='searching', payment_request_id, ...) + Pair->>Pair: Start mitra blast (existing Phase 2 logic) + + loop App keeps polling — sees confirmed + chat_session + App->>BE: GET /api/client/payment-requests/:id + BE-->>App: {status:'confirmed', chat_session_id} + Note over App: App switches to pairing-status polling.
No explicit POST /chat/request — server already started it. + end +``` + +### Variations (not drawn) + +- **Async payment (VA / retail outlet):** customer leaves Xendit page after seeing the VA + number; pays at an ATM hours later. The app's polling loop keeps running for + `payment_request_timeout_minutes`. When the customer eventually pays, the webhook arm + fires and unblocks the rest of the flow. +- **EXPIRED webhook:** Xendit fires `status:'EXPIRED'` when `invoice_duration` elapses + unpaid. Handler calls `expirePayment(id)` which mirrors the background sweeper — flips + `pending → expired` and emits `payment_request.expired`. +- **Webhook retry:** if our 200 OK is missed (>30s response or 5xx), Xendit re-fires. + `confirmPayment` throws `INVALID_STATE` on the second delivery (row already confirmed); + the handler swallows that specific error and ACKs. +- **createInvoice failure:** Xendit API errors at request time. `requestPayment` marks + the row `failed` (not `expired` — distinct state for "we never got a valid invoice"), + emits `payment_request.failed`, and returns 502 to the app. +- **Dev / Maestro:** the webhook arm (and the Xendit invoice creation arm) are both + skipped. `POST /internal/_test/force-confirm-payment` calls `payment.confirmPayment(id)` + directly, which fires the same event chain that triggers pairing. Maestro flows keep + working unchanged. +- **Lost event on process restart:** event fired but pairing handler never ran. The + reconciliation sweeper (Stage 5) catches the row on its next tick (≤60s) and re-invokes + the handler. Subscriber idempotency makes this safe. + +--- + +# Stage 0 — Account & credentials prep + +**Owner: Ramadhan.** Engineering can't start Stage 1 without these. + +1. **Xendit account** — register at https://dashboard.xendit.co (test mode is enabled by default; live mode unlocks after biz verification, not blocking Phase 5). +2. **Test mode keys.** Dashboard → Settings → API Keys → Generate. Copy: + - `xnd_development_XXX` (Secret Key — server) + - Public key not needed (we use the hosted-page flow, not in-app SDK) +3. **Callback verification token.** Dashboard → Settings → Callbacks → "Webhook Verification Token". Copy. +4. **Enable initial payment methods.** Dashboard → Settings → Payment Methods. Recommend QRIS + at least one VA (BCA) + one e-wallet (DANA or OVO). Add more later without code changes. +5. **Live-mode docs (Finance / Legal)** — not blocking dev. Required before prod cutover: + - NPWP, KTP direksi, akta, rekening koran + - Settlement bank account + +### `.env` additions (Stage 1 wires these up) + +```bash +# Phase 5 — Xendit +XENDIT_ENABLED=false # flip to true in staging/prod +XENDIT_SECRET_KEY=xnd_development_xxx +XENDIT_WEBHOOK_TOKEN=xxx # dashboard → Settings → Callbacks +XENDIT_SUCCESS_REDIRECT_URL=https://halobestie.com/payment/success +XENDIT_FAILURE_REDIRECT_URL=https://halobestie.com/payment/failure +``` + +Redirect URLs are sent per-invoice (D8 caveat applies only to the webhook URL). They land +the Custom Tab on a "you can close this" page; the app's waiting-payment screen drives the +real UX via polling. + +--- + +# Stage 1 — Schema + identifier rename + +This is the most invasive stage because it touches existing identifiers across backend + +client_app. Doing it once cleanly avoids two rename passes. + +## 1.1 Schema migration + +Append to [migrate.js](../backend/src/db/migrate.js) as a new "Phase 5" block. Steps, +all idempotent: + +```sql +-- 1.1.1 Rename the table (if not already renamed on this DB) +DO $$ +BEGIN + IF to_regclass('payment_sessions') IS NOT NULL AND to_regclass('payment_requests') IS NULL THEN + ALTER TABLE payment_sessions RENAME TO payment_requests; + END IF; +END $$; + +-- 1.1.2 Rename indexes that Postgres auto-named after the table +ALTER INDEX IF EXISTS idx_payment_sessions_customer RENAME TO idx_payment_requests_customer; +ALTER INDEX IF EXISTS idx_payment_sessions_status_expires RENAME TO idx_payment_requests_status_expires; + +-- 1.1.3 Rename FK columns on dependent tables +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='chat_sessions' AND column_name='payment_session_id') THEN + ALTER TABLE chat_sessions RENAME COLUMN payment_session_id TO payment_request_id; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='session_extensions' AND column_name='payment_session_id') THEN + ALTER TABLE session_extensions RENAME COLUMN payment_session_id TO payment_request_id; + END IF; + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='pairing_failures' AND column_name='payment_session_id') THEN + ALTER TABLE pairing_failures RENAME COLUMN payment_session_id TO payment_request_id; + END IF; +END $$; + +ALTER INDEX IF EXISTS idx_chat_sessions_payment RENAME TO idx_chat_sessions_payment_request; + +-- 1.1.4 Rename the config key +UPDATE app_config + SET key='payment_request_timeout_minutes' + WHERE key='payment_session_timeout_minutes' + AND NOT EXISTS (SELECT 1 FROM app_config WHERE key='payment_request_timeout_minutes'); + +-- 1.1.5 Rewrite the status CHECK constraint: +-- - rename 'failed_pairing' value to 'failed_delivery' +-- - add 'failed' (createInvoice errored before customer paid) +ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_sessions_status_check; +ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_requests_status_check; +UPDATE payment_requests SET status='failed_delivery' WHERE status='failed_pairing'; +ALTER TABLE payment_requests ADD CONSTRAINT payment_requests_status_check + CHECK (status IN ('pending','confirmed','consumed','expired','abandoned','failed','failed_delivery')); + +-- 1.1.6 Rewrite the cause_tag CHECK on pairing_failures +ALTER TABLE pairing_failures DROP CONSTRAINT IF EXISTS pairing_failures_cause_tag_check; +UPDATE pairing_failures SET cause_tag='payment_request_expired' WHERE cause_tag='payment_session_expired'; +ALTER TABLE pairing_failures ADD CONSTRAINT pairing_failures_cause_tag_check + CHECK (cause_tag IN ( + 'no_mitra_available','all_mitras_rejected','targeted_mitra_offline','targeted_mitra_rejected', + 'targeted_mitra_timeout','payment_request_expired','customer_cancelled', + 'extension_rejected','extension_safeguard_tripped' + )); + +-- 1.1.7 New columns: product_type, product_metadata (microservice-prep), xendit_* +ALTER TABLE payment_requests + ADD COLUMN IF NOT EXISTS product_type TEXT NOT NULL DEFAULT 'chat_session', + ADD COLUMN IF NOT EXISTS product_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS xendit_invoice_id TEXT, + ADD COLUMN IF NOT EXISTS xendit_invoice_url TEXT, + ADD COLUMN IF NOT EXISTS xendit_payment_method TEXT, + ADD COLUMN IF NOT EXISTS xendit_paid_amount INTEGER; + +-- 1.1.8 Partial unique index — webhook retries land on the same invoice_id; this +-- makes "already processed" detectable via a constraint violation. +CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_requests_xendit_invoice + ON payment_requests(xendit_invoice_id) WHERE xendit_invoice_id IS NOT NULL; +``` + +Also update the original `CREATE TABLE payment_sessions` block higher up in migrate.js +to use the new table name directly — that way a fresh DB skips the rename and the rename +block becomes a no-op on next run. + +## 1.2 Backfill `product_metadata` for existing rows + +```sql +-- Stamp product_metadata for any pre-Phase-5 rows so subscribers can read it uniformly +UPDATE payment_requests + SET product_metadata = jsonb_build_object( + 'duration_minutes', duration_minutes, + 'mode', mode, + 'is_extension', is_extension, + 'targeted_mitra_id', targeted_mitra_id + ) + WHERE product_metadata = '{}'::jsonb AND product_type = 'chat_session'; +``` + +## 1.3 Code renames + +A mechanical pass across backend + client_app. Use the table from the [Renames vs. the +original plan](#renames-vs-the-original-plan) section above as the canonical list. Where +multiple identifiers collide (e.g. variable `paymentSessionId` and parameter `payment_session_id`), +do them by length descending so substring shadowing doesn't happen. + +Files affected (non-exhaustive — grep `payment_session` / `PaymentSession` / `createPaymentSession` +across the repo): + +- `backend/src/services/payment.service.js` (function exports + internals) +- `backend/src/services/pairing-failure.service.js` (cause_tag value) +- `backend/src/services/pairing.service.js` (FK column reads) +- `backend/src/services/config.service.js` (config key + getter name) +- `backend/src/services/chat.service.js` (session-end refund path) +- `backend/src/routes/public/client.payment.routes.js` (route URL prefix + function calls) +- `backend/src/routes/public/client.chat.routes.js` (FK reads) +- `backend/src/routes/internal/*.routes.js` (test stub references) +- `backend/test/**/*.test.js` (test fixtures + assertions) +- `client_app/lib/core/payment/payment_repository.dart` (URL + model field names) +- `client_app/lib/core/payment/payment_request_model.dart` (or wherever the model lives) +- `client_app/lib/features/payment/*.dart` (BLoC state + UI string references) +- `control_center/src/pages/PaymentRequestsPage.tsx` (if it exists; otherwise skip) + +After this stage, the codebase consistently uses `payment_request` everywhere. The +following stages assume this rename is complete. + +--- + +# Stage 2 — Payment service: event emitter + Xendit wrapper + +`payment.service.js` becomes the single owner of the payment domain. After this stage: +no other file imports `xendit-node`, no other file SELECTs/UPDATEs `payment_requests` +directly, and the public API matches the contract documented in the architecture section +above. + +## 2.1 Install Xendit SDK + +```bash +cd backend && npm i xendit-node@^7 +``` + +(Pin major; check `npm view xendit-node version` before installing.) + +## 2.2 Env getter + boot validation + +Append to [config.service.js](../backend/src/services/config.service.js): + +```js +export const getXenditConfig = () => ({ + enabled: process.env.XENDIT_ENABLED === 'true', + secretKey: process.env.XENDIT_SECRET_KEY ?? '', + webhookToken: process.env.XENDIT_WEBHOOK_TOKEN ?? '', + successRedirectUrl: process.env.XENDIT_SUCCESS_REDIRECT_URL ?? '', + failureRedirectUrl: process.env.XENDIT_FAILURE_REDIRECT_URL ?? '', +}) +``` + +Boot validation in [server.js](../backend/src/server.js), before `initFirebase()`: + +```js +const xc = getXenditConfig() +if (xc.enabled) { + if (!xc.secretKey) throw new Error('XENDIT_ENABLED=true requires XENDIT_SECRET_KEY') + if (!xc.webhookToken || xc.webhookToken.length < 16) { + throw new Error('XENDIT_ENABLED=true requires XENDIT_WEBHOOK_TOKEN (>= 16 chars)') + } +} +``` + +## 2.3 EventEmitter inside `payment.service.js` + +```js +import { EventEmitter } from 'node:events' + +const emitter = new EventEmitter() +// Allow more listeners than the default 10 in case multiple products subscribe to the same event +emitter.setMaxListeners(50) + +// Public API: subscribers call this at app startup +export const on = (eventName, handler) => { + emitter.on(eventName, (payload) => { + // Wrap every handler so a throwing subscriber doesn't crash the process + // and so handlers run as fire-and-forget (don't block emit() returning) + Promise.resolve() + .then(() => handler(payload)) + .catch((err) => console.error(`[payment event ${eventName}] handler failed`, err)) + }) +} + +// Internal — called by the state-transition functions below +const emit = (eventName, payload) => emitter.emit(eventName, payload) +``` + +Events fire from inside `confirmPayment` / `expirePayment` / `cancelPayment` / +`markDeliveryFailed` AFTER the DB commit succeeds. Event payload shape: + +```js +{ + paymentRequestId: string, // UUID + productType: string, // 'chat_session', etc + productMetadata: object, // the JSONB blob — whatever the product stamped + amount: number, // IDR rupiah + customerId: string, // UUID + // confirmed-only: + xenditInvoiceId?: string, + xenditPaymentMethod?: string, +} +``` + +## 2.4 Public API rewrite + +Rewrite [payment.service.js](../backend/src/services/payment.service.js) so the exports +exactly match the contract: + +```js +// PUBLIC — these are the only functions other code may import. + +export const requestPayment = async ({ + productType, + productMetadata, + customerId, + amount, + ttlMinutes, + // chat-specific carry-throughs that stay top-level for now (legacy schema): + durationMinutes, + mode, + isFirstSessionDiscount, + isExtension, + targetedMitraId, +}) => { + // 1. INSERT row (status=pending) + const row = await sql` + INSERT INTO payment_requests ( + customer_id, amount, duration_minutes, mode, + is_first_session_discount, is_extension, targeted_mitra_id, + product_type, product_metadata, status, expires_at + ) VALUES ( + ${customerId}, ${amount}, ${durationMinutes}, ${mode}, + ${isFirstSessionDiscount}, ${isExtension}, ${targetedMitraId}, + ${productType}, ${sql.json(productMetadata)}, + ${PaymentRequestStatus.PENDING}, + NOW() + (${ttlMinutes} || ' minutes')::interval + ) + RETURNING * + `.then(r => r[0]) + + // 2. If Xendit on, create the invoice + stamp the row + const xc = getXenditConfig() + if (xc.enabled) { + try { + const { invoiceId, invoiceUrl } = await createXenditInvoice({ + paymentRequestId: row.id, + amount: row.amount, + ttlMinutes, + description: buildDescriptionFor(row), + }) + await sql` + UPDATE payment_requests + SET xendit_invoice_id = ${invoiceId}, xendit_invoice_url = ${invoiceUrl} + WHERE id = ${row.id} + ` + row.xendit_invoice_id = invoiceId + row.xendit_invoice_url = invoiceUrl + } catch (err) { + // Distinct terminal state — "we never got an invoice." NOT expired. + await sql` + UPDATE payment_requests + SET status = ${PaymentRequestStatus.FAILED} + WHERE id = ${row.id} AND status = ${PaymentRequestStatus.PENDING} + ` + emit('payment_request.failed', toEventPayload(row)) + throw Object.assign(new Error('Payment provider error'), { + code: 'PAYMENT_PROVIDER_ERROR', + statusCode: 502, + cause: err, + }) + } + } + + return row +} + +export const confirmPayment = async (paymentRequestId, xenditMeta = {}) => { + // State transition pending → confirmed (idempotency: throws INVALID_STATE if not pending) + const row = await transitionPending(paymentRequestId, PaymentRequestStatus.CONFIRMED, { confirmed_at: 'NOW()' }) + if (xenditMeta.invoiceId || xenditMeta.paymentMethod || xenditMeta.amount) { + await sql` + UPDATE payment_requests + SET xendit_invoice_id = COALESCE(${xenditMeta.invoiceId ?? null}, xendit_invoice_id), + xendit_payment_method = ${xenditMeta.paymentMethod ?? null}, + xendit_paid_amount = ${xenditMeta.amount ?? null} + WHERE id = ${paymentRequestId} + ` + } + emit('payment_request.confirmed', toEventPayload(row)) + return row +} + +export const expirePayment = async (paymentRequestId) => { + const row = await transitionPending(paymentRequestId, PaymentRequestStatus.EXPIRED) + if (row) emit('payment_request.expired', toEventPayload(row)) + return row +} + +export const cancelPayment = async (paymentRequestId, customerId) => { + // Customer-initiated; check ownership + const row = await transitionPendingWithOwner(paymentRequestId, customerId, PaymentRequestStatus.ABANDONED) + emit('payment_request.cancelled', toEventPayload(row)) + return row +} + +export const markDeliveryFailed = async (paymentRequestId, causeTag) => { + // Confirmed → failed_delivery; called by product code (pairing, future merch service) + const row = await transitionConfirmed(paymentRequestId, PaymentRequestStatus.FAILED_DELIVERY) + await recordFailure({ paymentRequestId, causeTag, ... }) + emit('payment_request.delivery_failed', { ...toEventPayload(row), causeTag }) + return row +} + +export const consumePayment = async (paymentRequestId) => { + // Confirmed → consumed; called by product code when delivery succeeds + // No event — terminal success, nothing else needs to react + return transitionConfirmed(paymentRequestId, PaymentRequestStatus.CONSUMED, { consumed_at: 'NOW()' }) +} + +export const getPayment = async (id) => { + const [row] = await sql`SELECT * FROM payment_requests WHERE id = ${id}` + return row ?? null +} + +export { on } // event subscription +``` + +## 2.5 Internal Xendit wrapper + +Private helpers inside `payment.service.js` (do NOT export): + +```js +import { Xendit } from 'xendit-node' + +let _client = null +const xenditClient = () => { + if (_client) return _client + _client = new Xendit({ secretKey: getXenditConfig().secretKey }) + return _client +} + +const createXenditInvoice = async ({ paymentRequestId, amount, ttlMinutes, description }) => { + const inv = await xenditClient().Invoice.createInvoice({ + data: { + externalId: paymentRequestId, + amount, + description, + invoiceDuration: Math.floor(ttlMinutes * 60), + currency: 'IDR', + successRedirectUrl: getXenditConfig().successRedirectUrl || undefined, + failureRedirectUrl: getXenditConfig().failureRedirectUrl || undefined, + // paymentMethods: null → honor dashboard config + }, + }) + return { invoiceId: inv.id, invoiceUrl: inv.invoiceUrl } +} + +export const verifyWebhookToken = (headerToken) => { + const { webhookToken } = getXenditConfig() + if (!headerToken || !webhookToken) return false + if (typeof headerToken !== 'string') return false + if (headerToken.length !== webhookToken.length) return false + let mismatch = 0 + for (let i = 0; i < headerToken.length; i++) { + mismatch |= headerToken.charCodeAt(i) ^ webhookToken.charCodeAt(i) + } + return mismatch === 0 +} +``` + +`verifyWebhookToken` is exported because the webhook route (Stage 3) calls it. It's the +one Xendit-aware helper the route layer is allowed to use. + +## 2.6 Gate `POST /payment-requests/:id/confirm` on `XENDIT_ENABLED` + +In [client.payment.routes.js](../backend/src/routes/public/client.payment.routes.js): + +```js +app.post('/:id/confirm', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { + if (getXenditConfig().enabled) { + return reply.code(403).send({ + success: false, + error: { code: 'FORBIDDEN', message: 'Confirmation must come from Xendit webhook' }, + }) + } + const row = await confirmPayment(request.params.id, {}) + return reply.send({ success: true, data: { id: row.id, status: row.status, confirmed_at: row.confirmed_at } }) +}) +``` + +`/internal/_test/force-confirm-payment` (in `_test.routes.js`) keeps using +`confirmPayment` directly — it lives on the internal listener and bypasses the gate. The +test stub call should be updated to use the new `confirmPayment` signature (id, {}). + +--- + +# Stage 3 — Webhook receiver + +## 3.1 New route file + +Create `backend/src/routes/public/shared.payment-webhooks.routes.js`: + +```js +import { verifyWebhookToken, confirmPayment, expirePayment, getPayment } from '../../services/payment.service.js' + +export const paymentWebhookRoutes = async (app) => { + app.post('/webhooks/xendit', async (request, reply) => { + const token = request.headers['x-callback-token'] + if (!verifyWebhookToken(token)) { + request.log.warn('xendit webhook: bad token') + return reply.code(401).send({ error: 'invalid_token' }) + } + + const { id: invoiceId, external_id: paymentRequestId, status, amount, payment_method } = request.body ?? {} + request.log.info({ paymentRequestId, invoiceId, status, amount }, 'xendit webhook received') + + if (!paymentRequestId) { + return reply.send({ ok: true, ignored: 'no_external_id' }) // forward-compat + } + + const existing = await getPayment(paymentRequestId) + if (!existing) { + request.log.warn({ paymentRequestId, invoiceId }, 'unknown payment_request — ACKing so Xendit stops retrying') + return reply.send({ ok: true, ignored: 'unknown_payment_request' }) + } + + if (status === 'PAID') { + if (typeof amount === 'number' && amount !== existing.amount) { + request.log.error({ paymentRequestId, expected: existing.amount, got: amount }, 'amount mismatch') + return reply.code(409).send({ error: 'amount_mismatch' }) + } + try { + await confirmPayment(paymentRequestId, { invoiceId, paymentMethod: payment_method, amount }) + } catch (err) { + if (err.code === 'INVALID_STATE' || err.code === 'CONFLICT') { + request.log.info({ paymentRequestId, code: err.code, prevStatus: existing.status }, 'already terminal, ACKing') + } else if (err.code === 'EXPIRED') { + request.log.error({ paymentRequestId, expiredAt: existing.expires_at }, 'PAID after expiry — manual recovery needed') + } else { + throw err + } + } + return reply.send({ ok: true }) + } + + if (status === 'EXPIRED') { + await expirePayment(paymentRequestId) + return reply.send({ ok: true }) + } + + return reply.send({ ok: true, ignored: status }) // forward-compat for future Xendit event types + }) +} +``` + +## 3.2 Register the route + +In [app.public.js](../backend/src/app.public.js): + +```js +import { paymentWebhookRoutes } from './routes/public/shared.payment-webhooks.routes.js' +// ... +app.register(paymentWebhookRoutes, { prefix: '/api/shared/payment' }) +``` + +Final URL: `POST /api/shared/payment/webhooks/xendit`. The path signals that Xendit is a +specific provider behind the payment service — a future Midtrans webhook would land at +`/api/shared/payment/webhooks/midtrans`. + +## 3.3 Test coverage + +Vitest covering: bad token (401), happy PAID (200 + row confirmed + xendit cols stamped), +amount mismatch (409), retry idempotency (second PAID with same invoice → 200 ACK, +row stays confirmed), EXPIRED, unknown payment_request (200 ignored), unhandled status +(200 ignored). See `test/routes/shared.payment-webhooks.routes.test.js`. + +--- + +# Stage 4 — Server-driven pairing subscriber + +## 4.1 Restructure pairing entry point + +Pull the existing chat-blast logic into a function that takes only the payment row + its +metadata. In [pairing.service.js](../backend/src/services/pairing.service.js): + +```js +export const startPairingFromPaymentRequest = async ({ paymentRequestId, productMetadata, customerId }) => { + // Idempotency — safe re-run from reconciliation sweeper or webhook retry + const [existing] = await sql` + SELECT id FROM chat_sessions WHERE payment_request_id = ${paymentRequestId} + ` + if (existing) return existing + + // Build chat_sessions row using product_metadata (no longer reading from payment_requests's + // legacy chat-specific columns — eventually those move into product_metadata too) + const { duration_minutes, mode, targeted_mitra_id, is_extension } = productMetadata + + const [chatSession] = await sql` + INSERT INTO chat_sessions ( + customer_id, status, duration_minutes, mode, + payment_request_id, is_first_session_discount + ) VALUES ( + ${customerId}, 'searching', ${duration_minutes}, ${mode}, + ${paymentRequestId}, ... + ) + RETURNING * + ` + + // Existing blast logic (Phase 2) + await blastToAvailableMitras({ chatSessionId: chatSession.id, targetedMitraId: targeted_mitra_id }) + + return chatSession +} +``` + +## 4.2 Register the subscriber at app startup + +In [server.js](../backend/src/server.js), after building both apps and before +`listen()` (or in a dedicated subscriber-registration module called from there): + +```js +import * as payment from './services/payment.service.js' +import { startPairingFromPaymentRequest } from './services/pairing.service.js' + +payment.on('payment_request.confirmed', async (evt) => { + if (evt.productType !== 'chat_session') return // future merch/course handlers filter their own + await startPairingFromPaymentRequest({ + paymentRequestId: evt.paymentRequestId, + productMetadata: evt.productMetadata, + customerId: evt.customerId, + }) +}) +``` + +## 4.3 Remove the explicit chat-request call (backend side) + +`POST /api/client/chat/request` becomes a no-op when the payment row already has a +chat_session — so legacy clients (if any) calling it get a 200 with the existing chat +session id. After client_app cutover (Stage 6) the endpoint can be removed entirely +(follow-up phase, not Phase 5 scope). + +## 4.4 Confirm extension flow + +The extension flow (`Lanjut Curhat`) also creates a payment_request; same subscriber +handles it via a productMetadata flag (`is_extension: true`). The handler branches to +the extension logic instead of starting a fresh blast. Verify the existing extension +service code path is callable from this subscriber. + +--- + +# Stage 5 — Reconciliation sweeper + graceful shutdown + +## 5.1 Extend the existing payment sweeper + +In [payment.service.js](../backend/src/services/payment.service.js), update +`expireStalePaymentRequests` (renamed from `expireStalePaymentSessions`) to also dispatch +lost subscriber work: + +```js +export const expireStalePaymentRequests = async () => { + // ... existing expire pending past expires_at logic ... + // ... existing confirmed-but-stale → failed_delivery logic ... + + // NEW: reconciliation — confirmed payments that never started their product work + const orphaned = await sql` + SELECT id, customer_id, product_type, product_metadata, amount + FROM payment_requests + WHERE status = 'confirmed' + AND confirmed_at < NOW() - INTERVAL '30 seconds' + AND product_type = 'chat_session' + AND NOT EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.payment_request_id = payment_requests.id) + AND NOT EXISTS (SELECT 1 FROM pairing_failures pf WHERE pf.payment_request_id = payment_requests.id) + LIMIT 100 -- bound per-tick work + ` + + for (const row of orphaned) { + try { + await startPairingFromPaymentRequest({ + paymentRequestId: row.id, + productMetadata: row.product_metadata, + customerId: row.customer_id, + }) + console.log(`[reconciler] re-triggered pairing for ${row.id}`) + } catch (err) { + console.error(`[reconciler] failed to re-trigger ${row.id}`, err) + } + } + return { ...existingCounts, reconciled: orphaned.length } +} +``` + +The 30-second buffer avoids racing with happy-path subscribers that are mid-flight. + +## 5.2 Run reconciliation at startup + +In `server.js`, after `restoreActiveTimers()`: + +```js +// Catch anything missed during a restart window +await expireStalePaymentRequests().catch(err => console.error('[reconciler] startup pass failed', err)) +``` + +## 5.3 SIGTERM trap + +In `server.js`, before `start().catch(...)`: + +```js +const shutdown = async () => { + console.log('SIGTERM received — closing servers, draining handlers') + await Promise.allSettled([publicApp.close(), internalApp.close()]) + // Give EventEmitter handlers up to 8s to finish (Cloud Run grace ~10s) + await new Promise(r => setTimeout(r, 8_000)) + process.exit(0) +} +process.on('SIGTERM', shutdown) +process.on('SIGINT', shutdown) +``` + +--- + +# Stage 6 — client_app cutover + +## 6.1 Read `invoice_url` from response + +In [payment_repository.dart](../client_app/lib/core/payment/payment_repository.dart), +parse `data.invoice_url` from the `POST /payment-requests` response and add it to the +payment-request model. + +## 6.2 Open Custom Tab on receipt + +In the screen that immediately follows payment creation (waiting-payment screen): + +```dart +import 'package:url_launcher/url_launcher.dart'; + +if (paymentRequest.invoiceUrl != null) { + await launchUrl( + Uri.parse(paymentRequest.invoiceUrl!), + mode: LaunchMode.inAppBrowserView, // Custom Tab on Android, SFVC on iOS + ); +} +// Either way (Xendit live or stub), show waiting-payment UI and poll. +``` + +When `invoice_url` is null (dev / `XENDIT_ENABLED=false`), the screen sits exactly as it +does today — the `force-confirm-payment` stub flips the row and polling picks it up. + +Verify that `url_launcher` is already a dependency (it is, used elsewhere in the app). + +## 6.3 Remove the explicit chat-request call + +After polling sees `status=confirmed`, the app should NOT call `POST /chat/request` anymore +— the server-side subscriber has already started pairing. Instead: + +- Find the call in the post-confirmed flow (currently in PaymentBloc or equivalent) +- Replace with a poll on the chat-session status (extend the existing polling to also + return `chat_session_id` when available, or add a separate endpoint) + +## 6.4 Remove the self-confirm call + +In [session_closure_notifier.dart:75](../client_app/lib/core/chat/session_closure_notifier.dart#L75) +the app currently calls `POST /payment-requests/:id/confirm` during chat-closure rebill. +With Xendit on, this returns 403. Remove the call; rely on polling. + +## 6.5 Optional: success-page deep-link return + +Out of Phase 5 scope. Today the user taps Done on the Custom Tab to come back; later we +could register a `halobestie://payment/return` URL scheme and pass it as +`successRedirectUrl` so they bounce back automatically. + +--- + +# Stage 7 — Dev infra + +## 7.1 Tunnel for webhook reachability + +Pick one: + +1. **cloudflared** (recommended — free, can be made static): + ```bash + cloudflared tunnel --url http://localhost:3000 + ``` + Pin the URL via a named tunnel + DNS record in the Cloudflare dashboard so it survives + restarts. + +2. **ngrok** — fastest to start; free tier rotates URL on restart: + ```bash + ngrok http 3000 + ``` + +Register the tunnel URL in Xendit Test Mode → Settings → Callbacks → Invoice Paid / +Invoice Expired: +``` +https:///api/shared/payment/webhooks/xendit +``` + +Document this in [backend/CLAUDE.md](../backend/CLAUDE.md) under a new "Phase 5 dev setup" +section so the next person doesn't have to re-derive it. + +## 7.2 Fake-webhook helper script + +`backend/.dev/xendit-fake-webhook.sh`: + +```bash +#!/usr/bin/env bash +# Fire a fake Xendit Invoice callback at the local backend. +# Usage: ./xendit-fake-webhook.sh [PAID|EXPIRED] [amount] +set -euo pipefail +PAYMENT_ID="${1:?usage}" +STATUS="${2:-PAID}" +AMOUNT="${3:-50000}" +TOKEN="${XENDIT_WEBHOOK_TOKEN:?XENDIT_WEBHOOK_TOKEN env not set}" +BASE_URL="${BASE_URL:-http://localhost:3000}" + +curl -sS -X POST "${BASE_URL}/api/shared/payment/webhooks/xendit" \ + -H "x-callback-token: ${TOKEN}" \ + -H "content-type: application/json" \ + -d "{ + \"id\": \"inv_fake_$(date +%s)_${RANDOM}\", + \"external_id\": \"${PAYMENT_ID}\", + \"status\": \"${STATUS}\", + \"amount\": ${AMOUNT}, + \"payment_method\": \"BCA\" + }" | jq . 2>/dev/null || cat +echo +``` + +Lets developers exercise the webhook handler without ngrok / Xendit. NOT a Maestro +replacement — Maestro continues to use `/internal/_test/force-confirm-payment`. + +## 7.3 `.env.example` block + +Append to `backend/.env.example`: + +```bash +# Phase 5 — Xendit (dev-safe defaults: integration disabled) +XENDIT_ENABLED=false +XENDIT_SECRET_KEY= +XENDIT_WEBHOOK_TOKEN= +XENDIT_SUCCESS_REDIRECT_URL= +XENDIT_FAILURE_REDIRECT_URL= +``` + +--- + +# Stage 8 — E2E verification + hardening checklist + +## 8.1 End-to-end smoke test (run once before declaring Phase 5 done) + +1. `XENDIT_ENABLED=true` + real test-mode keys + cloudflared tunnel up + dashboard webhook + URL registered. +2. Launch client_app pointed at local backend (`API_BASE_URL=http://192.168.88.247:3000` + per [memory feedback_flutter_run_api_base_url]). +3. Onboard fresh customer → "Mulai Curhat" → pick tier → tap pay. +4. Verify: backend logs show `createInvoice` succeeded; app opens Xendit URL in Custom Tab. +5. On Xendit's hosted page: pick a method, in Xendit dashboard → Transactions click + "Simulate Payment". +6. Verify: webhook hit in logs; `payment_requests.status='confirmed'`; **server-side + pairing subscriber fired**; `chat_sessions` row created with `payment_request_id` set; + app's polling advances to searching. + +## 8.2 Failure-path tests + +7. **Retry idempotency:** click Simulate Payment twice → second webhook → ACKs without + second-confirm error; row stays confirmed; only one chat_session exists. +8. **Expiry:** create a request, let `invoice_duration` elapse → Xendit fires EXPIRED → + row goes `expired`; `payment_request.expired` event fires (visible in logs). +9. **Amount tamper:** edit DB row to set `amount=1`; simulate PAID with `amount=50000` → + webhook returns 409; row stays unconfirmed. +10. **createInvoice failure:** temporarily set `XENDIT_SECRET_KEY` to a bad value; tap pay + → backend returns 502 with `PAYMENT_PROVIDER_ERROR`; row marked `failed`; + `payment_request.failed` event fired. +11. **Reconciliation:** disable the pairing subscriber temporarily; trigger a successful + payment; verify `payment_requests.status='confirmed'` but no `chat_sessions` row; wait + 60s; verify the sweeper re-triggered pairing and `chat_sessions` row now exists. +12. **SIGTERM drain:** start a payment, hit `force-confirm-payment` to fire the event; + immediately `kill -TERM `; verify the pairing handler completes before process + exits (chat_sessions row created); restart and verify nothing duplicated. + +Record results in `phase5-test-run-.md` following the +[phase3.7-test-run-2026-05-03.md](phase3.7-test-run-2026-05-03.md) template. + +## 8.3 Pre-ship hardening checklist + +1. **Webhook ACKs in <2s.** Xendit retries on 5xx / timeouts (>30s). Confirm by tailing + logs during a Simulate Payment burst. +2. **Idempotent confirm under race.** Two webhook deliveries within 100ms → second one + hits `INVALID_STATE` → swallowed → ACK. Unit test in `test/services/payment.service.test.js`. +3. **Unknown payment_request webhook.** ACK with `{ignored: 'unknown_payment_request'}` + so Xendit stops retrying orphan webhooks. Already in handler. +4. **Webhook ordering — PAID before our row knows its `xendit_invoice_id`.** QRIS can + confirm in <500ms. Handler looks up the row by `external_id` (= our UUID), not by + `xendit_invoice_id`, so order doesn't matter. The stamping UPDATE inside `confirmPayment` + uses the invoice id from the webhook body. +5. **PAID arriving after expiry sweeper.** D5 (`invoice_duration === payment_request_timeout_minutes`) + keeps these aligned. The handler's `EXPIRED` error catch logs at `error` level so we + notice if it ever fires in production. +6. **Boot-time validation.** Already in Stage 2.2. Tested by setting `XENDIT_ENABLED=true` + with empty `XENDIT_SECRET_KEY` and asserting boot fails. +7. **Log volume in prod.** Webhook entries at `info`; ignored/unknown at `warn`; mismatch + / EXPIRED-after-confirm at `error`. The high-volume health-check path stays at `info`. +8. **Refund process documented.** When `payment_request.delivery_failed` fires, operator + sees the row in CC, manually refunds via Xendit dashboard, marks `operator_action='refunded'` + in CC. No automated refund in Phase 5 — doc only. + +--- + +## Effort estimate + +| Stage | Effort | Notes | +|---|---|---| +| 0 | 30 min | Ramadhan, account + dashboard setup | +| 1 | 3–4 hrs | Migration + identifier rename across backend + client_app + tests | +| 2 | 3–4 hrs | Payment service rewrite (event emitter + Xendit wrap + state-transition refactor + tests) | +| 3 | 1–2 hrs | Webhook receiver + tests | +| 4 | 1–2 hrs | Pairing subscriber + entry-point refactor | +| 5 | 1 hr | Sweeper extension + SIGTERM + startup pass | +| 6 | 1–2 hrs | client_app cutover (Custom Tab + remove chat-request + remove self-confirm) | +| 7 | 1 hr | Tunnel setup + helper script + .env.example | +| 8 | 2–3 hrs | Real E2E + 6 failure-path tests + hardening checklist | + +**Total: ~2–2.5 days of focused work.** Stage 1 is the biggest because the rename touches +the most files. Stages 2–5 are the architectural core. Stages 6–8 are the user-facing + +ops polish.