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

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

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

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

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

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

View File

@@ -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 <payment_request_id> [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 <payment_request_id> [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

View File

@@ -40,3 +40,15 @@ ADMIN_PASSWORD=ChangeMe123!
# --- FCM (kept — only Messaging is used; Auth is self-managed) --- # --- FCM (kept — only Messaging is used; Auth is self-managed) ---
# Path to Firebase service-account JSON (falls back to backend/firebase-service-account.json) # Path to Firebase service-account JSON (falls back to backend/firebase-service-account.json)
FIREBASE_SERVICE_ACCOUNT_PATH= 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=

View File

@@ -22,6 +22,7 @@
"jwks-rsa": "^3.2.2", "jwks-rsa": "^3.2.2",
"pg": "^8.12.0", "pg": "^8.12.0",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"xendit-node": "^7.0.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "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": { "node_modules/xtend": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -28,6 +28,7 @@
"jwks-rsa": "^3.2.2", "jwks-rsa": "^3.2.2",
"pg": "^8.12.0", "pg": "^8.12.0",
"postgres": "^3.4.4", "postgres": "^3.4.4",
"xendit-node": "^7.0.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -15,6 +15,7 @@ import { publicBestieAvailabilityRoutes } from './routes/public/public.bestie-av
import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js' import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js'
import { sharedSupportRoutes } from './routes/public/shared.support.routes.js' import { sharedSupportRoutes } from './routes/public/shared.support.routes.js'
import { sharedChatRoutes } from './routes/public/shared.chat.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 { errorHandler } from './plugins/error-handler.js'
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.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(mitraStatusRoutes, { prefix: '/api/mitra/status' })
app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' }) app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' })
app.register(clientChatRoutes, { prefix: '/api/client/chat' }) 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(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' }) app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' })
// Onboarding-state stays client-only (anonymous customer flow). Support // Onboarding-state stays client-only (anonymous customer flow). Support
// handles are shared — both client and mitra apps link the same WA/TG. // handles are shared — both client and mitra apps link the same WA/TG.
app.register(clientOnboardingRoutes, { prefix: '/api/client' }) app.register(clientOnboardingRoutes, { prefix: '/api/client' })
app.register(sharedSupportRoutes, { prefix: '/api/shared' }) 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) // WebSocket route (registered at app level, not prefixed)
registerWebSocketRoute(app) registerWebSocketRoute(app)

View File

@@ -62,14 +62,22 @@ export const SessionMode = Object.freeze({
CALL: 'call', CALL: 'call',
}) })
// Payment session lifecycle // payment_requests lifecycle
export const PaymentSessionStatus = Object.freeze({ // 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', PENDING: 'pending',
CONFIRMED: 'confirmed', CONFIRMED: 'confirmed',
CONSUMED: 'consumed', CONSUMED: 'consumed',
FAILED_PAIRING: 'failed_pairing',
ABANDONED: 'abandoned',
EXPIRED: 'expired', EXPIRED: 'expired',
ABANDONED: 'abandoned',
FAILED: 'failed',
FAILED_DELIVERY: 'failed_delivery',
}) })
// Pairing failure cause tags // Pairing failure cause tags
@@ -79,7 +87,7 @@ export const PairingFailureCause = Object.freeze({
TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline', TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline',
TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected', TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected',
TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout', TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout',
PAYMENT_SESSION_EXPIRED: 'payment_session_expired', PAYMENT_REQUEST_EXPIRED: 'payment_request_expired',
CUSTOMER_CANCELLED: 'customer_cancelled', CUSTOMER_CANCELLED: 'customer_cancelled',
EXTENSION_REJECTED: 'extension_rejected', EXTENSION_REJECTED: 'extension_rejected',
EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped', EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped',

View File

@@ -434,9 +434,28 @@ const migrate = async () => {
// --- Phase 3.7: Paid Pairing Flow + Returning-Chat + Extension Flip --- // --- 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` 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(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id), customer_id UUID NOT NULL REFERENCES customers(id),
amount INTEGER NOT NULL DEFAULT 0, amount INTEGER NOT NULL DEFAULT 0,
@@ -444,7 +463,7 @@ const migrate = async () => {
is_free_trial BOOLEAN NOT NULL DEFAULT false, is_free_trial BOOLEAN NOT NULL DEFAULT false,
is_extension BOOLEAN NOT NULL DEFAULT false, is_extension BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'pending' 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), targeted_mitra_id UUID REFERENCES mitras(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
confirmed_at TIMESTAMPTZ, confirmed_at TIMESTAMPTZ,
@@ -454,20 +473,20 @@ const migrate = async () => {
` `
await sql` await sql`
CREATE INDEX IF NOT EXISTS idx_payment_sessions_customer CREATE INDEX IF NOT EXISTS idx_payment_requests_customer
ON payment_sessions (customer_id) ON payment_requests (customer_id)
` `
await sql` await sql`
CREATE INDEX IF NOT EXISTS idx_payment_sessions_status_expires CREATE INDEX IF NOT EXISTS idx_payment_requests_status_expires
ON payment_sessions (status, expires_at) ON payment_requests (status, expires_at)
` `
// pairing_failures: cause-tagged audit rows for confirmed payments that did not yield a chat // pairing_failures: cause-tagged audit rows for confirmed payments that did not yield a chat
await sql` await sql`
CREATE TABLE IF NOT EXISTS pairing_failures ( CREATE TABLE IF NOT EXISTS pairing_failures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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), customer_id UUID NOT NULL REFERENCES customers(id),
targeted_mitra_id UUID REFERENCES mitras(id), targeted_mitra_id UUID REFERENCES mitras(id),
cause_tag TEXT NOT NULL cause_tag TEXT NOT NULL
@@ -477,7 +496,7 @@ const migrate = async () => {
'targeted_mitra_offline', 'targeted_mitra_offline',
'targeted_mitra_rejected', 'targeted_mitra_rejected',
'targeted_mitra_timeout', 'targeted_mitra_timeout',
'payment_session_expired', 'payment_request_expired',
'customer_cancelled' 'customer_cancelled'
)), )),
amount INTEGER NOT NULL, amount INTEGER NOT NULL,
@@ -510,7 +529,8 @@ const migrate = async () => {
'targeted_mitra_offline', 'targeted_mitra_offline',
'targeted_mitra_rejected', 'targeted_mitra_rejected',
'targeted_mitra_timeout', 'targeted_mitra_timeout',
'payment_session_expired', -- Phase 5 rename: payment_session_expired → payment_request_expired
'payment_request_expired',
'customer_cancelled', 'customer_cancelled',
'extension_rejected', 'extension_rejected',
'extension_safeguard_tripped' 'extension_safeguard_tripped'
@@ -534,27 +554,27 @@ const migrate = async () => {
ON pairing_failures (created_at DESC) WHERE operator_action IS NULL 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` await sql`
ALTER TABLE chat_sessions 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` await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment_request
ON chat_sessions (payment_session_id) 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` await sql`
ALTER TABLE session_extensions 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) // Phase 3.7 config keys (idempotent — existing dev DBs need a manual update for extension_timeout_seconds → 10)
await sql` await sql`
INSERT INTO app_config (key, value) VALUES 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}'), ('returning_chat_confirmation_timeout_seconds', '{"value": 20}'),
('extension_default_action_on_timeout', '{"value": "auto_approve"}'), ('extension_default_action_on_timeout', '{"value": "auto_approve"}'),
('pairing_blast_timeout_seconds', '{"value": 60}') ('pairing_blast_timeout_seconds', '{"value": 60}')
@@ -563,13 +583,13 @@ const migrate = async () => {
// --- Phase 4 — Customer Flow Redesign --- // --- 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 // 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. // (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 // Idempotent: ADD/DROP both use IF [NOT] EXISTS, and each UPDATE is gated on the
// old column still existing. // old column still existing.
await sql` await sql`
ALTER TABLE payment_sessions ALTER TABLE payment_requests
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
` `
await sql` await sql`
@@ -590,11 +610,11 @@ const migrate = async () => {
BEGIN BEGIN
IF EXISTS ( IF EXISTS (
SELECT 1 FROM pg_attribute SELECT 1 FROM pg_attribute
WHERE attrelid = to_regclass('payment_sessions') WHERE attrelid = to_regclass('payment_requests')
AND attname = 'is_free_trial' AND attname = 'is_free_trial'
AND NOT attisdropped AND NOT attisdropped
) THEN ) THEN
EXECUTE 'UPDATE payment_sessions EXECUTE 'UPDATE payment_requests
SET is_first_session_discount = is_free_trial SET is_first_session_discount = is_free_trial
WHERE is_free_trial = true WHERE is_free_trial = true
AND is_first_session_discount = false'; 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` 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. // with a different price group + a header badge; no extra media handling.
await sql` await sql`
ALTER TABLE payment_sessions ALTER TABLE payment_requests
ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat' ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
CHECK (mode IN ('chat', 'call')) 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.') console.log('Migration complete.')
await sql.end() await sql.end()
} }

View File

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

View File

@@ -11,7 +11,7 @@ import {
getEarlyEndConfig, setEarlyEndConfig, getEarlyEndConfig, setEarlyEndConfig,
getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds, getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds,
getSensitivityConfig, setSensitivityConfig, getSensitivityConfig, setSensitivityConfig,
getPaymentSessionTimeoutMinutes, setPaymentSessionTimeoutMinutes, getPaymentRequestTimeoutMinutes, setPaymentRequestTimeoutMinutes,
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds, getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout, getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds, getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
@@ -247,21 +247,21 @@ export const internalConfigRoutes = async (app) => {
app.get('/payment-session-timeout', { app.get('/payment-session-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')], preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
}, async (_req, reply) => { }, async (_req, reply) => {
return reply.send({ success: true, data: await getPaymentSessionTimeoutMinutes() }) return reply.send({ success: true, data: await getPaymentRequestTimeoutMinutes() })
}) })
app.patch('/payment-session-timeout', { app.patch('/payment-session-timeout', {
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')], preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
}, async (request, reply) => { }, async (request, reply) => {
const { payment_session_timeout_minutes } = request.body ?? {} const { payment_request_timeout_minutes } = request.body ?? {}
if (typeof payment_session_timeout_minutes !== 'number' || payment_session_timeout_minutes < 1) { if (typeof payment_request_timeout_minutes !== 'number' || payment_request_timeout_minutes < 1) {
return reply.code(422).send({ return reply.code(422).send({
success: false, 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) const config = await setPaymentRequestTimeoutMinutes(payment_request_timeout_minutes)
await publishConfigInvalidate('payment_session_timeout_minutes') await publishConfigInvalidate('payment_request_timeout_minutes')
return reply.send({ success: true, data: config }) return reply.send({ success: true, data: config })
}) })

View File

@@ -47,16 +47,16 @@ export const clientChatRoutes = async (app) => {
/** /**
* Start a general-blast pairing search. * 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. * Pricing/duration/free-trial values are sourced from the payment session, NOT from the client.
*/ */
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { 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({ return reply.code(400).send({
success: false, 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, { const session = await createPairingRequest(request.customer.id, {
paymentSessionId: payment_session_id, paymentRequestId: payment_request_id,
topic_sensitivity, topic_sensitivity,
}) })
return reply.code(201).send({ success: true, data: session }) 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. * 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 * 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 * or at capacity. The payment session stays `confirmed` in that case so the customer
* can fall back to general blast on the same payment. * can fall back to general blast on the same payment.
*/ */
app.post('/chat-requests/returning', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { 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({ return reply.code(400).send({
success: false, 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) { if (!mitra_id) {
@@ -103,7 +103,7 @@ export const clientChatRoutes = async (app) => {
: TopicSensitivity.REGULAR : TopicSensitivity.REGULAR
const session = await createTargetedPairingRequest(request.customer.id, { const session = await createTargetedPairingRequest(request.customer.id, {
paymentSessionId: payment_session_id, paymentRequestId: payment_request_id,
targetedMitraId: mitra_id, targetedMitraId: mitra_id,
topic_sensitivity: resolvedTopic, topic_sensitivity: resolvedTopic,
}) })
@@ -113,26 +113,26 @@ export const clientChatRoutes = async (app) => {
/** /**
* Customer-initiated cancel during searching/waiting. * Customer-initiated cancel during searching/waiting.
* *
* Body: { payment_session_id } * Body: { payment_request_id }
* Terminal — payment session moves to failed_pairing with cause = customer_cancelled. * Terminal — payment session moves to failed_delivery with cause = customer_cancelled.
*/ */
app.post('/chat-requests/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { app.post('/chat-requests/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { payment_session_id } = request.body ?? {} const { payment_request_id } = request.body ?? {}
if (!payment_session_id) { if (!payment_request_id) {
return reply.code(400).send({ return reply.code(400).send({
success: false, 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 }) return reply.send({ success: true, data: result })
}) })
/** /**
* After a returning-chat fail, customer taps "Chat dengan bestie lain". * 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], preHandler: [authenticate, resolveCustomer],
}, async (request, reply) => { }, async (request, reply) => {
const { topic_sensitivity } = request.body ?? {} const { topic_sensitivity } = request.body ?? {}
@@ -140,7 +140,7 @@ export const clientChatRoutes = async (app) => {
? TopicSensitivity.SENSITIVE ? TopicSensitivity.SENSITIVE
: TopicSensitivity.REGULAR : TopicSensitivity.REGULAR
const session = await fallbackToGeneralBlast( const session = await fallbackToGeneralBlast(
request.params.paymentSessionId, request.params.paymentRequestId,
request.customer.id, request.customer.id,
{ topic_sensitivity: resolvedTopic }, { 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 * 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 * 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) => { app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await cancelPairingRequest(request.params.sessionId, request.customer.id) 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. * 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. * Pricing/duration come from the payment session via the extension service.
*/ */
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { 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({ return reply.code(400).send({
success: false, 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) { if (!duration_minutes || price === undefined) {
@@ -196,7 +196,7 @@ export const clientChatRoutes = async (app) => {
const extension = await requestExtension(request.params.sessionId, request.customer.id, { const extension = await requestExtension(request.params.sessionId, request.customer.id, {
duration_minutes, duration_minutes,
price, price,
extension_payment_session_id, extension_payment_request_id,
}) })
return reply.send({ success: true, data: extension }) return reply.send({ success: true, data: extension })
}) })

View File

@@ -1,10 +1,10 @@
import { authenticate } from '../../plugins/auth.js' import { authenticate } from '../../plugins/auth.js'
import { getCustomerById } from '../../services/customer.service.js' import { getCustomerById } from '../../services/customer.service.js'
import { import {
createPaymentSession, requestPayment,
confirmPaymentSession, confirmPaymentForCustomer,
abandonPaymentSession, cancelPayment,
getPaymentSession, getPayment,
getCustomerPendingPayments, getCustomerPendingPayments,
} from '../../services/payment.service.js' } from '../../services/payment.service.js'
import { import {
@@ -13,6 +13,7 @@ import {
findTier, findTier,
readFirstSessionDiscountConfig, readFirstSessionDiscountConfig,
} from '../../services/pricing.service.js' } from '../../services/pricing.service.js'
import { getXenditConfig } from '../../services/config.service.js'
import { UserType, SessionMode } from '../../constants.js' import { UserType, SessionMode } from '../../constants.js'
const resolveCustomer = async (request, reply) => { const resolveCustomer = async (request, reply) => {
@@ -35,10 +36,10 @@ const resolveCustomer = async (request, reply) => {
/** /**
* Payment session lifecycle (mocked — no Xendit yet). * Payment session lifecycle (mocked — no Xendit yet).
* *
* POST /api/client/payment-sessions * POST /api/client/payment-requests
* POST /api/client/payment-sessions/:id/confirm * POST /api/client/payment-requests/:id/confirm
* POST /api/client/payment-sessions/:id/cancel * POST /api/client/payment-requests/:id/cancel
* GET /api/client/payment-sessions/:id * GET /api/client/payment-requests/:id
*/ */
export const clientPaymentRoutes = async (app) => { export const clientPaymentRoutes = async (app) => {
// Create a payment session (status = pending). First-session-discount is server-authoritative: // 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, customerId: request.customer.id,
durationMinutes: duration_minutes, durationMinutes: duration_minutes,
amount, amount,
@@ -125,12 +130,21 @@ export const clientPaymentRoutes = async (app) => {
targeted_mitra_id: session.targeted_mitra_id, targeted_mitra_id: session.targeted_mitra_id,
expires_at: session.expires_at, expires_at: session.expires_at,
status: session.status, status: session.status,
invoice_url: session.xendit_invoice_url ?? null,
}, },
}) })
}) })
app.post('/:id/confirm', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { 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({ return reply.send({
success: true, success: true,
data: { data: {
@@ -142,7 +156,7 @@ export const clientPaymentRoutes = async (app) => {
}) })
app.post('/:id/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { 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({ return reply.send({
success: true, success: true,
data: { data: {
@@ -160,19 +174,34 @@ export const clientPaymentRoutes = async (app) => {
}) })
app.get('/:id', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => { 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) { if (!session) {
return reply.code(404).send({ return reply.code(404).send({
success: false, 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) { if (session.customer_id !== request.customer.id) {
return reply.code(403).send({ return reply.code(403).send({
success: false, 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,
},
})
}) })
} }

View File

@@ -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: <status> }` 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 })
})
}

View File

@@ -4,13 +4,24 @@ import { buildInternalApp } from './app.internal.js'
import { autoOfflineStaleMitras } from './services/mitra-status.service.js' import { autoOfflineStaleMitras } from './services/mitra-status.service.js'
import { initFirebase } from './plugins/firebase.js' import { initFirebase } from './plugins/firebase.js'
import { restoreActiveTimers } from './services/session-timer.service.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 PUBLIC_PORT = process.env.PUBLIC_PORT || 3000
const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001 const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001
const INTERNAL_HOST = process.env.INTERNAL_HOST || '127.0.0.1' const INTERNAL_HOST = process.env.INTERNAL_HOST || '127.0.0.1'
const start = async () => { 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() initFirebase()
const publicApp = await buildPublicApp() const publicApp = await buildPublicApp()
const internalApp = await buildInternalApp() const internalApp = await buildInternalApp()
@@ -24,6 +35,24 @@ const start = async () => {
// Restore session timers for active sessions (on server restart) // Restore session timers for active sessions (on server restart)
await restoreActiveTimers() 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) // Auto-offline mitras with stale heartbeat (every 30s)
setInterval(async () => { setInterval(async () => {
try { try {
@@ -34,22 +63,36 @@ const start = async () => {
} }
}, 30_000) }, 30_000)
// Expire stale payment_sessions (every 60s). // Expire stale payment_requests + reconcile lost subscriber work (every 60s).
// Pending past expires_at → expired (no failure row). Confirmed-but-stale → failed_pairing // Pending past expires_at → expired (no failure row).
// with cause = payment_session_expired (writes a pairing_failures row). // Confirmed-but-stale → failed_delivery (writes a pairing_failures row).
// Single-instance for now; Valkey keyspace notifications when we go multi-instance. // 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 () => { setInterval(async () => {
try { try {
const result = await expireStalePaymentSessions() const result = await expireStalePaymentRequests()
if (result.expired > 0 || result.failed > 0) { if (result.expired > 0 || result.failed > 0 || result.reconciled > 0) {
console.log(`Payment sweeper: ${result.expired} expired, ${result.failed} failed_pairing`) console.log(`Payment sweeper: ${result.expired} expired, ${result.failed} failed_delivery, ${result.reconciled} re-triggered`)
} }
} catch (err) { } catch (err) {
console.error('Payment session sweeper failed:', err) console.error('Payment request sweeper failed:', err)
} }
}, 60_000) }, 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) => { start().catch((err) => {
console.error(err) console.error(err)
process.exit(1) process.exit(1)

View File

@@ -149,6 +149,22 @@ export const getMitraHeartbeatCadenceSeconds = () => {
return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30 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 () => { export const getMitraPingConfig = async () => {
const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'` 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'` 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 --- // --- Paid Pairing Flow + Returning-Chat + Extension Flip ---
export const getPaymentSessionTimeoutMinutes = async () => { export const getPaymentRequestTimeoutMinutes = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'payment_session_timeout_minutes'` const [row] = await sql`SELECT value FROM app_config WHERE key = 'payment_request_timeout_minutes'`
return { payment_session_timeout_minutes: row?.value?.value ?? 20 } return { payment_request_timeout_minutes: row?.value?.value ?? 20 }
} }
export const setPaymentSessionTimeoutMinutes = async (value) => { export const setPaymentRequestTimeoutMinutes = async (value) => {
await sql` await sql`
INSERT INTO app_config (key, value, updated_at) 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() 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 () => { export const getReturningChatConfirmationTimeoutSeconds = async () => {

View File

@@ -14,7 +14,7 @@ import {
ExtensionStatus, ExtensionStatus,
TransactionType, TransactionType,
WsMessage, WsMessage,
PaymentSessionStatus, PaymentRequestStatus,
ExtensionTimeoutAction, ExtensionTimeoutAction,
PairingFailureCause, PairingFailureCause,
} from '../constants.js' } from '../constants.js'
@@ -39,7 +39,7 @@ const getExtensionTimeoutAction = async () => {
/** /**
* Customer requests an extension. * 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 * - belong to this customer
* - be in `confirmed` status (not yet consumed) * - be in `confirmed` status (not yet consumed)
* - have `is_extension = true` * - 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 * The payment session is NOT consumed at request time. It is consumed at approval moment
* (mitra explicit accept OR auto-approve fires). * (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. // Verify session belongs to customer and is in an extendable state.
// customer_display_name is pulled along for the FCM body when the mitra // customer_display_name is pulled along for the FCM body when the mitra
// misses the WS frame. // misses the WS frame.
@@ -65,31 +65,31 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
} }
// Validate extension payment session // Validate extension payment session
if (!extension_payment_session_id) { if (!extension_payment_request_id) {
throw Object.assign(new Error('extension_payment_session_id is required'), { throw Object.assign(new Error('extension_payment_request_id is required'), {
code: 'VALIDATION_ERROR', statusCode: 422, code: 'VALIDATION_ERROR', statusCode: 422,
}) })
} }
const paySession = await getPaymentSession(extension_payment_session_id) const payRequest = await getPaymentSession(extension_payment_request_id)
if (!paySession) { if (!payRequest) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) 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'), { throw Object.assign(new Error('Payment session does not belong to this customer'), {
code: 'FORBIDDEN', statusCode: 403, code: 'FORBIDDEN', statusCode: 403,
}) })
} }
if (paySession.status !== PaymentSessionStatus.CONFIRMED) { if (payRequest.status !== PaymentRequestStatus.CONFIRMED) {
throw Object.assign(new Error(`Payment session is ${paySession.status}, must be confirmed`), { throw Object.assign(new Error(`Payment session is ${payRequest.status}, must be confirmed`), {
code: 'INVALID_STATE', statusCode: 409, 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'), { throw Object.assign(new Error('Payment session is not flagged as an extension payment'), {
code: 'INVALID_STATE', statusCode: 409, 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'), { throw Object.assign(new Error('First-session discount is not available for extensions'), {
code: 'FIRST_SESSION_DISCOUNT_NOT_ALLOWED', statusCode: 400, 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) // Create extension record (linked to its payment session)
const [extension] = await sql` const [extension] = 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 (${sessionId}, ${duration_minutes}, ${price}, ${ExtensionStatus.PENDING}, ${extension_payment_session_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_session_id RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at, payment_request_id
` `
// Pause the session // Pause the session
@@ -182,7 +182,7 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) =
UPDATE session_extensions UPDATE session_extensions
SET status = ${status}, responded_at = NOW() SET status = ${status}, responded_at = NOW()
WHERE id = ${extensionId} AND session_id = ${sessionId} AND status = ${ExtensionStatus.PENDING} 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) { if (!extension) {
@@ -201,8 +201,8 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) =
if (accepted) { if (accepted) {
// Charge fires AT approval moment (explicit OR auto-approve). // Charge fires AT approval moment (explicit OR auto-approve).
if (extension.payment_session_id) { if (extension.payment_request_id) {
await consumePaymentSession(extension.payment_session_id) await consumePaymentSession(extension.payment_request_id)
} }
// Clear any pending grace timer from the previous expiry // 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. // Rejected — no charge. Fail the extension payment session if present.
// viaTimeout=false here means an explicit mitra reject (the timer path goes through // viaTimeout=false here means an explicit mitra reject (the timer path goes through
// timeoutExtension which never enters this branch with viaTimeout=true for reject). // timeoutExtension which never enters this branch with viaTimeout=true for reject).
if (extension.payment_session_id) { if (extension.payment_request_id) {
await failPaymentSession(extension.payment_session_id, PairingFailureCause.EXTENSION_REJECTED) await failPaymentSession(extension.payment_request_id, PairingFailureCause.EXTENSION_REJECTED)
} }
await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}` 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 UPDATE session_extensions
SET status = ${ExtensionStatus.TIMEOUT}, responded_at = NOW() SET status = ${ExtensionStatus.TIMEOUT}, responded_at = NOW()
WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING} WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING}
RETURNING id, payment_session_id RETURNING id, payment_request_id
` `
if (!timedOut) return if (!timedOut) return
if (timedOut.payment_session_id) { if (timedOut.payment_request_id) {
await failPaymentSession(timedOut.payment_session_id, causeTag) await failPaymentSession(timedOut.payment_request_id, causeTag)
} }
// Move session to closing & notify both parties (matches the explicit-reject UX). // Move session to closing & notify both parties (matches the explicit-reject UX).

View File

@@ -5,20 +5,20 @@ const sql = getDb()
/** /**
* Insert a pairing_failures row. Called from payment.service.failPaymentSession (and the * 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)) { if (!Object.values(PairingFailureCause).includes(causeTag)) {
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 }) throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
} }
const [row] = await sql` const [row] = await sql`
INSERT INTO pairing_failures ( 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 ( 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 operator_action, actioned_by, actioned_at, created_at
` `
return row return row
@@ -33,7 +33,7 @@ export const listFailures = async ({ causeTags = null, dateFrom = null, dateTo =
const items = await sql` const items = await sql`
SELECT 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, pf.cause_tag, pf.amount, pf.operator_action, pf.actioned_by, pf.actioned_at, pf.created_at,
c.display_name AS customer_call_name, c.display_name AS customer_call_name,
m.display_name AS targeted_mitra_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_by = ${ccUserId},
actioned_at = NOW() actioned_at = NOW()
WHERE id = ${failureId} 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 operator_action, actioned_by, actioned_at, created_at
` `
if (!updated) { if (!updated) {

View File

@@ -13,7 +13,7 @@ import {
TransactionType, TransactionType,
WsMessage, WsMessage,
TopicSensitivity, TopicSensitivity,
PaymentSessionStatus, PaymentRequestStatus,
PairingFailureCause, PairingFailureCause,
PairingRequestType, PairingRequestType,
} from '../constants.js' } 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.', body: 'Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera.',
data: { data: {
type: WsMessage.PAIRING_FAILED, type: WsMessage.PAIRING_FAILED,
payment_session_id: data.payment_session_id || '', payment_request_id: data.payment_request_id || '',
cause_tag: data.cause_tag || '', 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. * Validate that a payment session is owned by the customer, confirmed, and not yet consumed.
* Throws on mismatch. Returns the loaded payment session row. * Throws on mismatch. Returns the loaded payment session row.
*/ */
const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { allowExtension = false } = {}) => { const requireConfirmedPaymentRequest = async (paymentRequestId, customerId, { allowExtension = false } = {}) => {
if (!paymentSessionId) { if (!paymentRequestId) {
throw Object.assign(new Error('payment_session_id is required'), { throw Object.assign(new Error('payment_request_id is required'), {
code: 'VALIDATION_ERROR', statusCode: 422, code: 'VALIDATION_ERROR', statusCode: 422,
}) })
} }
const paySession = await getPaymentSession(paymentSessionId) const payRequest = await getPaymentSession(paymentRequestId)
if (!paySession) { if (!payRequest) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) 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'), { throw Object.assign(new Error('Payment session does not belong to this customer'), {
code: 'FORBIDDEN', statusCode: 403, code: 'FORBIDDEN', statusCode: 403,
}) })
} }
if (paySession.status !== PaymentSessionStatus.CONFIRMED) { if (payRequest.status !== PaymentRequestStatus.CONFIRMED) {
throw Object.assign(new Error(`Payment session is ${paySession.status}, must be confirmed`), { throw Object.assign(new Error(`Payment session is ${payRequest.status}, must be confirmed`), {
code: 'INVALID_STATE', statusCode: 409, 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'), { throw Object.assign(new Error('Extension payment session cannot be used to start a new chat'), {
code: 'INVALID_STATE', statusCode: 409, 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). // 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 }) 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 * 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. * 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 * fall back to general blast on the same payment. The flag bypasses the
* "use returning-chat endpoint" guard in that exact case. * "use returning-chat endpoint" guard in that exact case.
*/ */
export const createPairingRequest = async (customerId, { paymentSessionId, topic_sensitivity, allowTargetedPayment = false } = {}) => { export const createPairingRequest = async (customerId, { paymentRequestId, topic_sensitivity, allowTargetedPayment = false } = {}) => {
const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId) const payRequest = await requireConfirmedPaymentRequest(paymentRequestId, customerId)
// Targeted payment session must use createTargetedPairingRequest unless we're // Targeted payment session must use createTargetedPairingRequest unless we're
// explicitly invoked by the fallback-to-blast path. // 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'), { throw Object.assign(new Error('Payment session is targeted to a specific mitra; use returning-chat endpoint'), {
code: 'INVALID_STATE', statusCode: 409, code: 'INVALID_STATE', statusCode: 409,
}) })
@@ -170,7 +170,7 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
const availableMitras = await findAvailableMitras() const availableMitras = await findAvailableMitras()
if (availableMitras.length === 0) { if (availableMitras.length === 0) {
// No mitras to blast to — fail the payment immediately. // 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'), { throw Object.assign(new Error('No bestie available'), {
code: 'NO_MITRA_AVAILABLE', statusCode: 404, code: 'NO_MITRA_AVAILABLE', statusCode: 404,
}) })
@@ -183,14 +183,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
// Create session sourced from the payment session. // Create session sourced from the payment session.
const [session] = await sql` const [session] = await sql`
INSERT INTO chat_sessions ( 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 ( VALUES (
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount}, ${payRequest.duration_minutes}, ${payRequest.amount}, ${payRequest.is_first_session_discount},
${resolvedTopic}, ${paymentSessionId} ${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 // 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 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). * 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 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). * - On accept: existing accept path runs (consumes payment session as for general blast).
*/ */
export const createTargetedPairingRequest = async (customerId, { paymentSessionId, targetedMitraId, topic_sensitivity } = {}) => { export const createTargetedPairingRequest = async (customerId, { paymentRequestId, targetedMitraId, topic_sensitivity } = {}) => {
const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId) const payRequest = await requireConfirmedPaymentRequest(paymentRequestId, customerId)
if (!targetedMitraId) { if (!targetedMitraId) {
throw Object.assign(new Error('targetedMitraId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 }) throw Object.assign(new Error('targetedMitraId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
} }
// Cross-check: payment_session.targeted_mitra_id should match (if set). // Cross-check: payment_request.targeted_mitra_id should match (if set).
if (paySession.targeted_mitra_id && paySession.targeted_mitra_id !== targetedMitraId) { if (payRequest.targeted_mitra_id && payRequest.targeted_mitra_id !== targetedMitraId) {
throw Object.assign(new Error('targetedMitraId does not match payment session'), { throw Object.assign(new Error('targetedMitraId does not match payment session'), {
code: 'INVALID_STATE', statusCode: 409, 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 // Intermediate failure: audit row written, payment stays `confirmed` so the customer
// can choose to fall back to general blast (or cancel, which terminates). // can choose to fall back to general blast (or cancel, which terminates).
await recordIntermediateFailure({ await recordIntermediateFailure({
paymentSessionId, paymentRequestId,
customerId, customerId,
targetedMitraId, targetedMitraId,
causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE, causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE,
amount: paySession.amount, amount: payRequest.amount,
}) })
throw Object.assign(new Error('Targeted mitra is offline'), { throw Object.assign(new Error('Targeted mitra is offline'), {
code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_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) const midSessionWithCustomer = await isMitraInActiveSessionWithCustomer(targetedMitraId, customerId)
if (!midSessionWithCustomer) { if (!midSessionWithCustomer) {
await recordIntermediateFailure({ await recordIntermediateFailure({
paymentSessionId, paymentRequestId,
customerId, customerId,
targetedMitraId, targetedMitraId,
causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE, causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE,
amount: paySession.amount, amount: payRequest.amount,
}) })
throw Object.assign(new Error('Targeted mitra is at capacity'), { throw Object.assign(new Error('Targeted mitra is at capacity'), {
code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline', 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. // Create session sourced from the payment session, status = pending_acceptance.
const [session] = await sql` const [session] = await sql`
INSERT INTO chat_sessions ( 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 ( VALUES (
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE}, ${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount}, ${payRequest.duration_minutes}, ${payRequest.amount}, ${payRequest.is_first_session_discount},
${resolvedTopic}, ${paymentSessionId} ${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 // Single notification to the targeted mitra
@@ -355,7 +405,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
UPDATE chat_sessions UPDATE chat_sessions
SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW() SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW()
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} AND mitra_id IS NULL 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) { if (!session) {
@@ -386,8 +436,8 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
} }
// Consume the payment session at the moment of acceptance. // Consume the payment session at the moment of acceptance.
if (session.payment_session_id) { if (session.payment_request_id) {
await consumePaymentSession(session.payment_session_id) await consumePaymentSession(session.payment_request_id)
} }
// Activate the session and set expires_at. // Activate the session and set expires_at.
@@ -399,7 +449,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
ELSE NULL ELSE NULL
END END
WHERE id = ${sessionId} 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 // Record transaction
@@ -453,25 +503,25 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL 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. // notification count — a general blast with only one online mitra also has length=1.
const [targetCheck] = await sql` const [targetCheck] = await sql`
SELECT ps.targeted_mitra_id SELECT ps.targeted_mitra_id
FROM chat_sessions cs 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} WHERE cs.id = ${sessionId}
` `
const isTargeted = !!targetCheck?.targeted_mitra_id const isTargeted = !!targetCheck?.targeted_mitra_id
if (isTargeted) { if (isTargeted) {
// Mark the chat_session as expired (the targeted attempt is over) — but keep the // 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). // the same payment, or cancel (which then terminates).
const [session] = await sql` const [session] = await sql`
UPDATE chat_sessions UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED} SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
RETURNING id, customer_id, payment_session_id RETURNING id, customer_id, payment_request_id
` `
if (session) { if (session) {
// Clear the 20s timer if still pending. // Clear the 20s timer if still pending.
@@ -482,15 +532,15 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
} }
// Audit row only; payment session stays `confirmed`. // Audit row only; payment session stays `confirmed`.
if (session.payment_session_id) { if (session.payment_request_id) {
const paySession = await getPaymentSession(session.payment_session_id) const payRequest = await getPaymentSession(session.payment_request_id)
if (paySession) { if (payRequest) {
await recordIntermediateFailure({ await recordIntermediateFailure({
paymentSessionId: session.payment_session_id, paymentRequestId: session.payment_request_id,
customerId: session.customer_id, customerId: session.customer_id,
targetedMitraId: mitraId, targetedMitraId: mitraId,
causeTag: PairingFailureCause.TARGETED_MITRA_REJECTED, 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, { await notifyCustomer(session.customer_id, {
type: WsMessage.RETURNING_CHAT_REJECTED, type: WsMessage.RETURNING_CHAT_REJECTED,
session_id: sessionId, session_id: sessionId,
payment_session_id: session.payment_session_id, payment_request_id: session.payment_request_id,
}) })
} }
return return
@@ -518,7 +568,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
UPDATE chat_sessions UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED} SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
RETURNING id, customer_id, payment_session_id RETURNING id, customer_id, payment_request_id
` `
if (session) { if (session) {
const timeoutId = pairingTimeouts.get(sessionId) 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 // Intermediate failure: payment stays confirmed so the customer can re-blast
// from the S7 timeout CTA. Audit row is still written. // from the S7 timeout CTA. Audit row is still written.
if (session.payment_session_id) { if (session.payment_request_id) {
const paySession = await getPaymentSession(session.payment_session_id) const payRequest = await getPaymentSession(session.payment_request_id)
if (paySession) { if (payRequest) {
await recordIntermediateFailure({ await recordIntermediateFailure({
paymentSessionId: session.payment_session_id, paymentRequestId: session.payment_request_id,
customerId: session.customer_id, customerId: session.customer_id,
causeTag: PairingFailureCause.ALL_MITRAS_REJECTED, 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, { await notifyCustomer(session.customer_id, {
type: WsMessage.PAIRING_FAILED, type: WsMessage.PAIRING_FAILED,
session_id: sessionId, session_id: sessionId,
payment_session_id: session.payment_session_id, payment_request_id: session.payment_request_id,
cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED, cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED,
is_terminal: false, is_terminal: false,
}) })
@@ -558,7 +608,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
SET status = ${SessionStatus.CANCELLED} SET status = ${SessionStatus.CANCELLED}
WHERE id = ${sessionId} AND customer_id = ${customerId} WHERE id = ${sessionId} AND customer_id = ${customerId}
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}) 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) { if (!session) {
@@ -594,8 +644,8 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
// Customer initiated this cancel; the calling client already navigates home. Do not // Customer initiated this cancel; the calling client already navigates home. Do not
// push PAIRING_FAILED for customer-initiated cancels — surfacing it as a "failure" // push PAIRING_FAILED for customer-initiated cancels — surfacing it as a "failure"
// event (especially via FCM if backgrounded) misframes the user's own action. // event (especially via FCM if backgrounded) misframes the user's own action.
if (session.payment_session_id) { if (session.payment_request_id) {
await failPaymentSession(session.payment_session_id, PairingFailureCause.CUSTOMER_CANCELLED) await failPaymentSession(session.payment_request_id, PairingFailureCause.CUSTOMER_CANCELLED)
} }
return session 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), * If a chat_session was already created (general blast in flight, or targeted request out),
* we cancel that too. * we cancel that too.
*/ */
export const cancelPaymentSearch = async (paymentSessionId, customerId) => { export const cancelPaymentSearch = async (paymentRequestId, customerId) => {
const paySession = await getPaymentSession(paymentSessionId) const payRequest = await getPaymentSession(paymentRequestId)
if (!paySession) { if (!payRequest) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) 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'), { throw Object.assign(new Error('Payment session does not belong to this customer'), {
code: 'FORBIDDEN', statusCode: 403, 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. // If a chat_session exists for this payment in pending_acceptance/searching, cancel it.
const [linkedSession] = await sql` const [linkedSession] = await sql`
SELECT id FROM chat_sessions SELECT id FROM chat_sessions
WHERE payment_session_id = ${paymentSessionId} WHERE payment_request_id = ${paymentRequestId}
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE}) AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
` `
if (linkedSession) { 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 // 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) // 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. // but the payment is still `confirmed`. No customer-side WS push — see cancelPairingRequest.
if (paySession.status === PaymentSessionStatus.CONFIRMED) { if (payRequest.status === PaymentRequestStatus.CONFIRMED) {
await failPaymentSession(paymentSessionId, PairingFailureCause.CUSTOMER_CANCELLED) 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". * 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. * 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` * 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_session — that's the * 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 * 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 * 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. * original intent); the general blast happens regardless.
*/ */
export const fallbackToGeneralBlast = async (paymentSessionId, customerId, { topic_sensitivity } = {}) => { export const fallbackToGeneralBlast = async (paymentRequestId, customerId, { topic_sensitivity } = {}) => {
const paySession = await getPaymentSession(paymentSessionId) const payRequest = await getPaymentSession(paymentRequestId)
if (!paySession) { if (!payRequest) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 }) 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'), { throw Object.assign(new Error('Payment session does not belong to this customer'), {
code: 'FORBIDDEN', statusCode: 403, code: 'FORBIDDEN', statusCode: 403,
}) })
} }
if (paySession.status !== PaymentSessionStatus.CONFIRMED) { if (payRequest.status !== PaymentRequestStatus.CONFIRMED) {
throw Object.assign(new Error(`Cannot fallback from payment in status ${paySession.status}`), { throw Object.assign(new Error(`Cannot fallback from payment in status ${payRequest.status}`), {
code: 'INVALID_STATE', statusCode: 409, 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` // 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. // so the targeted_mitra_id on the payment session doesn't trip the general-blast guard.
return createPairingRequest(customerId, { return createPairingRequest(customerId, {
paymentSessionId, paymentRequestId,
topic_sensitivity, topic_sensitivity,
allowTargetedPayment: true, allowTargetedPayment: true,
}) })
@@ -683,7 +733,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
UPDATE chat_sessions UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED} SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} 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 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 // 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 // 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. // written so the failed-pairing CC view captures every attempt.
if (session.payment_session_id) { if (session.payment_request_id) {
const paySession = await getPaymentSession(session.payment_session_id) const payRequest = await getPaymentSession(session.payment_request_id)
if (paySession) { if (payRequest) {
await recordIntermediateFailure({ await recordIntermediateFailure({
paymentSessionId: session.payment_session_id, paymentRequestId: session.payment_request_id,
customerId: session.customer_id, customerId: session.customer_id,
causeTag, causeTag,
amount: paySession.amount, amount: payRequest.amount,
}) })
} }
} }
@@ -714,7 +764,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
await notifyCustomer(session.customer_id, { await notifyCustomer(session.customer_id, {
type: WsMessage.PAIRING_FAILED, type: WsMessage.PAIRING_FAILED,
session_id: sessionId, session_id: sessionId,
payment_session_id: session.payment_session_id, payment_request_id: session.payment_request_id,
cause_tag: causeTag, cause_tag: causeTag,
is_terminal: false, is_terminal: false,
}) })
@@ -736,7 +786,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
* Targeted-request timer fired with no mitra response. * Targeted-request timer fired with no mitra response.
* *
* INTERMEDIATE failure: the chat_session is marked expired (the targeted attempt is over) * 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). * on the same payment, or cancel (which then terminates).
* *
* - cause_tag is targeted_mitra_timeout (audit row only) * - cause_tag is targeted_mitra_timeout (audit row only)
@@ -747,7 +797,7 @@ export const expireTargetedPairingRequest = async (sessionId) => {
UPDATE chat_sessions UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED} SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} 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 if (!session) return null
@@ -764,15 +814,15 @@ export const expireTargetedPairingRequest = async (sessionId) => {
WHERE session_id = ${sessionId} AND response IS NULL WHERE session_id = ${sessionId} AND response IS NULL
` `
if (session.payment_session_id) { if (session.payment_request_id) {
const paySession = await getPaymentSession(session.payment_session_id) const payRequest = await getPaymentSession(session.payment_request_id)
if (paySession) { if (payRequest) {
await recordIntermediateFailure({ await recordIntermediateFailure({
paymentSessionId: session.payment_session_id, paymentRequestId: session.payment_request_id,
customerId: session.customer_id, customerId: session.customer_id,
targetedMitraId: notif?.mitra_id ?? null, targetedMitraId: notif?.mitra_id ?? null,
causeTag: PairingFailureCause.TARGETED_MITRA_TIMEOUT, 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, { await notifyCustomer(session.customer_id, {
type: WsMessage.RETURNING_CHAT_TIMEOUT, type: WsMessage.RETURNING_CHAT_TIMEOUT,
session_id: sessionId, 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 // 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) => { 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 // 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. // the countdown overlay — same field the WS payload provides for the live path.
const rows = await sql` const rows = await sql`
@@ -814,7 +864,7 @@ export const getPendingRequestsForMitra = async (mitraId) => {
END AS request_type END AS request_type
FROM chat_request_notifications crn FROM chat_request_notifications crn
JOIN chat_sessions cs ON cs.id = crn.session_id 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} WHERE crn.mitra_id = ${mitraId}
AND crn.response IS NULL AND crn.response IS NULL
AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE} AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE}

View File

@@ -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 { 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 { recordFailure } from './pairing-failure.service.js'
import { sendToUser } from '../plugins/websocket.js' import { sendToUser } from '../plugins/websocket.js'
import { sendPushNotification } from './notification.service.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 sql = getDb()
const getPaymentSessionTimeoutMinutes = async () => { // --- EventEmitter setup ---
const { payment_session_timeout_minutes } = await readPaymentSessionTimeoutMinutes()
return payment_session_timeout_minutes 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)
* Create a new payment session in `pending` status.
* Reads `payment_session_timeout_minutes` from config to compute expires_at. // --- Internal Xendit client (lazy + re-creatable for test env stubbing) ---
*
* Phase 4: `isFirstSessionDiscount` replaces the old `isFreeTrial` flag. Voice-call let _xenditClient = null
* mode is a routing/badge thing — the price comes from the call tier group, not from let _xenditKey = null
* the mode itself. const xenditClient = () => {
*/ const { secretKey } = getXenditConfig()
export const createPaymentSession = async ({ if (_xenditClient && _xenditKey === secretKey) return _xenditClient
customerId, _xenditClient = new Xendit({ secretKey })
durationMinutes, _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, 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 request in `pending` status. When XENDIT_ENABLED=true, also
* creates a Xendit Invoice and stamps invoice_id + invoice_url on the row.
*
* 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 requestPayment = async ({
productType = 'chat_session',
productMetadata = {},
customerId,
amount,
ttlMinutes,
// Chat-specific legacy fields (still written to top-level columns for now)
durationMinutes,
mode = SessionMode.CHAT,
isFirstSessionDiscount = false, isFirstSessionDiscount = false,
isExtension = false, isExtension = false,
targetedMitraId = null, targetedMitraId = null,
mode = SessionMode.CHAT,
}) => { }) => {
if (!customerId) { if (!customerId) {
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 }) 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) { if (typeof amount !== 'number' || amount < 0) {
throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 }) throw Object.assign(new Error('amount must be a non-negative number'), { 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) { if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
throw Object.assign(new Error('mode must be chat or call'), { code: 'VALIDATION_ERROR', statusCode: 422 }) 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` const [row] = await sql`
INSERT INTO payment_sessions ( INSERT INTO payment_requests (
customer_id, amount, duration_minutes, is_first_session_discount, is_extension, 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 ( VALUES (
${customerId}, ${amount}, ${durationMinutes}, ${isFirstSessionDiscount}, ${isExtension}, ${customerId}, ${amount}, ${durationMinutes ?? 0}, ${isFirstSessionDiscount}, ${isExtension},
${PaymentSessionStatus.PENDING}, ${targetedMitraId}, ${mode}, ${PaymentRequestStatus.PENDING}, ${targetedMitraId}, ${mode},
NOW() + (${ttlMinutes} || ' minutes')::interval NOW() + (${ttl} || ' minutes')::interval,
${productType}, ${sql.json(meta)}
) )
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension, RETURNING *
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
` `
// 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 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` const [existing] = await sql`
SELECT id, customer_id, status, expires_at SELECT id, customer_id, status, expires_at
FROM payment_sessions FROM payment_requests
WHERE id = ${paymentSessionId} WHERE id = ${paymentRequestId}
` `
if (!existing) { 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) { if (existing.status !== PaymentRequestStatus.PENDING) {
throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 }) throw Object.assign(new Error(`Payment request is ${existing.status}, cannot confirm`), {
}
if (existing.status !== PaymentSessionStatus.PENDING) {
throw Object.assign(new Error(`Payment session is ${existing.status}, cannot confirm`), {
code: 'INVALID_STATE', statusCode: 409, code: 'INVALID_STATE', statusCode: 409,
}) })
} }
if (new Date(existing.expires_at) <= new Date()) { if (new Date(existing.expires_at) <= new Date()) {
// Inline expiry check in addition to the background sweeper, since the customer can // Inline expiry check (sweeper hasn't run yet)
// attempt to confirm a row that's already past expires_at before the sweep runs.
await sql` await sql`
UPDATE payment_sessions SET status = ${PaymentSessionStatus.EXPIRED} UPDATE payment_requests SET status = ${PaymentRequestStatus.EXPIRED}
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING} 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` const [updated] = await sql`
UPDATE payment_sessions UPDATE payment_requests
SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW() SET status = ${PaymentRequestStatus.CONFIRMED},
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING} confirmed_at = NOW(),
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension, xendit_invoice_id = COALESCE(${xenditMeta.invoiceId ?? null}, xendit_invoice_id),
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at 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) { 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 return updated
} }
/** // Customer-facing wrapper used by the legacy /payment-requests/:id/confirm route
* Transition confirmed → consumed. Called from pairing service when a chat starts. // (kept only for dev/Maestro — production gates the route on XENDIT_ENABLED).
* Idempotent at higher level (caller should check status first if it matters). // Verifies customer ownership before delegating to the internal confirmPayment.
*/ export const confirmPaymentForCustomer = async (paymentRequestId, customerId) => {
export const consumePaymentSession = async (paymentSessionId) => { const [existing] = await sql`SELECT customer_id FROM payment_requests WHERE id = ${paymentRequestId}`
const [updated] = await sql` if (!existing) {
UPDATE payment_sessions throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 })
SET status = ${PaymentSessionStatus.CONSUMED}, consumed_at = NOW() }
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED} if (existing.customer_id !== customerId) {
RETURNING id, status, consumed_at throw Object.assign(new Error('Payment request does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
` }
return updated || null return confirmPayment(paymentRequestId)
} }
// --- expirePayment (webhook-callable; no ownership check) ---
/** /**
* TERMINAL: mark a confirmed payment session as failed_pairing AND write a pairing_failures row. * Transition pending → expired. Called by the Xendit EXPIRED webhook handler
* Idempotent: no-op if already terminal (consumed/failed_pairing/expired/abandoned). * and by the background sweeper. Idempotent if already terminal, no-op.
* * Emits 'payment_request.expired' if a transition occurred.
* 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).
*/ */
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)) { if (!Object.values(PairingFailureCause).includes(causeTag)) {
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 }) throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
} }
const [existing] = await sql` const [existing] = await sql`
SELECT id, customer_id, targeted_mitra_id, amount, status SELECT id, customer_id, targeted_mitra_id, amount, status, product_type, product_metadata, xendit_invoice_id, xendit_payment_method
FROM payment_sessions FROM payment_requests
WHERE id = ${paymentSessionId} WHERE id = ${paymentRequestId}
` `
if (!existing) { if (!existing) return null
return null if (existing.status !== PaymentRequestStatus.CONFIRMED) return existing // idempotent
}
// 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
}
const [updated] = await sql` const [updated] = await sql`
UPDATE payment_sessions UPDATE payment_requests
SET status = ${PaymentSessionStatus.FAILED_PAIRING} SET status = ${PaymentRequestStatus.FAILED_DELIVERY}
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED} WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.CONFIRMED}
RETURNING id, customer_id, targeted_mitra_id, amount, status RETURNING *
` `
if (!updated) { if (!updated) return existing
return existing
}
await recordFailure({ await recordFailure({
paymentSessionId, paymentRequestId,
customerId: existing.customer_id, customerId: existing.customer_id,
targetedMitraId: existing.targeted_mitra_id, targetedMitraId: existing.targeted_mitra_id,
causeTag, causeTag,
amount: existing.amount, amount: existing.amount,
}) })
emit('payment_request.delivery_failed', { ...buildEventPayload(updated), causeTag })
return updated 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" * Returns the inserted pairing_failures row, or null if the payment is missing.
* 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.
*/ */
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)) { if (!Object.values(PairingFailureCause).includes(causeTag)) {
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 }) throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
} }
const [existing] = await sql`SELECT id FROM payment_requests WHERE id = ${paymentRequestId}`
// 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}`
if (!existing) return null if (!existing) return null
return recordFailure({ paymentRequestId, customerId, targetedMitraId, causeTag, amount })
return recordFailure({
paymentSessionId,
customerId,
targetedMitraId,
causeTag,
amount,
})
} }
// --- cancelPayment (customer-initiated abandonment) ---
/** /**
* Customer-initiated abandonment of a `pending` payment session (e.g. closed payment screen). * Customer-initiated abandonment of a pending payment (closed payment screen).
* No pairing_failures row is written — only confirmed-but-no-chat counts as a pairing failure. * 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` 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) { 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) { 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) { if (existing.status !== PaymentRequestStatus.PENDING) {
// Idempotent — already terminal. return existing // idempotent
return existing
} }
const [updated] = await sql` const [updated] = await sql`
UPDATE payment_sessions SET status = ${PaymentSessionStatus.ABANDONED} UPDATE payment_requests SET status = ${PaymentRequestStatus.ABANDONED}
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING} WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
RETURNING id, customer_id, status RETURNING *
` `
return updated || existing if (updated) emit('payment_request.cancelled', buildEventPayload(updated))
return updated ?? existing
} }
// --- Background sweeper + reconciliation ---
/** /**
* Background sweeper: * Runs every 60s from server.js + once at startup.
* - 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 * 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 () => { export const expireStalePaymentRequests = async () => {
// 1) pending → expired // 1) pending → expired (emit event for each)
const expired = await sql` const expired = await sql`
UPDATE payment_sessions UPDATE payment_requests
SET status = ${PaymentSessionStatus.EXPIRED} SET status = ${PaymentRequestStatus.EXPIRED}
WHERE status = ${PaymentSessionStatus.PENDING} WHERE status = ${PaymentRequestStatus.PENDING}
AND expires_at <= NOW() 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 // 2) confirmed-but-stale → failed_delivery
// 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.
const flipped = await sql` const flipped = await sql`
UPDATE payment_sessions UPDATE payment_requests
SET status = ${PaymentSessionStatus.FAILED_PAIRING} SET status = ${PaymentRequestStatus.FAILED_DELIVERY}
WHERE status = ${PaymentSessionStatus.CONFIRMED} WHERE status = ${PaymentRequestStatus.CONFIRMED}
AND expires_at <= NOW() AND expires_at <= NOW()
RETURNING id, customer_id, targeted_mitra_id, amount RETURNING *
` `
await Promise.all(flipped.map(async (row) => { await Promise.all(flipped.map(async (row) => {
await recordFailure({ await recordFailure({
paymentSessionId: row.id, paymentRequestId: row.id,
customerId: row.customer_id, customerId: row.customer_id,
targetedMitraId: row.targeted_mitra_id, targetedMitraId: row.targeted_mitra_id,
causeTag: PairingFailureCause.PAYMENT_SESSION_EXPIRED, causeTag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
amount: row.amount, amount: row.amount,
}) })
// Customer may be on searching/waiting; push terminal PAIRING_FAILED in real time. // Customer-facing: push terminal PAIRING_FAILED via WS; FCM fallback if not connected.
// FCM fallback when not WS-connected so they're notified at the OS level.
try { try {
const wsSent = sendToUser(UserType.CUSTOMER, row.customer_id, { const wsSent = sendToUser(UserType.CUSTOMER, row.customer_id, {
type: WsMessage.PAIRING_FAILED, type: WsMessage.PAIRING_FAILED,
payment_session_id: row.id, payment_request_id: row.id,
cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED, cause_tag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
is_terminal: true, is_terminal: true,
}) })
if (!wsSent) { if (!wsSent) {
@@ -281,43 +521,73 @@ export const expireStalePaymentSessions = async () => {
body: 'Sesi pembayaranmu telah berakhir. Silakan mulai ulang.', body: 'Sesi pembayaranmu telah berakhir. Silakan mulai ulang.',
data: { data: {
type: WsMessage.PAIRING_FAILED, type: WsMessage.PAIRING_FAILED,
payment_session_id: row.id, payment_request_id: row.id,
cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED, cause_tag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
}, },
}) })
} }
} catch (err) { } catch (err) {
console.error('expireStalePaymentSessions: failed to notify customer', { console.error('expireStalePaymentRequests: failed to notify customer', {
paymentSessionId: row.id, customerId: row.customer_id, err, 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 })
}
} }
export const getPaymentSession = async (id) => { return { expired: expired.length, failed: flipped.length, reconciled }
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. * Phase 4 Stage 10 — Chat Tab Pembayaran feed.
* * Returns the customer's pending payment requests (initial + extension) that
* Returns the customer's pending payment sessions (initial + extension) that * haven't paid AND haven't expired.
* 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.
*/ */
export const getCustomerPendingPayments = async (customerId) => { export const getCustomerPendingPayments = async (customerId) => {
const items = await sql` const items = await sql`
@@ -331,15 +601,37 @@ export const getCustomerPendingPayments = async (customerId) => {
ps.expires_at, ps.expires_at,
COALESCE(ext_m.id, tgt_m.id) AS mitra_id, COALESCE(ext_m.id, tgt_m.id) AS mitra_id,
COALESCE(ext_m.display_name, tgt_m.display_name) AS mitra_display_name COALESCE(ext_m.display_name, tgt_m.display_name) AS mitra_display_name
FROM payment_sessions ps FROM payment_requests ps
LEFT JOIN session_extensions se ON se.payment_session_id = ps.id 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 chat_sessions cs ON cs.id = se.session_id
LEFT JOIN mitras ext_m ON ext_m.id = cs.mitra_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 LEFT JOIN mitras tgt_m ON tgt_m.id = ps.targeted_mitra_id
WHERE ps.customer_id = ${customerId} WHERE ps.customer_id = ${customerId}
AND ps.status = ${PaymentSessionStatus.PENDING} AND ps.status = ${PaymentRequestStatus.PENDING}
AND ps.expires_at > NOW() AND ps.expires_at > NOW()
ORDER BY ps.created_at DESC ORDER BY ps.created_at DESC
` `
return { items, total: items.length } 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,
})
})
}

View File

@@ -149,9 +149,9 @@ export const listSessions = async ({ page = 1, limit = 20, status, topic_sensiti
} }
export const getSessionById = async (sessionId) => { 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. // 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` const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics, 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, 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 FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id INNER JOIN customers c ON c.id = cs.customer_id
LEFT JOIN mitras m ON m.id = cs.mitra_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} WHERE cs.id = ${sessionId}
` `
return session return session
@@ -251,7 +251,7 @@ export const getCustomerHistory = async (customerId, { cursor = null, limit = 20
FROM chat_sessions cs FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id 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 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} WHERE cs.customer_id = ${customerId}
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING}) AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
AND ( AND (
@@ -275,7 +275,7 @@ export const getCustomerHistory = async (customerId, { cursor = null, limit = 20
FROM chat_sessions cs FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id 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 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} WHERE cs.customer_id = ${customerId}
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING}) AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC, cs.id DESC ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC, cs.id DESC

View File

@@ -9,7 +9,7 @@ export const db = () => getDb()
/** /**
* Truncate Phase 3.7-relevant tables between tests. * 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 * 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. * we don't have to maintain the topological order when tables get added.
* *
@@ -19,7 +19,7 @@ export const db = () => getDb()
*/ */
const TRUNCATE_TABLES = [ const TRUNCATE_TABLES = [
'pairing_failures', 'pairing_failures',
'payment_sessions', 'payment_requests',
'chat_request_notifications', 'chat_request_notifications',
'session_extensions', 'session_extensions',
'session_closures', 'session_closures',
@@ -70,7 +70,7 @@ export const resetAppConfig = async () => {
['extension_timeout_seconds', { value: 60 }], ['extension_timeout_seconds', { value: 60 }],
['early_end_mitra_enabled', { value: false }], ['early_end_mitra_enabled', { value: false }],
['early_end_customer_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 }], ['returning_chat_confirmation_timeout_seconds', { value: 20 }],
['extension_default_action_on_timeout', { value: 'auto_approve' }], ['extension_default_action_on_timeout', { value: 'auto_approve' }],
['pairing_blast_timeout_seconds', { value: 60 }], ['pairing_blast_timeout_seconds', { value: 60 }],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,14 +48,14 @@ class SessionClosure extends _$SessionClosure {
SessionClosureData build() => const ClosureInitialData(); SessionClosureData build() => const ClosureInitialData();
/// Extension request is a 3-step flow with the extension cost held in its /// 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 /// 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` /// 1. POST `/api/client/payment-requests` with `is_extension: true`
/// 2. POST `/api/client/payment-sessions/:id/confirm` /// 2. POST `/api/client/payment-requests/:id/confirm`
/// 3. POST `/api/client/chat/session/:sessionId/extend` with the /// 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. /// 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. /// If the mitra explicitly rejects within 10s the payment is failed back, no charge.
@@ -64,15 +64,15 @@ class SessionClosure extends _$SessionClosure {
try { try {
final api = ref.read(apiClientProvider); 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, 'duration_minutes': durationMinutes,
'is_extension': true, 'is_extension': true,
}); });
final paymentSessionId = (createResp['data'] as Map<String, dynamic>)['id'] as String; final paymentRequestId = (createResp['data'] as Map<String, dynamic>)['id'] as String;
// Backend rejects truly empty bodies on confirm, so always send `{}`. // Backend rejects truly empty bodies on confirm, so always send `{}`.
await api.post( await api.post(
'/api/client/payment-sessions/$paymentSessionId/confirm', '/api/client/payment-requests/$paymentRequestId/confirm',
data: const <String, dynamic>{}, data: const <String, dynamic>{},
); );
@@ -81,7 +81,7 @@ class SessionClosure extends _$SessionClosure {
await api.post('/api/client/chat/session/$sessionId/extend', data: { await api.post('/api/client/chat/session/$sessionId/extend', data: {
'duration_minutes': durationMinutes, 'duration_minutes': durationMinutes,
'price': price, 'price': price,
'extension_payment_session_id': paymentSessionId, 'extension_payment_request_id': paymentRequestId,
}); });
} catch (e) { } catch (e) {
state = const ClosureErrorData('Gagal meminta perpanjangan.'); state = const ClosureErrorData('Gagal meminta perpanjangan.');

View File

@@ -64,7 +64,7 @@ class ExtensionStatus {
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 /// (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; /// "voice call" badge and (eventually) a Meet link the mitra pastes manually;
/// no real audio transport is built yet. /// no real audio transport is built yet.
@@ -153,7 +153,7 @@ enum PairingFailureCause {
targetedMitraOffline('targeted_mitra_offline'), targetedMitraOffline('targeted_mitra_offline'),
targetedMitraRejected('targeted_mitra_rejected'), targetedMitraRejected('targeted_mitra_rejected'),
targetedMitraTimeout('targeted_mitra_timeout'), targetedMitraTimeout('targeted_mitra_timeout'),
paymentSessionExpired('payment_session_expired'), paymentSessionExpired('payment_request_expired'),
customerCancelled('customer_cancelled'), customerCancelled('customer_cancelled'),
unknown('unknown'); unknown('unknown');
@@ -165,13 +165,13 @@ enum PairingFailureCause {
} }
/// Payment session lifecycle. Mirror of backend /// Payment session lifecycle. Mirror of backend
/// `PaymentSessionStatus`. /// `PaymentRequestStatus`.
class PaymentSessionStatus { class PaymentRequestStatus {
static const pending = 'pending'; static const pending = 'pending';
static const confirmed = 'confirmed'; static const confirmed = 'confirmed';
static const consumed = 'consumed'; static const consumed = 'consumed';
static const failedPairing = 'failed_pairing'; static const failedPairing = 'failed_delivery';
static const abandoned = 'abandoned'; static const abandoned = 'abandoned';
static const expired = 'expired'; static const expired = 'expired';
PaymentSessionStatus._(); PaymentRequestStatus._();
} }

View File

@@ -23,12 +23,12 @@ class PairingInitialData extends PairingData {
/// General-blast in flight. The chat_session row exists; backend has already /// General-blast in flight. The chat_session row exists; backend has already
/// notified all available mitras and is waiting for the first to accept. /// notified all available mitras and is waiting for the first to accept.
class PairingSearchingData extends PairingData { class PairingSearchingData extends PairingData {
/// chat_session id (NOT payment_session id). /// chat_session id (NOT payment_request id).
final String sessionId; 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. /// 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 /// Carried so a retryable PAIRING_FAILED can preserve the customer's original
/// topic choice when looping back into Blast via retryBlast(). /// topic choice when looping back into Blast via retryBlast().
@@ -36,7 +36,7 @@ class PairingSearchingData extends PairingData {
const PairingSearchingData({ const PairingSearchingData({
required this.sessionId, required this.sessionId,
required this.paymentSessionId, required this.paymentRequestId,
required this.topicSensitivity, 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 /// server is the source of truth for the actual auto-reject; the local timer
/// is purely cosmetic. /// is purely cosmetic.
class PairingTargetedWaitingData extends PairingData { class PairingTargetedWaitingData extends PairingData {
final String paymentSessionId; final String paymentRequestId;
final String mitraId; final String mitraId;
final String mitraName; final String mitraName;
final int secondsRemaining; final int secondsRemaining;
@@ -58,7 +58,7 @@ class PairingTargetedWaitingData extends PairingData {
final TopicSensitivity topicSensitivity; final TopicSensitivity topicSensitivity;
const PairingTargetedWaitingData({ const PairingTargetedWaitingData({
required this.paymentSessionId, required this.paymentRequestId,
required this.mitraId, required this.mitraId,
required this.mitraName, required this.mitraName,
required this.secondsRemaining, required this.secondsRemaining,
@@ -67,7 +67,7 @@ class PairingTargetedWaitingData extends PairingData {
PairingTargetedWaitingData copyWith({int? secondsRemaining}) { PairingTargetedWaitingData copyWith({int? secondsRemaining}) {
return PairingTargetedWaitingData( return PairingTargetedWaitingData(
paymentSessionId: paymentSessionId, paymentRequestId: paymentRequestId,
mitraId: mitraId, mitraId: mitraId,
mitraName: mitraName, mitraName: mitraName,
secondsRemaining: secondsRemaining ?? this.secondsRemaining, secondsRemaining: secondsRemaining ?? this.secondsRemaining,
@@ -96,14 +96,14 @@ class PairingActiveData extends PairingData {
/// ///
/// The UI surfaces this via the bestie-unavailable dialog. /// The UI surfaces this via the bestie-unavailable dialog.
class PairingTargetedUnavailableData extends PairingData { class PairingTargetedUnavailableData extends PairingData {
final String paymentSessionId; final String paymentRequestId;
final String mitraName; final String mitraName;
final PairingFailureCause cause; final PairingFailureCause cause;
// Carried so the fallback-to-blast call preserves the customer's original choice. // Carried so the fallback-to-blast call preserves the customer's original choice.
final TopicSensitivity topicSensitivity; final TopicSensitivity topicSensitivity;
const PairingTargetedUnavailableData({ const PairingTargetedUnavailableData({
required this.paymentSessionId, required this.paymentRequestId,
required this.mitraName, required this.mitraName,
required this.cause, required this.cause,
required this.topicSensitivity, required this.topicSensitivity,
@@ -114,11 +114,11 @@ class PairingTargetedUnavailableData extends PairingData {
/// ///
/// `isRetryable=true` means the backend kept the payment session `confirmed` /// `isRetryable=true` means the backend kept the payment session `confirmed`
/// (audit-only failure) so the customer can re-blast on the same payment via /// (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. /// and any retry must start from a fresh payment session.
class PairingFailedData extends PairingData { class PairingFailedData extends PairingData {
final PairingFailureCause cause; final PairingFailureCause cause;
final String? paymentSessionId; final String? paymentRequestId;
final bool isRetryable; final bool isRetryable;
// Carried so retryBlast() can re-issue the blast with the customer's original // 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. // topic choice. Null when the failure originated before any topic was known.
@@ -126,7 +126,7 @@ class PairingFailedData extends PairingData {
const PairingFailedData({ const PairingFailedData({
required this.cause, required this.cause,
this.paymentSessionId, this.paymentRequestId,
this.isRetryable = false, this.isRetryable = false,
this.topicSensitivity, this.topicSensitivity,
}); });
@@ -156,7 +156,7 @@ class Pairing extends _$Pairing {
/// Returns once the chat_session row is created server-side; subsequent /// Returns once the chat_session row is created server-side; subsequent
/// transitions (paired / pairing_failed) arrive via WS. /// transitions (paired / pairing_failed) arrive via WS.
Future<void> startSearch({ Future<void> startSearch({
required String paymentSessionId, required String paymentRequestId,
required TopicSensitivity topicSensitivity, required TopicSensitivity topicSensitivity,
}) async { }) async {
state = const PairingInitialData(); state = const PairingInitialData();
@@ -165,7 +165,7 @@ class Pairing extends _$Pairing {
final response = await _apiClient.post( final response = await _apiClient.post(
'/api/client/chat/request', '/api/client/chat/request',
data: { data: {
'payment_session_id': paymentSessionId, 'payment_request_id': paymentRequestId,
'topic_sensitivity': topicSensitivity.value, 'topic_sensitivity': topicSensitivity.value,
}, },
); );
@@ -173,7 +173,7 @@ class Pairing extends _$Pairing {
final sessionId = data['id'] as String; final sessionId = data['id'] as String;
state = PairingSearchingData( state = PairingSearchingData(
sessionId: sessionId, sessionId: sessionId,
paymentSessionId: paymentSessionId, paymentRequestId: paymentRequestId,
topicSensitivity: topicSensitivity, topicSensitivity: topicSensitivity,
); );
} on DioException catch (e) { } on DioException catch (e) {
@@ -183,7 +183,7 @@ class Pairing extends _$Pairing {
// Backend already failed the payment in this case — terminal. // Backend already failed the payment in this case — terminal.
state = PairingFailedData( state = PairingFailedData(
cause: PairingFailureCause.noMitraAvailable, cause: PairingFailureCause.noMitraAvailable,
paymentSessionId: paymentSessionId, paymentRequestId: paymentRequestId,
); );
} else if (code == 'ALREADY_ACTIVE') { } else if (code == 'ALREADY_ACTIVE') {
state = const PairingErrorData('Kamu sudah memiliki sesi aktif.'); 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 /// row (payment stays confirmed) — we transition to TargetedUnavailable so
/// the UI can offer the fallback dialog. /// the UI can offer the fallback dialog.
Future<void> startTargetedSearch({ Future<void> startTargetedSearch({
required String paymentSessionId, required String paymentRequestId,
required String mitraId, required String mitraId,
required String mitraName, required String mitraName,
required TopicSensitivity topicSensitivity, required TopicSensitivity topicSensitivity,
@@ -212,7 +212,7 @@ class Pairing extends _$Pairing {
final response = await _apiClient.post( final response = await _apiClient.post(
'/api/client/chat/chat-requests/returning', '/api/client/chat/chat-requests/returning',
data: { data: {
'payment_session_id': paymentSessionId, 'payment_request_id': paymentRequestId,
'mitra_id': mitraId, 'mitra_id': mitraId,
'topic_sensitivity': topicSensitivity.value, 'topic_sensitivity': topicSensitivity.value,
}, },
@@ -222,7 +222,7 @@ class Pairing extends _$Pairing {
final sessionData = response['data'] as Map<String, dynamic>?; final sessionData = response['data'] as Map<String, dynamic>?;
final seconds = (sessionData?['confirmation_timeout_seconds'] as num?)?.toInt() ?? 20; final seconds = (sessionData?['confirmation_timeout_seconds'] as num?)?.toInt() ?? 20;
state = PairingTargetedWaitingData( state = PairingTargetedWaitingData(
paymentSessionId: paymentSessionId, paymentRequestId: paymentRequestId,
mitraId: mitraId, mitraId: mitraId,
mitraName: mitraName, mitraName: mitraName,
secondsRemaining: seconds, secondsRemaining: seconds,
@@ -237,7 +237,7 @@ class Pairing extends _$Pairing {
// Intermediate — payment session is still confirmed; show the // Intermediate — payment session is still confirmed; show the
// bestie-unavailable popup with a "Chat dengan bestie lain" option. // bestie-unavailable popup with a "Chat dengan bestie lain" option.
state = PairingTargetedUnavailableData( state = PairingTargetedUnavailableData(
paymentSessionId: paymentSessionId, paymentRequestId: paymentRequestId,
mitraName: mitraName, mitraName: mitraName,
cause: PairingFailureCause.targetedMitraOffline, cause: PairingFailureCause.targetedMitraOffline,
topicSensitivity: topicSensitivity, topicSensitivity: topicSensitivity,
@@ -251,17 +251,17 @@ class Pairing extends _$Pairing {
} }
/// Customer-initiated cancel during a search/wait. Terminal — payment /// 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. /// (NOT to the failed-pairing screen) since the customer chose this.
Future<void> cancelSearch() async { Future<void> cancelSearch() async {
String? paymentSessionId; String? paymentRequestId;
final current = state; final current = state;
if (current is PairingSearchingData) { if (current is PairingSearchingData) {
paymentSessionId = current.paymentSessionId; paymentRequestId = current.paymentRequestId;
} else if (current is PairingTargetedWaitingData) { } else if (current is PairingTargetedWaitingData) {
paymentSessionId = current.paymentSessionId; paymentRequestId = current.paymentRequestId;
} }
if (paymentSessionId == null) { if (paymentRequestId == null) {
_cleanup(); _cleanup();
state = const PairingCancelledData(); state = const PairingCancelledData();
return; return;
@@ -269,7 +269,7 @@ class Pairing extends _$Pairing {
try { try {
await _apiClient.post( await _apiClient.post(
'/api/client/chat/chat-requests/cancel', '/api/client/chat/chat-requests/cancel',
data: {'payment_session_id': paymentSessionId}, data: {'payment_request_id': paymentRequestId},
); );
} catch (_) { } catch (_) {
// Best-effort. Backend will still fail the payment if/when it // 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 /// Reuses the same payment session — backend transitions back into the
/// general-blast path. /// general-blast path.
Future<void> fallbackToBlast({ Future<void> fallbackToBlast({
required String paymentSessionId, required String paymentRequestId,
required TopicSensitivity topicSensitivity, required TopicSensitivity topicSensitivity,
}) async { }) async {
state = const PairingInitialData(); state = const PairingInitialData();
try { try {
await _connectWebSocket(); await _connectWebSocket();
final response = await _apiClient.post( 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}, data: {'topic_sensitivity': topicSensitivity.value},
); );
final data = response['data'] as Map<String, dynamic>; final data = response['data'] as Map<String, dynamic>;
final sessionId = data['id'] as String; final sessionId = data['id'] as String;
state = PairingSearchingData( state = PairingSearchingData(
sessionId: sessionId, sessionId: sessionId,
paymentSessionId: paymentSessionId, paymentRequestId: paymentRequestId,
topicSensitivity: topicSensitivity, topicSensitivity: topicSensitivity,
); );
} on DioException catch (e) { } on DioException catch (e) {
@@ -306,7 +306,7 @@ class Pairing extends _$Pairing {
if (code == 'NO_MITRA_AVAILABLE') { if (code == 'NO_MITRA_AVAILABLE') {
state = PairingFailedData( state = PairingFailedData(
cause: PairingFailureCause.noMitraAvailable, cause: PairingFailureCause.noMitraAvailable,
paymentSessionId: paymentSessionId, paymentRequestId: paymentRequestId,
); );
} else { } else {
state = const PairingErrorData('Gagal memulai. Coba lagi.'); 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. /// `confirmed` (retryable failure). Re-blasts on the same payment session.
/// ///
/// Caller should only invoke this when `state is PairingFailedData && /// Caller should only invoke this when `state is PairingFailedData &&
/// state.isRetryable && paymentSessionId != null && topicSensitivity != null`. /// state.isRetryable && paymentRequestId != null && topicSensitivity != null`.
Future<void> retryBlast() async { Future<void> retryBlast() async {
final current = state; final current = state;
if (current is! PairingFailedData if (current is! PairingFailedData
|| !current.isRetryable || !current.isRetryable
|| current.paymentSessionId == null || current.paymentRequestId == null
|| current.topicSensitivity == null) { || current.topicSensitivity == null) {
return; return;
} }
await startSearch( await startSearch(
paymentSessionId: current.paymentSessionId!, paymentRequestId: current.paymentRequestId!,
topicSensitivity: current.topicSensitivity!, topicSensitivity: current.topicSensitivity!,
); );
} }
@@ -388,7 +388,7 @@ class Pairing extends _$Pairing {
if (type == WsMessage.pairingFailed) { if (type == WsMessage.pairingFailed) {
final causeTag = data['cause_tag'] as String?; 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 // Missing flag = terminal (backward-compat with older emit sites). When
// false, the backend kept the payment confirmed and we can re-blast. // false, the backend kept the payment confirmed and we can re-blast.
final isRetryable = data['is_terminal'] == false; final isRetryable = data['is_terminal'] == false;
@@ -398,7 +398,7 @@ class Pairing extends _$Pairing {
_cleanup(); _cleanup();
state = PairingFailedData( state = PairingFailedData(
cause: PairingFailureCause.fromString(causeTag), cause: PairingFailureCause.fromString(causeTag),
paymentSessionId: paymentSessionId, paymentRequestId: paymentRequestId,
isRetryable: isRetryable, isRetryable: isRetryable,
topicSensitivity: carriedTopic, topicSensitivity: carriedTopic,
); );
@@ -409,7 +409,7 @@ class Pairing extends _$Pairing {
// Intermediate — payment still confirmed. Show the bestie-unavailable // Intermediate — payment still confirmed. Show the bestie-unavailable
// dialog (UI surfaces via state listener). // dialog (UI surfaces via state listener).
_stopLocalCountdown(); _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 // 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. // the request payload). If we somehow lost it, fall back to safe defaults.
String mitraName = 'Bestie'; String mitraName = 'Bestie';
@@ -419,7 +419,7 @@ class Pairing extends _$Pairing {
carriedTopic = current.topicSensitivity; carriedTopic = current.topicSensitivity;
} }
state = PairingTargetedUnavailableData( state = PairingTargetedUnavailableData(
paymentSessionId: paymentSessionId ?? (current is PairingTargetedWaitingData ? current.paymentSessionId : ''), paymentRequestId: paymentRequestId ?? (current is PairingTargetedWaitingData ? current.paymentRequestId : ''),
mitraName: mitraName, mitraName: mitraName,
topicSensitivity: carriedTopic, topicSensitivity: carriedTopic,
cause: type == WsMessage.returningChatTimeout cause: type == WsMessage.returningChatTimeout

View File

@@ -6,7 +6,7 @@ import '../../../core/pairing/pairing_notifier.dart';
/// Terminal failed-pairing screen. /// Terminal failed-pairing screen.
/// ///
/// Reached when the pairing notifier transitions to [PairingFailedData] /// 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 /// recorded). Copy is intentionally identical regardless of `cause_tag` for
/// now (the design pass will revise this later). /// now (the design pass will revise this later).
/// ///

View File

@@ -70,7 +70,7 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
if (draft.targetedMitraId != null) { if (draft.targetedMitraId != null) {
// ignore: discarded_futures // ignore: discarded_futures
ref.read(pairingProvider.notifier).startTargetedSearch( ref.read(pairingProvider.notifier).startTargetedSearch(
paymentSessionId: draft.paymentId!, paymentRequestId: draft.paymentId!,
mitraId: draft.targetedMitraId!, mitraId: draft.targetedMitraId!,
mitraName: draft.targetedMitraName ?? 'Bestie', mitraName: draft.targetedMitraName ?? 'Bestie',
topicSensitivity: draft.topicSensitivity, topicSensitivity: draft.topicSensitivity,
@@ -80,7 +80,7 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
} }
// ignore: discarded_futures // ignore: discarded_futures
ref.read(pairingProvider.notifier).startSearch( ref.read(pairingProvider.notifier).startSearch(
paymentSessionId: draft.paymentId!, paymentRequestId: draft.paymentId!,
topicSensitivity: draft.topicSensitivity, topicSensitivity: draft.topicSensitivity,
); );
} }
@@ -117,7 +117,7 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
context, context,
variant: BestieOfflineVariant.returning, variant: BestieOfflineVariant.returning,
mitraName: next.mitraName, mitraName: next.mitraName,
paymentSessionId: next.paymentSessionId, paymentRequestId: next.paymentRequestId,
topicSensitivity: next.topicSensitivity, topicSensitivity: next.topicSensitivity,
).then((_) { ).then((_) {
if (mounted) _unavailableDialogShown = false; if (mounted) _unavailableDialogShown = false;

View File

@@ -64,7 +64,7 @@ class _TargetedWaitingScreenState extends ConsumerState<TargetedWaitingScreen> {
context, context,
variant: BestieOfflineVariant.returning, variant: BestieOfflineVariant.returning,
mitraName: next.mitraName, mitraName: next.mitraName,
paymentSessionId: next.paymentSessionId, paymentRequestId: next.paymentRequestId,
topicSensitivity: next.topicSensitivity, topicSensitivity: next.topicSensitivity,
).then((_) { ).then((_) {
if (mounted) _popupShown = false; if (mounted) _popupShown = false;

View File

@@ -23,7 +23,7 @@ import '../../support/widgets/tanya_admin_sheet.dart';
/// payment session exists yet, so the "cari bestie lain" CTA resets the /// payment session exists yet, so the "cari bestie lain" CTA resets the
/// payment draft and pushes `/payment/entry` for a fresh blast-payment /// payment draft and pushes `/payment/entry` for a fresh blast-payment
/// flow. This branch never calls [Pairing.fallbackToBlast] because there's /// 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 /// - [BestieOfflineVariant.new_] — the customer triggered a general blast
/// that bottomed out (no online besties). No fallback button; just a /// that bottomed out (no online besties). No fallback button; just a
/// ghost `tanya admin` and a `kembali ke home` exit. /// ghost `tanya admin` and a `kembali ke home` exit.
@@ -35,14 +35,14 @@ enum BestieOfflineVariant { returning, prePayReturning, new_ }
class BestieOfflinePopup extends ConsumerWidget { class BestieOfflinePopup extends ConsumerWidget {
final BestieOfflineVariant variant; final BestieOfflineVariant variant;
final String mitraName; final String mitraName;
final String? paymentSessionId; final String? paymentRequestId;
final TopicSensitivity? topicSensitivity; final TopicSensitivity? topicSensitivity;
const BestieOfflinePopup({ const BestieOfflinePopup({
super.key, super.key,
required this.variant, required this.variant,
required this.mitraName, required this.mitraName,
this.paymentSessionId, this.paymentRequestId,
this.topicSensitivity, this.topicSensitivity,
}); });
@@ -50,7 +50,7 @@ class BestieOfflinePopup extends ConsumerWidget {
BuildContext context, { BuildContext context, {
required BestieOfflineVariant variant, required BestieOfflineVariant variant,
required String mitraName, required String mitraName,
String? paymentSessionId, String? paymentRequestId,
TopicSensitivity? topicSensitivity, TopicSensitivity? topicSensitivity,
}) { }) {
return showDialog<void>( return showDialog<void>(
@@ -60,7 +60,7 @@ class BestieOfflinePopup extends ConsumerWidget {
builder: (_) => BestieOfflinePopup( builder: (_) => BestieOfflinePopup(
variant: variant, variant: variant,
mitraName: mitraName, mitraName: mitraName,
paymentSessionId: paymentSessionId, paymentRequestId: paymentRequestId,
topicSensitivity: topicSensitivity, topicSensitivity: topicSensitivity,
), ),
); );
@@ -83,7 +83,7 @@ class BestieOfflinePopup extends ConsumerWidget {
final canFallbackToBlast = isReturning && final canFallbackToBlast = isReturning &&
hasOtherAvailable && hasOtherAvailable &&
paymentSessionId != null && paymentRequestId != null &&
topicSensitivity != null; topicSensitivity != null;
return Dialog( return Dialog(
@@ -145,7 +145,7 @@ class BestieOfflinePopup extends ConsumerWidget {
Navigator.of(context).pop(); Navigator.of(context).pop();
// ignore: discarded_futures // ignore: discarded_futures
ref.read(pairingProvider.notifier).fallbackToBlast( ref.read(pairingProvider.notifier).fallbackToBlast(
paymentSessionId: paymentSessionId!, paymentRequestId: paymentRequestId!,
topicSensitivity: topicSensitivity!, topicSensitivity: topicSensitivity!,
); );
}, },

View File

@@ -4,7 +4,7 @@ import '../../../core/auth/auth_notifier.dart';
/// One row in the Chat Tab > Pembayaran sub-tab. /// 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 /// is either an initial-session payment (`isExtension == false`) — for which
/// mitra info is only present in the targeted "Curhat lagi" flow — or an /// mitra info is only present in the targeted "Curhat lagi" flow — or an
/// extension payment (`isExtension == true`) — mitra info resolved by the /// extension payment (`isExtension == true`) — mitra info resolved by the
@@ -80,7 +80,7 @@ final pendingPaymentsProvider =
if (customerId == null) return PendingPaymentsData.empty; if (customerId == null) return PendingPaymentsData.empty;
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
final response = final response =
await api.get('/api/client/payment-sessions/pending'); await api.get('/api/client/payment-requests/pending');
final data = response['data'] as Map<String, dynamic>? ?? const {}; final data = response['data'] as Map<String, dynamic>? ?? const {};
final items = (data['items'] as List<dynamic>? ?? []) final items = (data['items'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>() .cast<Map<String, dynamic>>()

View File

@@ -9,7 +9,7 @@ import '../../../core/theme/widgets/halo_button.dart';
import '../state/payment_draft_provider.dart'; import '../state/payment_draft_provider.dart';
/// "Cara bayar" — QRIS-first list of payment methods. On tap of `bayar`: /// "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`. /// 2. Push `/payment/waiting/:paymentId`.
class PaymentMethodScreen extends ConsumerStatefulWidget { class PaymentMethodScreen extends ConsumerStatefulWidget {
const PaymentMethodScreen({super.key}); const PaymentMethodScreen({super.key});
@@ -72,7 +72,7 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
}; };
// Trailing slash matches the existing payment_notifier path — Fastify // Trailing slash matches the existing payment_notifier path — Fastify
// is not configured with `ignoreTrailingSlash`. // 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<String, dynamic>; final data = response['data'] as Map<String, dynamic>;
final paymentId = data['id'] as String; final paymentId = data['id'] as String;
ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId); ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId);

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../core/api/api_client_provider.dart'; import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart'; import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart'; import '../../../core/theme/halo_tokens.dart';
@@ -36,6 +37,7 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
bool _initialLoading = true; bool _initialLoading = true;
bool _terminal = false; bool _terminal = false;
String? _error; String? _error;
bool _invoiceUrlLaunched = false; // Phase 5: only auto-launch the Custom Tab once
Duration get _remaining { Duration get _remaining {
final exp = _expiresAt; final exp = _expiresAt;
@@ -80,11 +82,34 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
_qrPayload = (session['qr_string'] as String?) ?? widget.paymentId; _qrPayload = (session['qr_string'] as String?) ?? widget.paymentId;
_initialLoading = false; _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); _maybeHandleStatus(session);
_startTicker(); _startTicker();
_resumePolling(); _resumePolling();
} }
Future<void> _maybeLaunchInvoiceUrl(Map<String, dynamic> 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() { void _startTicker() {
_ticker?.cancel(); _ticker?.cancel();
_ticker = Timer.periodic(_tickInterval, (_) { _ticker = Timer.periodic(_tickInterval, (_) {
@@ -111,7 +136,7 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
Future<Map<String, dynamic>?> _fetchSession() async { Future<Map<String, dynamic>?> _fetchSession() async {
try { try {
final api = ref.read(apiClientProvider); 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<String, dynamic>?; return response['data'] as Map<String, dynamic>?;
} catch (e) { } catch (e) {
if (!mounted) return null; if (!mounted) return null;
@@ -122,12 +147,12 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
void _maybeHandleStatus(Map<String, dynamic> session) { void _maybeHandleStatus(Map<String, dynamic> session) {
final status = session['status'] as String?; final status = session['status'] as String?;
if (status == PaymentSessionStatus.confirmed || if (status == PaymentRequestStatus.confirmed ||
status == PaymentSessionStatus.consumed) { status == PaymentRequestStatus.consumed) {
_markTerminal(); _markTerminal();
_navigateTerminal('/onboarding/notif-gate'); _navigateTerminal('/onboarding/notif-gate');
} else if (status == PaymentSessionStatus.expired || } else if (status == PaymentRequestStatus.expired ||
status == PaymentSessionStatus.abandoned) { status == PaymentRequestStatus.abandoned) {
_markTerminal(); _markTerminal();
_navigateTerminal('/payment/expired/${widget.paymentId}'); _navigateTerminal('/payment/expired/${widget.paymentId}');
} }

View File

@@ -8,7 +8,7 @@ export const PairingFailureCause = Object.freeze({
TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline', TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline',
TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected', TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected',
TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout', TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout',
PAYMENT_SESSION_EXPIRED: 'payment_session_expired', PAYMENT_REQUEST_EXPIRED: 'payment_request_expired',
CUSTOMER_CANCELLED: 'customer_cancelled', CUSTOMER_CANCELLED: 'customer_cancelled',
EXTENSION_REJECTED: 'extension_rejected', EXTENSION_REJECTED: 'extension_rejected',
EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped', 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_OFFLINE]: 'Targeted mitra offline',
[PairingFailureCause.TARGETED_MITRA_REJECTED]: 'Targeted mitra rejected', [PairingFailureCause.TARGETED_MITRA_REJECTED]: 'Targeted mitra rejected',
[PairingFailureCause.TARGETED_MITRA_TIMEOUT]: 'Targeted mitra timeout', [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.CUSTOMER_CANCELLED]: 'Customer cancelled',
[PairingFailureCause.EXTENSION_REJECTED]: 'Extension rejected', [PairingFailureCause.EXTENSION_REJECTED]: 'Extension rejected',
[PairingFailureCause.EXTENSION_SAFEGUARD_TRIPPED]: 'Extension safeguard tripped', [PairingFailureCause.EXTENSION_SAFEGUARD_TRIPPED]: 'Extension safeguard tripped',

File diff suppressed because it is too large Load Diff