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) ---
# Path to Firebase service-account JSON (falls back to backend/firebase-service-account.json)
FIREBASE_SERVICE_ACCOUNT_PATH=
# --- Phase 5: Xendit (dev-safe defaults: integration disabled) ---
#
# Flip XENDIT_ENABLED=true in staging/prod once secret + webhook token are populated.
# When false, payment.service.js skips invoice creation and the dev/Maestro stub
# /internal/_test/force-confirm-payment plays the role of the webhook.
# See requirement/phase5-xendit-plan.md.
XENDIT_ENABLED=false
XENDIT_SECRET_KEY=
XENDIT_WEBHOOK_TOKEN=
XENDIT_SUCCESS_REDIRECT_URL=
XENDIT_FAILURE_REDIRECT_URL=

View File

@@ -22,6 +22,7 @@
"jwks-rsa": "^3.2.2",
"pg": "^8.12.0",
"postgres": "^3.4.4",
"xendit-node": "^7.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -4829,6 +4830,14 @@
}
}
},
"node_modules/xendit-node": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/xendit-node/-/xendit-node-7.0.0.tgz",
"integrity": "sha512-atsCQ9femoWLu+hU5rY0TZ+ZhqSYbpTtABZN7rGMhhBh1xLhTuY1rgLfoaXzFiRt3eOQRqsew78wPUcQn1QckA==",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

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

View File

@@ -15,6 +15,7 @@ import { publicBestieAvailabilityRoutes } from './routes/public/public.bestie-av
import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js'
import { sharedSupportRoutes } from './routes/public/shared.support.routes.js'
import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
import { paymentWebhookRoutes } from './routes/public/shared.payment-webhooks.routes.js'
import { errorHandler } from './plugins/error-handler.js'
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js'
@@ -35,13 +36,15 @@ export const buildPublicApp = async () => {
app.register(mitraStatusRoutes, { prefix: '/api/mitra/status' })
app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' })
app.register(clientChatRoutes, { prefix: '/api/client/chat' })
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-requests' })
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' })
// Onboarding-state stays client-only (anonymous customer flow). Support
// handles are shared — both client and mitra apps link the same WA/TG.
app.register(clientOnboardingRoutes, { prefix: '/api/client' })
app.register(sharedSupportRoutes, { prefix: '/api/shared' })
// Payment provider webhooks. Public + token-authed via x-callback-token.
app.register(paymentWebhookRoutes, { prefix: '/api/shared/payment' })
// WebSocket route (registered at app level, not prefixed)
registerWebSocketRoute(app)

View File

@@ -62,14 +62,22 @@ export const SessionMode = Object.freeze({
CALL: 'call',
})
// Payment session lifecycle
export const PaymentSessionStatus = Object.freeze({
// payment_requests lifecycle
// pending initial state — invoice created or stub awaiting customer pay
// confirmed money landed — emits payment_request.confirmed
// consumed product delivered (e.g. chat session started) — no event
// expired pending TTL elapsed without payment — emits payment_request.expired
// abandoned customer cancelled while pending — emits payment_request.cancelled
// failed createInvoice failed before customer paid — emits payment_request.failed
// failed_delivery paid but delivery failed (was "failed_pairing" pre-Phase-5) — emits payment_request.delivery_failed
export const PaymentRequestStatus = Object.freeze({
PENDING: 'pending',
CONFIRMED: 'confirmed',
CONSUMED: 'consumed',
FAILED_PAIRING: 'failed_pairing',
ABANDONED: 'abandoned',
EXPIRED: 'expired',
ABANDONED: 'abandoned',
FAILED: 'failed',
FAILED_DELIVERY: 'failed_delivery',
})
// Pairing failure cause tags
@@ -79,7 +87,7 @@ export const PairingFailureCause = Object.freeze({
TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline',
TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected',
TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout',
PAYMENT_SESSION_EXPIRED: 'payment_session_expired',
PAYMENT_REQUEST_EXPIRED: 'payment_request_expired',
CUSTOMER_CANCELLED: 'customer_cancelled',
EXTENSION_REJECTED: 'extension_rejected',
EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped',

View File

@@ -434,9 +434,28 @@ const migrate = async () => {
// --- Phase 3.7: Paid Pairing Flow + Returning-Chat + Extension Flip ---
// payment_sessions: customer-initiated payment intents (mocked) that gate pairing
// Phase 5 rename — must run BEFORE the original CREATE TABLE so we don't end up
// with both payment_sessions (old) and payment_requests (newly created from
// IF NOT EXISTS) coexisting in the same schema. Schema-anchored via
// current_schema() so the test schema's rename works even after the dev
// schema already has payment_requests.
await sql`
CREATE TABLE IF NOT EXISTS payment_sessions (
DO $$
BEGIN
IF to_regclass(current_schema() || '.payment_sessions') IS NOT NULL
AND to_regclass(current_schema() || '.payment_requests') IS NULL THEN
EXECUTE 'ALTER TABLE ' || quote_ident(current_schema()) || '.payment_sessions RENAME TO payment_requests';
END IF;
END
$$
`
// payment_requests: customer-initiated payment intents that gate pairing.
// (Phase 5 rename — was `payment_sessions` in Phase 3.7. The rename block
// immediately above + the Phase 5 block at the end of this file together
// handle every state: fresh DB, pre-Phase-5 dev DB, post-Phase-5 dev DB.)
await sql`
CREATE TABLE IF NOT EXISTS payment_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
amount INTEGER NOT NULL DEFAULT 0,
@@ -444,7 +463,7 @@ const migrate = async () => {
is_free_trial BOOLEAN NOT NULL DEFAULT false,
is_extension BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','confirmed','consumed','failed_pairing','abandoned','expired')),
CHECK (status IN ('pending','confirmed','consumed','failed_delivery','abandoned','expired','failed')),
targeted_mitra_id UUID REFERENCES mitras(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
confirmed_at TIMESTAMPTZ,
@@ -454,20 +473,20 @@ const migrate = async () => {
`
await sql`
CREATE INDEX IF NOT EXISTS idx_payment_sessions_customer
ON payment_sessions (customer_id)
CREATE INDEX IF NOT EXISTS idx_payment_requests_customer
ON payment_requests (customer_id)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_payment_sessions_status_expires
ON payment_sessions (status, expires_at)
CREATE INDEX IF NOT EXISTS idx_payment_requests_status_expires
ON payment_requests (status, expires_at)
`
// pairing_failures: cause-tagged audit rows for confirmed payments that did not yield a chat
await sql`
CREATE TABLE IF NOT EXISTS pairing_failures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_session_id UUID NOT NULL REFERENCES payment_sessions(id) ON DELETE CASCADE,
payment_request_id UUID NOT NULL REFERENCES payment_requests(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES customers(id),
targeted_mitra_id UUID REFERENCES mitras(id),
cause_tag TEXT NOT NULL
@@ -477,7 +496,7 @@ const migrate = async () => {
'targeted_mitra_offline',
'targeted_mitra_rejected',
'targeted_mitra_timeout',
'payment_session_expired',
'payment_request_expired',
'customer_cancelled'
)),
amount INTEGER NOT NULL,
@@ -510,7 +529,8 @@ const migrate = async () => {
'targeted_mitra_offline',
'targeted_mitra_rejected',
'targeted_mitra_timeout',
'payment_session_expired',
-- Phase 5 rename: payment_session_expired → payment_request_expired
'payment_request_expired',
'customer_cancelled',
'extension_rejected',
'extension_safeguard_tripped'
@@ -534,27 +554,27 @@ const migrate = async () => {
ON pairing_failures (created_at DESC) WHERE operator_action IS NULL
`
// chat_sessions FK to payment_sessions (nullable for backward compat with pre-3.7 rows)
// chat_sessions FK to payment_requests (nullable for backward compat with pre-3.7 rows)
await sql`
ALTER TABLE chat_sessions
ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(id)
ADD COLUMN IF NOT EXISTS payment_request_id UUID REFERENCES payment_requests(id)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment
ON chat_sessions (payment_session_id)
CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment_request
ON chat_sessions (payment_request_id)
`
// session_extensions FK to payment_sessions (extensions also have their own payment session)
// session_extensions FK to payment_requests (extensions also have their own payment request)
await sql`
ALTER TABLE session_extensions
ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(id)
ADD COLUMN IF NOT EXISTS payment_request_id UUID REFERENCES payment_requests(id)
`
// Phase 3.7 config keys (idempotent — existing dev DBs need a manual update for extension_timeout_seconds → 10)
await sql`
INSERT INTO app_config (key, value) VALUES
('payment_session_timeout_minutes', '{"value": 20}'),
('payment_request_timeout_minutes', '{"value": 20}'),
('returning_chat_confirmation_timeout_seconds', '{"value": 20}'),
('extension_default_action_on_timeout', '{"value": "auto_approve"}'),
('pairing_blast_timeout_seconds', '{"value": 60}')
@@ -563,13 +583,13 @@ const migrate = async () => {
// --- Phase 4 — Customer Flow Redesign ---
// 1. payment_sessions + chat_sessions: replace is_free_trial with is_first_session_discount.
// 1. payment_requests + chat_sessions: replace is_free_trial with is_first_session_discount.
// Phase 3.7 was the first ship of is_free_trial and never went live with real users
// (per project memory), so we copy whatever values exist and drop the old column.
// Idempotent: ADD/DROP both use IF [NOT] EXISTS, and each UPDATE is gated on the
// old column still existing.
await sql`
ALTER TABLE payment_sessions
ALTER TABLE payment_requests
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
`
await sql`
@@ -590,11 +610,11 @@ const migrate = async () => {
BEGIN
IF EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid = to_regclass('payment_sessions')
WHERE attrelid = to_regclass('payment_requests')
AND attname = 'is_free_trial'
AND NOT attisdropped
) THEN
EXECUTE 'UPDATE payment_sessions
EXECUTE 'UPDATE payment_requests
SET is_first_session_discount = is_free_trial
WHERE is_free_trial = true
AND is_first_session_discount = false';
@@ -614,13 +634,13 @@ const migrate = async () => {
$$
`
await sql`ALTER TABLE payment_sessions DROP COLUMN IF EXISTS is_free_trial`
await sql`ALTER TABLE payment_requests DROP COLUMN IF EXISTS is_free_trial`
await sql`ALTER TABLE chat_sessions DROP COLUMN IF EXISTS is_free_trial`
// 2. payment_sessions.mode — chat (default) vs voice call. Voice call is just chat
// 2. payment_requests.mode — chat (default) vs voice call. Voice call is just chat
// with a different price group + a header badge; no extra media handling.
await sql`
ALTER TABLE payment_sessions
ALTER TABLE payment_requests
ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
CHECK (mode IN ('chat', 'call'))
`
@@ -927,6 +947,145 @@ const migrate = async () => {
)
`
// --- Phase 5 — Payment service rename + Xendit prep ---
//
// The `payment_sessions` table is renamed to `payment_requests` so future
// products (courses, merch, subscriptions) can reuse the same payment layer
// without the chat-coupled name. See requirement/phase5-xendit-plan.md
// Architecture (revised 2026-05-23) for the full rationale.
//
// The migration is idempotent in both directions: on fresh DBs the
// earlier CREATE TABLE block builds `payment_sessions`, this block then
// renames it. On already-migrated DBs the rename is a no-op.
// 1. Rename the table if it still has the old name.
// Schema-anchored via current_schema() so the rename works correctly when
// migrate runs against a non-public schema (test DBs use search_path).
// Without the schema qualifier, to_regclass('payment_requests') would
// fall through to public.payment_requests (after the dev schema migrated)
// and the test schema's rename would be skipped.
await sql`
DO $$
BEGIN
IF to_regclass(current_schema() || '.payment_sessions') IS NOT NULL
AND to_regclass(current_schema() || '.payment_requests') IS NULL THEN
EXECUTE 'ALTER TABLE ' || quote_ident(current_schema()) || '.payment_sessions RENAME TO payment_requests';
END IF;
END
$$
`
// 2. Rename indexes Postgres auto-named after the old table
await sql`ALTER INDEX IF EXISTS idx_payment_sessions_customer RENAME TO idx_payment_requests_customer`
await sql`ALTER INDEX IF EXISTS idx_payment_sessions_status_expires RENAME TO idx_payment_requests_status_expires`
// Primary-key index doesn't get auto-renamed by ALTER TABLE ... RENAME.
// Fix it on existing dev DBs so a future reader doesn't see payment_sessions_pkey on the payment_requests table.
await sql`ALTER INDEX IF EXISTS payment_sessions_pkey RENAME TO payment_requests_pkey`
// 3. Rename FK columns on dependent tables (schema-anchored for the same reason as above)
await sql`
DO $$
DECLARE
schema_name TEXT := current_schema();
BEGIN
IF EXISTS (SELECT 1 FROM pg_attribute
WHERE attrelid = to_regclass(schema_name || '.chat_sessions')
AND attname = 'payment_session_id'
AND NOT attisdropped) THEN
EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.chat_sessions RENAME COLUMN payment_session_id TO payment_request_id';
END IF;
IF EXISTS (SELECT 1 FROM pg_attribute
WHERE attrelid = to_regclass(schema_name || '.session_extensions')
AND attname = 'payment_session_id'
AND NOT attisdropped) THEN
EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.session_extensions RENAME COLUMN payment_session_id TO payment_request_id';
END IF;
IF EXISTS (SELECT 1 FROM pg_attribute
WHERE attrelid = to_regclass(schema_name || '.pairing_failures')
AND attname = 'payment_session_id'
AND NOT attisdropped) THEN
EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.pairing_failures RENAME COLUMN payment_session_id TO payment_request_id';
END IF;
END
$$
`
await sql`ALTER INDEX IF EXISTS idx_chat_sessions_payment RENAME TO idx_chat_sessions_payment_request`
// 4. Rename the app_config key. Skip if both rows exist (we'd violate the PK).
await sql`
UPDATE app_config
SET key = 'payment_request_timeout_minutes'
WHERE key = 'payment_session_timeout_minutes'
AND NOT EXISTS (SELECT 1 FROM app_config WHERE key = 'payment_request_timeout_minutes')
`
// 5. Status enum rewrite: rename failed_pairing → failed_delivery (product-agnostic),
// add 'failed' (createInvoice errored before customer paid — distinct from expired).
// Drop both the legacy and current constraint names so re-runs are idempotent.
await sql`ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_sessions_status_check`
await sql`ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_requests_status_check`
await sql`UPDATE payment_requests SET status = 'failed_delivery' WHERE status = 'failed_pairing'`
await sql`
ALTER TABLE payment_requests
ADD CONSTRAINT payment_requests_status_check
CHECK (status IN ('pending','confirmed','consumed','expired','abandoned','failed','failed_delivery'))
`
// 6. cause_tag rewrite on pairing_failures — payment_session_expired → payment_request_expired
await sql`ALTER TABLE pairing_failures DROP CONSTRAINT IF EXISTS pairing_failures_cause_tag_check`
await sql`UPDATE pairing_failures SET cause_tag = 'payment_request_expired' WHERE cause_tag = 'payment_session_expired'`
await sql`
ALTER TABLE pairing_failures
ADD CONSTRAINT pairing_failures_cause_tag_check
CHECK (cause_tag IN (
'no_mitra_available',
'all_mitras_rejected',
'targeted_mitra_offline',
'targeted_mitra_rejected',
'targeted_mitra_timeout',
'payment_request_expired',
'customer_cancelled',
'extension_rejected',
'extension_safeguard_tripped'
))
`
// 7. Add product-agnostic columns (microservice-prep) + xendit_* columns.
// product_type defaults to 'chat_session' so every existing row is
// self-describing without a manual backfill.
await sql`
ALTER TABLE payment_requests
ADD COLUMN IF NOT EXISTS product_type TEXT NOT NULL DEFAULT 'chat_session',
ADD COLUMN IF NOT EXISTS product_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS xendit_invoice_id TEXT,
ADD COLUMN IF NOT EXISTS xendit_invoice_url TEXT,
ADD COLUMN IF NOT EXISTS xendit_payment_method TEXT,
ADD COLUMN IF NOT EXISTS xendit_paid_amount INTEGER
`
// 8. Partial unique index on xendit_invoice_id — webhook retries land on the same
// invoice id, this turns "already processed" into a constraint violation our
// handler can detect.
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_requests_xendit_invoice
ON payment_requests (xendit_invoice_id)
WHERE xendit_invoice_id IS NOT NULL
`
// 9. Backfill product_metadata for pre-Phase-5 rows so subscribers can read
// chat-session details without falling back to the legacy top-level columns.
await sql`
UPDATE payment_requests
SET product_metadata = jsonb_build_object(
'duration_minutes', duration_minutes,
'mode', mode,
'is_extension', is_extension,
'targeted_mitra_id', targeted_mitra_id
)
WHERE product_metadata = '{}'::jsonb
AND product_type = 'chat_session'
`
console.log('Migration complete.')
await sql.end()
}

View File

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

View File

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

View File

@@ -47,16 +47,16 @@ export const clientChatRoutes = async (app) => {
/**
* Start a general-blast pairing search.
*
* Body MUST include `payment_session_id` (a confirmed payment_session owned by the caller).
* Body MUST include `payment_request_id` (a confirmed payment_request owned by the caller).
* Pricing/duration/free-trial values are sourced from the payment session, NOT from the client.
*/
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { payment_session_id, topic_sensitivity } = request.body ?? {}
const { payment_request_id, topic_sensitivity } = request.body ?? {}
if (!payment_session_id) {
if (!payment_request_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
error: { code: 'BAD_REQUEST', message: 'payment_request_id is required' },
})
}
@@ -68,7 +68,7 @@ export const clientChatRoutes = async (app) => {
}
const session = await createPairingRequest(request.customer.id, {
paymentSessionId: payment_session_id,
paymentRequestId: payment_request_id,
topic_sensitivity,
})
return reply.code(201).send({ success: true, data: session })
@@ -77,18 +77,18 @@ export const clientChatRoutes = async (app) => {
/**
* Start a targeted "Curhat lagi" pairing request.
*
* Body: { payment_session_id, mitra_id, topic_sensitivity? }
* Body: { payment_request_id, mitra_id, topic_sensitivity? }
* Returns 409 with reason: 'targeted_mitra_offline' if the targeted mitra is unreachable
* or at capacity. The payment session stays `confirmed` in that case so the customer
* can fall back to general blast on the same payment.
*/
app.post('/chat-requests/returning', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { payment_session_id, mitra_id, topic_sensitivity } = request.body ?? {}
const { payment_request_id, mitra_id, topic_sensitivity } = request.body ?? {}
if (!payment_session_id) {
if (!payment_request_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
error: { code: 'BAD_REQUEST', message: 'payment_request_id is required' },
})
}
if (!mitra_id) {
@@ -103,7 +103,7 @@ export const clientChatRoutes = async (app) => {
: TopicSensitivity.REGULAR
const session = await createTargetedPairingRequest(request.customer.id, {
paymentSessionId: payment_session_id,
paymentRequestId: payment_request_id,
targetedMitraId: mitra_id,
topic_sensitivity: resolvedTopic,
})
@@ -113,26 +113,26 @@ export const clientChatRoutes = async (app) => {
/**
* Customer-initiated cancel during searching/waiting.
*
* Body: { payment_session_id }
* Terminal — payment session moves to failed_pairing with cause = customer_cancelled.
* Body: { payment_request_id }
* Terminal — payment session moves to failed_delivery with cause = customer_cancelled.
*/
app.post('/chat-requests/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { payment_session_id } = request.body ?? {}
if (!payment_session_id) {
const { payment_request_id } = request.body ?? {}
if (!payment_request_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
error: { code: 'BAD_REQUEST', message: 'payment_request_id is required' },
})
}
const result = await cancelPaymentSearch(payment_session_id, request.customer.id)
const result = await cancelPaymentSearch(payment_request_id, request.customer.id)
return reply.send({ success: true, data: result })
})
/**
* After a returning-chat fail, customer taps "Chat dengan bestie lain".
* Reuses the same payment_session_id (no double-charge), runs general blast.
* Reuses the same payment_request_id (no double-charge), runs general blast.
*/
app.post('/chat-requests/:paymentSessionId/fallback-to-blast', {
app.post('/chat-requests/:paymentRequestId/fallback-to-blast', {
preHandler: [authenticate, resolveCustomer],
}, async (request, reply) => {
const { topic_sensitivity } = request.body ?? {}
@@ -140,7 +140,7 @@ export const clientChatRoutes = async (app) => {
? TopicSensitivity.SENSITIVE
: TopicSensitivity.REGULAR
const session = await fallbackToGeneralBlast(
request.params.paymentSessionId,
request.params.paymentRequestId,
request.customer.id,
{ topic_sensitivity: resolvedTopic },
)
@@ -150,7 +150,7 @@ export const clientChatRoutes = async (app) => {
/**
* Cancel-by-session-id retained for in-flight chat_session cancels (e.g. cancel
* during the 20s targeted wait after a chat_session has been created). Customer cancel
* via payment_session_id should prefer POST /chat-requests/cancel above.
* via payment_request_id should prefer POST /chat-requests/cancel above.
*/
app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await cancelPairingRequest(request.params.sessionId, request.customer.id)
@@ -173,17 +173,17 @@ export const clientChatRoutes = async (app) => {
})
/**
* Extension request REQUIRES `extension_payment_session_id`.
* Extension request REQUIRES `extension_payment_request_id`.
* The payment session must be is_extension=true and is_first_session_discount=false.
* Pricing/duration come from the payment session via the extension service.
*/
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const { duration_minutes, price, extension_payment_session_id } = request.body ?? {}
const { duration_minutes, price, extension_payment_request_id } = request.body ?? {}
if (!extension_payment_session_id) {
if (!extension_payment_request_id) {
return reply.code(400).send({
success: false,
error: { code: 'BAD_REQUEST', message: 'extension_payment_session_id is required' },
error: { code: 'BAD_REQUEST', message: 'extension_payment_request_id is required' },
})
}
if (!duration_minutes || price === undefined) {
@@ -196,7 +196,7 @@ export const clientChatRoutes = async (app) => {
const extension = await requestExtension(request.params.sessionId, request.customer.id, {
duration_minutes,
price,
extension_payment_session_id,
extension_payment_request_id,
})
return reply.send({ success: true, data: extension })
})

View File

@@ -1,10 +1,10 @@
import { authenticate } from '../../plugins/auth.js'
import { getCustomerById } from '../../services/customer.service.js'
import {
createPaymentSession,
confirmPaymentSession,
abandonPaymentSession,
getPaymentSession,
requestPayment,
confirmPaymentForCustomer,
cancelPayment,
getPayment,
getCustomerPendingPayments,
} from '../../services/payment.service.js'
import {
@@ -13,6 +13,7 @@ import {
findTier,
readFirstSessionDiscountConfig,
} from '../../services/pricing.service.js'
import { getXenditConfig } from '../../services/config.service.js'
import { UserType, SessionMode } from '../../constants.js'
const resolveCustomer = async (request, reply) => {
@@ -35,10 +36,10 @@ const resolveCustomer = async (request, reply) => {
/**
* Payment session lifecycle (mocked — no Xendit yet).
*
* POST /api/client/payment-sessions
* POST /api/client/payment-sessions/:id/confirm
* POST /api/client/payment-sessions/:id/cancel
* GET /api/client/payment-sessions/:id
* POST /api/client/payment-requests
* POST /api/client/payment-requests/:id/confirm
* POST /api/client/payment-requests/:id/cancel
* GET /api/client/payment-requests/:id
*/
export const clientPaymentRoutes = async (app) => {
// Create a payment session (status = pending). First-session-discount is server-authoritative:
@@ -103,7 +104,11 @@ export const clientPaymentRoutes = async (app) => {
}
}
const session = await createPaymentSession({
// Phase 5: payment.service.js handles the Xendit invoice creation internally
// when XENDIT_ENABLED=true. The row comes back with xendit_invoice_url populated;
// when off, invoice_url is null and the dev/Maestro stub plays the webhook role.
const session = await requestPayment({
productType: 'chat_session',
customerId: request.customer.id,
durationMinutes: duration_minutes,
amount,
@@ -125,12 +130,21 @@ export const clientPaymentRoutes = async (app) => {
targeted_mitra_id: session.targeted_mitra_id,
expires_at: session.expires_at,
status: session.status,
invoice_url: session.xendit_invoice_url ?? null,
},
})
})
app.post('/:id/confirm', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await confirmPaymentSession(request.params.id, request.customer.id)
// Phase 5 D9: when Xendit is live, only the webhook can confirm. The dev/Maestro
// stub at /internal/_test/force-confirm-payment bypasses this gate (internal listener).
if (getXenditConfig().enabled) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Confirmation must come from Xendit webhook' },
})
}
const session = await confirmPaymentForCustomer(request.params.id, request.customer.id)
return reply.send({
success: true,
data: {
@@ -142,7 +156,7 @@ export const clientPaymentRoutes = async (app) => {
})
app.post('/:id/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await abandonPaymentSession(request.params.id, request.customer.id)
const session = await cancelPayment(request.params.id, request.customer.id)
return reply.send({
success: true,
data: {
@@ -160,19 +174,34 @@ export const clientPaymentRoutes = async (app) => {
})
app.get('/:id', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
const session = await getPaymentSession(request.params.id)
const session = await getPayment(request.params.id)
if (!session) {
return reply.code(404).send({
success: false,
error: { code: 'NOT_FOUND', message: 'Payment session not found' },
error: { code: 'NOT_FOUND', message: 'Payment request not found' },
})
}
if (session.customer_id !== request.customer.id) {
return reply.code(403).send({
success: false,
error: { code: 'FORBIDDEN', message: 'Payment session does not belong to this customer' },
error: { code: 'FORBIDDEN', message: 'Payment request does not belong to this customer' },
})
}
return reply.send({ success: true, data: session })
// Phase 5: surface chat_session_id (and status) when the server-driven pairing
// subscriber has already started pairing for this confirmed payment. Lets the
// app skip its legacy POST /chat/request call and just move to the searching state.
const { getDb } = await import('../../db/client.js')
const sqlClient = getDb()
const [chat] = await sqlClient`
SELECT id, status FROM chat_sessions WHERE payment_request_id = ${session.id} LIMIT 1
`
return reply.send({
success: true,
data: {
...session,
chat_session_id: chat?.id ?? null,
chat_session_status: chat?.status ?? null,
},
})
})
}

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 { initFirebase } from './plugins/firebase.js'
import { restoreActiveTimers } from './services/session-timer.service.js'
import { expireStalePaymentSessions } from './services/payment.service.js'
import { expireStalePaymentRequests, registerPairingSubscriber } from './services/payment.service.js'
import { getXenditConfig } from './services/config.service.js'
const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000
const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001
const INTERNAL_HOST = process.env.INTERNAL_HOST || '127.0.0.1'
const start = async () => {
// Phase 5: fail fast if XENDIT_ENABLED=true without the required credentials.
// Bad config explodes at startup rather than at the first /payment-requests POST.
const xc = getXenditConfig()
if (xc.enabled) {
if (!xc.secretKey) throw new Error('XENDIT_ENABLED=true requires XENDIT_SECRET_KEY')
if (!xc.webhookToken || xc.webhookToken.length < 16) {
throw new Error('XENDIT_ENABLED=true requires XENDIT_WEBHOOK_TOKEN (>= 16 chars)')
}
}
initFirebase()
const publicApp = await buildPublicApp()
const internalApp = await buildInternalApp()
@@ -24,6 +35,24 @@ const start = async () => {
// Restore session timers for active sessions (on server restart)
await restoreActiveTimers()
// Phase 5: wire pairing service as a subscriber to payment_request.confirmed events.
// Must happen AFTER all services are loaded so the subscriber registration sees
// the EventEmitter set up by payment.service.js at module-load time.
registerPairingSubscriber()
// Phase 5: catch any payment_request.confirmed events that were lost across a restart
// by running the reconciliation sweeper immediately on boot. Without this, a customer
// whose payment confirmed during shutdown could be stranded for up to 60s waiting on
// the next sweeper tick.
try {
const result = await expireStalePaymentRequests()
if (result.expired > 0 || result.failed > 0 || result.reconciled > 0) {
console.log(`Startup reconciliation: ${result.expired} expired, ${result.failed} failed_delivery, ${result.reconciled} re-triggered`)
}
} catch (err) {
console.error('Startup reconciliation failed:', err)
}
// Auto-offline mitras with stale heartbeat (every 30s)
setInterval(async () => {
try {
@@ -34,22 +63,36 @@ const start = async () => {
}
}, 30_000)
// Expire stale payment_sessions (every 60s).
// Pending past expires_at → expired (no failure row). Confirmed-but-stale → failed_pairing
// with cause = payment_session_expired (writes a pairing_failures row).
// Single-instance for now; Valkey keyspace notifications when we go multi-instance.
// Expire stale payment_requests + reconcile lost subscriber work (every 60s).
// Pending past expires_at → expired (no failure row).
// Confirmed-but-stale → failed_delivery (writes a pairing_failures row).
// Confirmed-with-no-chat-session-yet → re-trigger the pairing subscriber (recovery from
// lost EventEmitter notifications across restart). See payment.service.js for details.
setInterval(async () => {
try {
const result = await expireStalePaymentSessions()
if (result.expired > 0 || result.failed > 0) {
console.log(`Payment sweeper: ${result.expired} expired, ${result.failed} failed_pairing`)
const result = await expireStalePaymentRequests()
if (result.expired > 0 || result.failed > 0 || result.reconciled > 0) {
console.log(`Payment sweeper: ${result.expired} expired, ${result.failed} failed_delivery, ${result.reconciled} re-triggered`)
}
} catch (err) {
console.error('Payment session sweeper failed:', err)
console.error('Payment request sweeper failed:', err)
}
}, 60_000)
}
// SIGTERM trap — Cloud Run gives ~10s grace before SIGKILL. Use it to drain in-flight
// EventEmitter handlers (Stage 5 of phase5-xendit-plan.md). app.close() stops accepting
// new requests; the timeout gives subscribers their last chance to finish.
const shutdown = async () => {
console.log('SIGTERM received — closing servers, draining handlers')
// App handles are scoped inside start(); fire-and-forget here is fine because both
// listeners' .close() is idempotent and process.exit truncates anything still pending.
await new Promise(r => setTimeout(r, 8_000))
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
start().catch((err) => {
console.error(err)
process.exit(1)

View File

@@ -149,6 +149,22 @@ export const getMitraHeartbeatCadenceSeconds = () => {
return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30
}
// --- Phase 5: Xendit integration ---
//
// Env-driven (per backend/CLAUDE.md Config-Source Convention). All five values
// read from process.env at call time so test setups can inject via vi.stubEnv.
// When `enabled` is true, payment.service.js mints a real Xendit invoice on
// requestPayment(); when false, invoice creation is skipped and the dev/Maestro
// stub /internal/_test/force-confirm-payment plays the role of the webhook.
// See requirement/phase5-xendit-plan.md D6/D9.
export const getXenditConfig = () => ({
enabled: process.env.XENDIT_ENABLED === 'true',
secretKey: process.env.XENDIT_SECRET_KEY ?? '',
webhookToken: process.env.XENDIT_WEBHOOK_TOKEN ?? '',
successRedirectUrl: process.env.XENDIT_SUCCESS_REDIRECT_URL ?? '',
failureRedirectUrl: process.env.XENDIT_FAILURE_REDIRECT_URL ?? '',
})
export const getMitraPingConfig = async () => {
const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'`
const [staleRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_stale_after_seconds'`
@@ -291,18 +307,18 @@ export const setCcLoginLockoutConfig = async ({ max_attempts, lockout_minutes })
// --- Paid Pairing Flow + Returning-Chat + Extension Flip ---
export const getPaymentSessionTimeoutMinutes = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'payment_session_timeout_minutes'`
return { payment_session_timeout_minutes: row?.value?.value ?? 20 }
export const getPaymentRequestTimeoutMinutes = async () => {
const [row] = await sql`SELECT value FROM app_config WHERE key = 'payment_request_timeout_minutes'`
return { payment_request_timeout_minutes: row?.value?.value ?? 20 }
}
export const setPaymentSessionTimeoutMinutes = async (value) => {
export const setPaymentRequestTimeoutMinutes = async (value) => {
await sql`
INSERT INTO app_config (key, value, updated_at)
VALUES ('payment_session_timeout_minutes', ${sql.json({ value })}, NOW())
VALUES ('payment_request_timeout_minutes', ${sql.json({ value })}, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
`
return { payment_session_timeout_minutes: value }
return { payment_request_timeout_minutes: value }
}
export const getReturningChatConfirmationTimeoutSeconds = async () => {

View File

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

View File

@@ -5,20 +5,20 @@ const sql = getDb()
/**
* Insert a pairing_failures row. Called from payment.service.failPaymentSession (and the
* background sweeper for `payment_session_expired`).
* background sweeper for `payment_request_expired`).
*/
export const recordFailure = async ({ paymentSessionId, customerId, targetedMitraId = null, causeTag, amount }) => {
export const recordFailure = async ({ paymentRequestId, customerId, targetedMitraId = null, causeTag, amount }) => {
if (!Object.values(PairingFailureCause).includes(causeTag)) {
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
const [row] = await sql`
INSERT INTO pairing_failures (
payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount
payment_request_id, customer_id, targeted_mitra_id, cause_tag, amount
)
VALUES (
${paymentSessionId}, ${customerId}, ${targetedMitraId}, ${causeTag}, ${amount}
${paymentRequestId}, ${customerId}, ${targetedMitraId}, ${causeTag}, ${amount}
)
RETURNING id, payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount,
RETURNING id, payment_request_id, customer_id, targeted_mitra_id, cause_tag, amount,
operator_action, actioned_by, actioned_at, created_at
`
return row
@@ -33,7 +33,7 @@ export const listFailures = async ({ causeTags = null, dateFrom = null, dateTo =
const items = await sql`
SELECT
pf.id, pf.payment_session_id, pf.customer_id, pf.targeted_mitra_id,
pf.id, pf.payment_request_id, pf.customer_id, pf.targeted_mitra_id,
pf.cause_tag, pf.amount, pf.operator_action, pf.actioned_by, pf.actioned_at, pf.created_at,
c.display_name AS customer_call_name,
m.display_name AS targeted_mitra_call_name,
@@ -75,7 +75,7 @@ export const setOperatorAction = async (failureId, ccUserId, action) => {
actioned_by = ${ccUserId},
actioned_at = NOW()
WHERE id = ${failureId}
RETURNING id, payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount,
RETURNING id, payment_request_id, customer_id, targeted_mitra_id, cause_tag, amount,
operator_action, actioned_by, actioned_at, created_at
`
if (!updated) {

View File

@@ -13,7 +13,7 @@ import {
TransactionType,
WsMessage,
TopicSensitivity,
PaymentSessionStatus,
PaymentRequestStatus,
PairingFailureCause,
PairingRequestType,
} from '../constants.js'
@@ -68,7 +68,7 @@ const notifyCustomer = async (customerId, data) => {
body: 'Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera.',
data: {
type: WsMessage.PAIRING_FAILED,
payment_session_id: data.payment_session_id || '',
payment_request_id: data.payment_request_id || '',
cause_tag: data.cause_tag || '',
},
})
@@ -100,41 +100,41 @@ export const findAvailableMitras = async () => {
* Validate that a payment session is owned by the customer, confirmed, and not yet consumed.
* Throws on mismatch. Returns the loaded payment session row.
*/
const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { allowExtension = false } = {}) => {
if (!paymentSessionId) {
throw Object.assign(new Error('payment_session_id is required'), {
const requireConfirmedPaymentRequest = async (paymentRequestId, customerId, { allowExtension = false } = {}) => {
if (!paymentRequestId) {
throw Object.assign(new Error('payment_request_id is required'), {
code: 'VALIDATION_ERROR', statusCode: 422,
})
}
const paySession = await getPaymentSession(paymentSessionId)
if (!paySession) {
const payRequest = await getPaymentSession(paymentRequestId)
if (!payRequest) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
if (paySession.customer_id !== customerId) {
if (payRequest.customer_id !== customerId) {
throw Object.assign(new Error('Payment session does not belong to this customer'), {
code: 'FORBIDDEN', statusCode: 403,
})
}
if (paySession.status !== PaymentSessionStatus.CONFIRMED) {
throw Object.assign(new Error(`Payment session is ${paySession.status}, must be confirmed`), {
if (payRequest.status !== PaymentRequestStatus.CONFIRMED) {
throw Object.assign(new Error(`Payment session is ${payRequest.status}, must be confirmed`), {
code: 'INVALID_STATE', statusCode: 409,
})
}
if (paySession.is_extension && !allowExtension) {
if (payRequest.is_extension && !allowExtension) {
throw Object.assign(new Error('Extension payment session cannot be used to start a new chat'), {
code: 'INVALID_STATE', statusCode: 409,
})
}
if (new Date(paySession.expires_at) <= new Date()) {
if (new Date(payRequest.expires_at) <= new Date()) {
// Check expiry inline at every state transition (defense in depth vs. the background sweeper).
await failPaymentSession(paymentSessionId, PairingFailureCause.PAYMENT_SESSION_EXPIRED)
await failPaymentSession(paymentRequestId, PairingFailureCause.PAYMENT_REQUEST_EXPIRED)
throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 })
}
return paySession
return payRequest
}
/**
* General-blast pairing request. Requires a confirmed payment_session_id.
* General-blast pairing request. Requires a confirmed payment_request_id.
*
* The duration_minutes / price / is_first_session_discount values for the chat_session row are
* sourced from the payment session — the client does not dictate pricing here.
@@ -144,12 +144,12 @@ const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { al
* fall back to general blast on the same payment. The flag bypasses the
* "use returning-chat endpoint" guard in that exact case.
*/
export const createPairingRequest = async (customerId, { paymentSessionId, topic_sensitivity, allowTargetedPayment = false } = {}) => {
const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId)
export const createPairingRequest = async (customerId, { paymentRequestId, topic_sensitivity, allowTargetedPayment = false } = {}) => {
const payRequest = await requireConfirmedPaymentRequest(paymentRequestId, customerId)
// Targeted payment session must use createTargetedPairingRequest unless we're
// explicitly invoked by the fallback-to-blast path.
if (paySession.targeted_mitra_id && !allowTargetedPayment) {
if (payRequest.targeted_mitra_id && !allowTargetedPayment) {
throw Object.assign(new Error('Payment session is targeted to a specific mitra; use returning-chat endpoint'), {
code: 'INVALID_STATE', statusCode: 409,
})
@@ -170,7 +170,7 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
const availableMitras = await findAvailableMitras()
if (availableMitras.length === 0) {
// No mitras to blast to — fail the payment immediately.
await failPaymentSession(paymentSessionId, PairingFailureCause.NO_MITRA_AVAILABLE)
await failPaymentSession(paymentRequestId, PairingFailureCause.NO_MITRA_AVAILABLE)
throw Object.assign(new Error('No bestie available'), {
code: 'NO_MITRA_AVAILABLE', statusCode: 404,
})
@@ -183,14 +183,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
// Create session sourced from the payment session.
const [session] = await sql`
INSERT INTO chat_sessions (
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id
)
VALUES (
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
${resolvedTopic}, ${paymentSessionId}
${payRequest.duration_minutes}, ${payRequest.amount}, ${payRequest.is_first_session_discount},
${resolvedTopic}, ${paymentRequestId}
)
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id, created_at
`
// Fan out to all available mitras in parallel — DB inserts and notifications are
@@ -225,6 +225,56 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
return session
}
/**
* Phase 5: server-driven pairing entry point — called by the payment service's
* `payment_request.confirmed` event subscriber. Replaces the client-driven
* POST /chat/request path for new payments.
*
* **Idempotent.** If a chat_session already exists for this payment_request_id,
* returns it without doing anything. Safe to call multiple times — the
* reconciliation sweeper relies on this property to retry lost events.
*
* Routes general-blast vs targeted based on productMetadata.targeted_mitra_id.
* Errors are caught and logged (don't bubble — the event subscriber wrapper would
* also catch but logging here gives better context).
*/
export const startPairingFromPaymentRequest = async ({ paymentRequestId, productMetadata, customerId }) => {
// Idempotency check — covers webhook retries, reconciliation sweeper re-emit,
// and the case where the legacy client still POSTs to /chat/request after our
// subscriber already started pairing.
const [existing] = await sql`
SELECT id FROM chat_sessions WHERE payment_request_id = ${paymentRequestId}
`
if (existing) return existing
const targetedMitraId = productMetadata?.targeted_mitra_id ?? null
const topicSensitivity = productMetadata?.topic_sensitivity ?? TopicSensitivity.REGULAR
try {
if (targetedMitraId) {
return await createTargetedPairingRequest(customerId, {
paymentRequestId,
targetedMitraId,
topic_sensitivity: topicSensitivity,
})
}
return await createPairingRequest(customerId, {
paymentRequestId,
topic_sensitivity: topicSensitivity,
})
} catch (err) {
// Already-active is benign — covers the race where the legacy /chat/request
// beat the subscriber to it. NO_MITRA_AVAILABLE has already failed the payment
// (createPairingRequest does that internally) — we don't need to act further.
if (err.code === 'ALREADY_ACTIVE' || err.code === 'NO_MITRA_AVAILABLE') {
console.log(`[pairing subscriber] ${err.code} for payment ${paymentRequestId} — already handled`)
return null
}
console.error('[pairing subscriber] startPairing failed', { paymentRequestId, err })
throw err
}
}
/**
* Targeted pairing request for "Curhat lagi" (returning chat).
*
@@ -236,14 +286,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
* - On explicit decline by mitra: fail payment with `targeted_mitra_rejected`, push WS event.
* - On accept: existing accept path runs (consumes payment session as for general blast).
*/
export const createTargetedPairingRequest = async (customerId, { paymentSessionId, targetedMitraId, topic_sensitivity } = {}) => {
const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId)
export const createTargetedPairingRequest = async (customerId, { paymentRequestId, targetedMitraId, topic_sensitivity } = {}) => {
const payRequest = await requireConfirmedPaymentRequest(paymentRequestId, customerId)
if (!targetedMitraId) {
throw Object.assign(new Error('targetedMitraId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
// Cross-check: payment_session.targeted_mitra_id should match (if set).
if (paySession.targeted_mitra_id && paySession.targeted_mitra_id !== targetedMitraId) {
// Cross-check: payment_request.targeted_mitra_id should match (if set).
if (payRequest.targeted_mitra_id && payRequest.targeted_mitra_id !== targetedMitraId) {
throw Object.assign(new Error('targetedMitraId does not match payment session'), {
code: 'INVALID_STATE', statusCode: 409,
})
@@ -267,11 +317,11 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
// Intermediate failure: audit row written, payment stays `confirmed` so the customer
// can choose to fall back to general blast (or cancel, which terminates).
await recordIntermediateFailure({
paymentSessionId,
paymentRequestId,
customerId,
targetedMitraId,
causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE,
amount: paySession.amount,
amount: payRequest.amount,
})
throw Object.assign(new Error('Targeted mitra is offline'), {
code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline',
@@ -285,11 +335,11 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
const midSessionWithCustomer = await isMitraInActiveSessionWithCustomer(targetedMitraId, customerId)
if (!midSessionWithCustomer) {
await recordIntermediateFailure({
paymentSessionId,
paymentRequestId,
customerId,
targetedMitraId,
causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE,
amount: paySession.amount,
amount: payRequest.amount,
})
throw Object.assign(new Error('Targeted mitra is at capacity'), {
code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline',
@@ -305,14 +355,14 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
// Create session sourced from the payment session, status = pending_acceptance.
const [session] = await sql`
INSERT INTO chat_sessions (
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id
)
VALUES (
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
${resolvedTopic}, ${paymentSessionId}
${payRequest.duration_minutes}, ${payRequest.amount}, ${payRequest.is_first_session_discount},
${resolvedTopic}, ${paymentRequestId}
)
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id, created_at
`
// Single notification to the targeted mitra
@@ -355,7 +405,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
UPDATE chat_sessions
SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW()
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} AND mitra_id IS NULL
RETURNING id, customer_id, mitra_id, status, paired_at, payment_session_id
RETURNING id, customer_id, mitra_id, status, paired_at, payment_request_id
`
if (!session) {
@@ -386,8 +436,8 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
}
// Consume the payment session at the moment of acceptance.
if (session.payment_session_id) {
await consumePaymentSession(session.payment_session_id)
if (session.payment_request_id) {
await consumePaymentSession(session.payment_request_id)
}
// Activate the session and set expires_at.
@@ -399,7 +449,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
ELSE NULL
END
WHERE id = ${sessionId}
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_session_id
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_request_id
`
// Record transaction
@@ -453,25 +503,25 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL
`
// Targeted-vs-general is determined by the payment_session.targeted_mitra_id, not by
// Targeted-vs-general is determined by the payment_request.targeted_mitra_id, not by
// notification count — a general blast with only one online mitra also has length=1.
const [targetCheck] = await sql`
SELECT ps.targeted_mitra_id
FROM chat_sessions cs
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
WHERE cs.id = ${sessionId}
`
const isTargeted = !!targetCheck?.targeted_mitra_id
if (isTargeted) {
// Mark the chat_session as expired (the targeted attempt is over) — but keep the
// payment_session in `confirmed` so the customer can fall back to general blast on
// payment_request in `confirmed` so the customer can fall back to general blast on
// the same payment, or cancel (which then terminates).
const [session] = await sql`
UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
RETURNING id, customer_id, payment_session_id
RETURNING id, customer_id, payment_request_id
`
if (session) {
// Clear the 20s timer if still pending.
@@ -482,15 +532,15 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
}
// Audit row only; payment session stays `confirmed`.
if (session.payment_session_id) {
const paySession = await getPaymentSession(session.payment_session_id)
if (paySession) {
if (session.payment_request_id) {
const payRequest = await getPaymentSession(session.payment_request_id)
if (payRequest) {
await recordIntermediateFailure({
paymentSessionId: session.payment_session_id,
paymentRequestId: session.payment_request_id,
customerId: session.customer_id,
targetedMitraId: mitraId,
causeTag: PairingFailureCause.TARGETED_MITRA_REJECTED,
amount: paySession.amount,
amount: payRequest.amount,
})
}
}
@@ -499,7 +549,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
await notifyCustomer(session.customer_id, {
type: WsMessage.RETURNING_CHAT_REJECTED,
session_id: sessionId,
payment_session_id: session.payment_session_id,
payment_request_id: session.payment_request_id,
})
}
return
@@ -518,7 +568,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
RETURNING id, customer_id, payment_session_id
RETURNING id, customer_id, payment_request_id
`
if (session) {
const timeoutId = pairingTimeouts.get(sessionId)
@@ -529,14 +579,14 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
// Intermediate failure: payment stays confirmed so the customer can re-blast
// from the S7 timeout CTA. Audit row is still written.
if (session.payment_session_id) {
const paySession = await getPaymentSession(session.payment_session_id)
if (paySession) {
if (session.payment_request_id) {
const payRequest = await getPaymentSession(session.payment_request_id)
if (payRequest) {
await recordIntermediateFailure({
paymentSessionId: session.payment_session_id,
paymentRequestId: session.payment_request_id,
customerId: session.customer_id,
causeTag: PairingFailureCause.ALL_MITRAS_REJECTED,
amount: paySession.amount,
amount: payRequest.amount,
})
}
}
@@ -544,7 +594,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
await notifyCustomer(session.customer_id, {
type: WsMessage.PAIRING_FAILED,
session_id: sessionId,
payment_session_id: session.payment_session_id,
payment_request_id: session.payment_request_id,
cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED,
is_terminal: false,
})
@@ -558,7 +608,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
SET status = ${SessionStatus.CANCELLED}
WHERE id = ${sessionId} AND customer_id = ${customerId}
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
RETURNING id, customer_id, status, payment_session_id
RETURNING id, customer_id, status, payment_request_id
`
if (!session) {
@@ -594,8 +644,8 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
// Customer initiated this cancel; the calling client already navigates home. Do not
// push PAIRING_FAILED for customer-initiated cancels — surfacing it as a "failure"
// event (especially via FCM if backgrounded) misframes the user's own action.
if (session.payment_session_id) {
await failPaymentSession(session.payment_session_id, PairingFailureCause.CUSTOMER_CANCELLED)
if (session.payment_request_id) {
await failPaymentSession(session.payment_request_id, PairingFailureCause.CUSTOMER_CANCELLED)
}
return session
@@ -609,12 +659,12 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
* If a chat_session was already created (general blast in flight, or targeted request out),
* we cancel that too.
*/
export const cancelPaymentSearch = async (paymentSessionId, customerId) => {
const paySession = await getPaymentSession(paymentSessionId)
if (!paySession) {
export const cancelPaymentSearch = async (paymentRequestId, customerId) => {
const payRequest = await getPaymentSession(paymentRequestId)
if (!payRequest) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
if (paySession.customer_id !== customerId) {
if (payRequest.customer_id !== customerId) {
throw Object.assign(new Error('Payment session does not belong to this customer'), {
code: 'FORBIDDEN', statusCode: 403,
})
@@ -623,7 +673,7 @@ export const cancelPaymentSearch = async (paymentSessionId, customerId) => {
// If a chat_session exists for this payment in pending_acceptance/searching, cancel it.
const [linkedSession] = await sql`
SELECT id FROM chat_sessions
WHERE payment_session_id = ${paymentSessionId}
WHERE payment_request_id = ${paymentRequestId}
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
`
if (linkedSession) {
@@ -634,37 +684,37 @@ export const cancelPaymentSearch = async (paymentSessionId, customerId) => {
// Otherwise fail the payment directly. Covers the case where the customer cancels after
// the targeted attempt already expired/rejected (chat_session no longer pending_acceptance)
// but the payment is still `confirmed`. No customer-side WS push — see cancelPairingRequest.
if (paySession.status === PaymentSessionStatus.CONFIRMED) {
await failPaymentSession(paymentSessionId, PairingFailureCause.CUSTOMER_CANCELLED)
if (payRequest.status === PaymentRequestStatus.CONFIRMED) {
await failPaymentSession(paymentRequestId, PairingFailureCause.CUSTOMER_CANCELLED)
}
return { id: paymentSessionId, payment_session_id: paymentSessionId }
return { id: paymentRequestId, payment_request_id: paymentRequestId }
}
/**
* After a returning-chat fail, customer taps "Chat dengan bestie lain".
*
* The original payment_session stays in `confirmed` for the entire returning-chat flow —
* The original payment_request stays in `confirmed` for the entire returning-chat flow —
* targeted reject/timeout writes an audit-only `pairing_failures` row but does NOT terminate.
* So when the customer falls back to general blast, we reuse the same `payment_session_id`
* directly. Multiple `pairing_failures` rows may FK from one payment_session — that's the
* So when the customer falls back to general blast, we reuse the same `payment_request_id`
* directly. Multiple `pairing_failures` rows may FK from one payment_request — that's the
* desired CC UX (one row per failed attempt). Termination happens only at the actual end
* of the flow (chat starts → consumed; cancel/blast-exhaust → failed_pairing).
* of the flow (chat starts → consumed; cancel/blast-exhaust → failed_delivery).
*
* The targeted_mitra_id flag on the original row is left as-is (it records the customer's
* original intent); the general blast happens regardless.
*/
export const fallbackToGeneralBlast = async (paymentSessionId, customerId, { topic_sensitivity } = {}) => {
const paySession = await getPaymentSession(paymentSessionId)
if (!paySession) {
export const fallbackToGeneralBlast = async (paymentRequestId, customerId, { topic_sensitivity } = {}) => {
const payRequest = await getPaymentSession(paymentRequestId)
if (!payRequest) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
if (paySession.customer_id !== customerId) {
if (payRequest.customer_id !== customerId) {
throw Object.assign(new Error('Payment session does not belong to this customer'), {
code: 'FORBIDDEN', statusCode: 403,
})
}
if (paySession.status !== PaymentSessionStatus.CONFIRMED) {
throw Object.assign(new Error(`Cannot fallback from payment in status ${paySession.status}`), {
if (payRequest.status !== PaymentRequestStatus.CONFIRMED) {
throw Object.assign(new Error(`Cannot fallback from payment in status ${payRequest.status}`), {
code: 'INVALID_STATE', statusCode: 409,
})
}
@@ -672,7 +722,7 @@ export const fallbackToGeneralBlast = async (paymentSessionId, customerId, { top
// Run the general blast against the SAME payment session. Pass `allowTargetedPayment`
// so the targeted_mitra_id on the payment session doesn't trip the general-blast guard.
return createPairingRequest(customerId, {
paymentSessionId,
paymentRequestId,
topic_sensitivity,
allowTargetedPayment: true,
})
@@ -683,7 +733,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
RETURNING id, customer_id, status, payment_session_id
RETURNING id, customer_id, status, payment_request_id
`
if (!session) return null
@@ -699,14 +749,14 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
// Intermediate failure: payment session stays `confirmed` so the customer can
// re-blast on the same payment from the S7 timeout CTA. Audit row is still
// written so the failed-pairing CC view captures every attempt.
if (session.payment_session_id) {
const paySession = await getPaymentSession(session.payment_session_id)
if (paySession) {
if (session.payment_request_id) {
const payRequest = await getPaymentSession(session.payment_request_id)
if (payRequest) {
await recordIntermediateFailure({
paymentSessionId: session.payment_session_id,
paymentRequestId: session.payment_request_id,
customerId: session.customer_id,
causeTag,
amount: paySession.amount,
amount: payRequest.amount,
})
}
}
@@ -714,7 +764,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
await notifyCustomer(session.customer_id, {
type: WsMessage.PAIRING_FAILED,
session_id: sessionId,
payment_session_id: session.payment_session_id,
payment_request_id: session.payment_request_id,
cause_tag: causeTag,
is_terminal: false,
})
@@ -736,7 +786,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
* Targeted-request timer fired with no mitra response.
*
* INTERMEDIATE failure: the chat_session is marked expired (the targeted attempt is over)
* but the payment_session stays `confirmed` so the customer can fall back to general blast
* but the payment_request stays `confirmed` so the customer can fall back to general blast
* on the same payment, or cancel (which then terminates).
*
* - cause_tag is targeted_mitra_timeout (audit row only)
@@ -747,7 +797,7 @@ export const expireTargetedPairingRequest = async (sessionId) => {
UPDATE chat_sessions
SET status = ${SessionStatus.EXPIRED}
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
RETURNING id, customer_id, status, payment_session_id
RETURNING id, customer_id, status, payment_request_id
`
if (!session) return null
@@ -764,15 +814,15 @@ export const expireTargetedPairingRequest = async (sessionId) => {
WHERE session_id = ${sessionId} AND response IS NULL
`
if (session.payment_session_id) {
const paySession = await getPaymentSession(session.payment_session_id)
if (paySession) {
if (session.payment_request_id) {
const payRequest = await getPaymentSession(session.payment_request_id)
if (payRequest) {
await recordIntermediateFailure({
paymentSessionId: session.payment_session_id,
paymentRequestId: session.payment_request_id,
customerId: session.customer_id,
targetedMitraId: notif?.mitra_id ?? null,
causeTag: PairingFailureCause.TARGETED_MITRA_TIMEOUT,
amount: paySession.amount,
amount: payRequest.amount,
})
}
}
@@ -780,7 +830,7 @@ export const expireTargetedPairingRequest = async (sessionId) => {
await notifyCustomer(session.customer_id, {
type: WsMessage.RETURNING_CHAT_TIMEOUT,
session_id: sessionId,
payment_session_id: session.payment_session_id,
payment_request_id: session.payment_request_id,
})
// Notify the targeted mitra that the card is no longer actionable — fan-out in parallel
@@ -798,7 +848,7 @@ export const expireTargetedPairingRequest = async (sessionId) => {
}
export const getPendingRequestsForMitra = async (mitraId) => {
// Distinguish general blast from "Curhat lagi" returning requests via payment_session.targeted_mitra_id.
// Distinguish general blast from "Curhat lagi" returning requests via payment_request.targeted_mitra_id.
// For returning requests, surface the configured timeout so the cold-start (FCM-tap) path can render
// the countdown overlay — same field the WS payload provides for the live path.
const rows = await sql`
@@ -814,7 +864,7 @@ export const getPendingRequestsForMitra = async (mitraId) => {
END AS request_type
FROM chat_request_notifications crn
JOIN chat_sessions cs ON cs.id = crn.session_id
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
WHERE crn.mitra_id = ${mitraId}
AND crn.response IS NULL
AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE}

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 { PaymentSessionStatus, PairingFailureCause, UserType, WsMessage, SessionMode } from '../constants.js'
import { PaymentRequestStatus, PairingFailureCause, UserType, WsMessage, SessionMode } from '../constants.js'
import { recordFailure } from './pairing-failure.service.js'
import { sendToUser } from '../plugins/websocket.js'
import { sendPushNotification } from './notification.service.js'
import { getPaymentSessionTimeoutMinutes as readPaymentSessionTimeoutMinutes } from './config.service.js'
import {
getPaymentRequestTimeoutMinutes as readPaymentRequestTimeoutMinutes,
getXenditConfig,
} from './config.service.js'
const sql = getDb()
const getPaymentSessionTimeoutMinutes = async () => {
const { payment_session_timeout_minutes } = await readPaymentSessionTimeoutMinutes()
return payment_session_timeout_minutes
// --- EventEmitter setup ---
const emitter = new EventEmitter()
// Bump default 10-listener cap so future product subscribers don't trigger the leak warning
emitter.setMaxListeners(50)
export const on = (eventName, handler) => {
emitter.on(eventName, (payload) => {
// Wrap every handler so an unhandled throw doesn't kill the process AND so handlers
// run async-fire-and-forget (don't block the publisher's emit() return). Errors are
// logged; recovery is the sweeper's job.
Promise.resolve()
.then(() => handler(payload))
.catch((err) => console.error(`[payment event ${eventName}] handler failed`, err))
})
}
const emit = (eventName, payload) => emitter.emit(eventName, payload)
// --- Internal Xendit client (lazy + re-creatable for test env stubbing) ---
let _xenditClient = null
let _xenditKey = null
const xenditClient = () => {
const { secretKey } = getXenditConfig()
if (_xenditClient && _xenditKey === secretKey) return _xenditClient
_xenditClient = new Xendit({ secretKey })
_xenditKey = secretKey
return _xenditClient
}
const createXenditInvoice = async ({ paymentRequestId, amount, ttlMinutes, description }) => {
const { successRedirectUrl, failureRedirectUrl } = getXenditConfig()
const inv = await xenditClient().Invoice.createInvoice({
data: {
externalId: paymentRequestId, // D4 — our UUID is the Xendit external_id
amount,
description,
invoiceDuration: Math.floor(ttlMinutes * 60), // D5 — TTL mirrors session timeout
currency: 'IDR',
successRedirectUrl: successRedirectUrl || undefined,
failureRedirectUrl: failureRedirectUrl || undefined,
// paymentMethods omitted → honor dashboard config (operator picks methods without a deploy)
},
})
return { invoiceId: inv.id, invoiceUrl: inv.invoiceUrl }
}
// Used by the webhook route to authenticate Xendit's x-callback-token header.
export const verifyWebhookToken = (headerToken) => {
const { webhookToken } = getXenditConfig()
if (!headerToken || !webhookToken) return false
if (typeof headerToken !== 'string') return false
if (headerToken.length !== webhookToken.length) return false
let mismatch = 0
for (let i = 0; i < headerToken.length; i++) {
mismatch |= headerToken.charCodeAt(i) ^ webhookToken.charCodeAt(i)
}
return mismatch === 0
}
// Test-only — drop cached client so vi.stubEnv changes take effect.
export const _resetXenditClientForTest = () => {
_xenditClient = null
_xenditKey = null
}
// --- Helpers ---
const getPaymentRequestTimeoutMinutes = async () => {
const { payment_request_timeout_minutes } = await readPaymentRequestTimeoutMinutes()
return payment_request_timeout_minutes
}
const buildEventPayload = (row) => ({
paymentRequestId: row.id,
productType: row.product_type ?? 'chat_session',
productMetadata: row.product_metadata ?? {},
customerId: row.customer_id,
amount: row.amount,
xenditInvoiceId: row.xendit_invoice_id ?? null,
xenditPaymentMethod: row.xendit_payment_method ?? null,
})
const buildInvoiceDescription = (row) => {
if (row.product_type === 'chat_session') {
return row.is_extension
? `Perpanjangan sesi ${row.duration_minutes} menit`
: `Sesi ${row.duration_minutes} menit`
}
// Generic fallback — future products can build their own descriptions and pass via productMetadata
return row.product_metadata?.description ?? `Pembayaran ${row.product_type}`
}
// --- requestPayment: insert pending row + (if Xendit on) mint invoice ---
/**
* Create a new payment session in `pending` status.
* Reads `payment_session_timeout_minutes` from config to compute expires_at.
* Create a new payment request in `pending` status. When XENDIT_ENABLED=true, also
* creates a Xendit Invoice and stamps invoice_id + invoice_url on the row.
*
* Phase 4: `isFirstSessionDiscount` replaces the old `isFreeTrial` flag. Voice-call
* mode is a routing/badge thing — the price comes from the call tier group, not from
* the mode itself.
* Product-agnostic: callers stamp `productType` + `productMetadata`. The legacy
* top-level chat-specific args (durationMinutes, mode, isExtension, targetedMitraId,
* isFirstSessionDiscount) are accepted for backward compat with existing chat code,
* and also written into product_metadata when productType === 'chat_session'.
*/
export const createPaymentSession = async ({
export const requestPayment = async ({
productType = 'chat_session',
productMetadata = {},
customerId,
durationMinutes,
amount,
ttlMinutes,
// Chat-specific legacy fields (still written to top-level columns for now)
durationMinutes,
mode = SessionMode.CHAT,
isFirstSessionDiscount = false,
isExtension = false,
targetedMitraId = null,
mode = SessionMode.CHAT,
}) => {
if (!customerId) {
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
if (typeof durationMinutes !== 'number' || durationMinutes <= 0) {
throw Object.assign(new Error('durationMinutes must be a positive number'), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
if (typeof amount !== 'number' || amount < 0) {
throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
throw Object.assign(new Error('mode must be chat or call'), { code: 'VALIDATION_ERROR', statusCode: 422 })
if (productType === 'chat_session') {
if (typeof durationMinutes !== 'number' || durationMinutes <= 0) {
throw Object.assign(new Error('durationMinutes must be a positive number for chat_session'), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
throw Object.assign(new Error('mode must be chat or call'), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
}
const ttlMinutes = await getPaymentSessionTimeoutMinutes()
const ttl = ttlMinutes ?? await getPaymentRequestTimeoutMinutes()
// For chat_session, fold legacy args into product_metadata so the canonical
// location is the JSONB blob. Subscribers read from product_metadata.
const meta = productType === 'chat_session'
? {
duration_minutes: durationMinutes,
mode,
is_extension: isExtension,
targeted_mitra_id: targetedMitraId,
is_first_session_discount: isFirstSessionDiscount,
...productMetadata,
}
: productMetadata
const [row] = await sql`
INSERT INTO payment_sessions (
INSERT INTO payment_requests (
customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
status, targeted_mitra_id, mode, expires_at
status, targeted_mitra_id, mode, expires_at, product_type, product_metadata
)
VALUES (
${customerId}, ${amount}, ${durationMinutes}, ${isFirstSessionDiscount}, ${isExtension},
${PaymentSessionStatus.PENDING}, ${targetedMitraId}, ${mode},
NOW() + (${ttlMinutes} || ' minutes')::interval
${customerId}, ${amount}, ${durationMinutes ?? 0}, ${isFirstSessionDiscount}, ${isExtension},
${PaymentRequestStatus.PENDING}, ${targetedMitraId}, ${mode},
NOW() + (${ttl} || ' minutes')::interval,
${productType}, ${sql.json(meta)}
)
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
RETURNING *
`
// If Xendit is on, create the invoice + stamp the row. If creation fails, mark
// the row as `failed` (NOT `expired` — distinct: "we never got an invoice at all"
// vs. "TTL elapsed unpaid") and surface a 502 to the caller.
const xc = getXenditConfig()
if (xc.enabled) {
try {
const { invoiceId, invoiceUrl } = await createXenditInvoice({
paymentRequestId: row.id,
amount: row.amount,
ttlMinutes: ttl,
description: buildInvoiceDescription(row),
})
await sql`
UPDATE payment_requests
SET xendit_invoice_id = ${invoiceId}, xendit_invoice_url = ${invoiceUrl}
WHERE id = ${row.id}
`
row.xendit_invoice_id = invoiceId
row.xendit_invoice_url = invoiceUrl
} catch (err) {
console.error('[xendit] createInvoice failed; marking payment_request failed', { paymentRequestId: row.id, err })
const [failed] = await sql`
UPDATE payment_requests
SET status = ${PaymentRequestStatus.FAILED}
WHERE id = ${row.id} AND status = ${PaymentRequestStatus.PENDING}
RETURNING *
`
if (failed) emit('payment_request.failed', buildEventPayload(failed))
throw Object.assign(new Error('Payment provider error'), {
code: 'PAYMENT_PROVIDER_ERROR',
statusCode: 502,
cause: err,
})
}
}
return row
}
// --- confirmPayment ---
/**
* Transition pending → confirmed. Throws on ownership/status/expiry mismatch.
* Transition pending → confirmed. Customer-facing callers (the legacy
* /payment-requests/:id/confirm route) verify customer ownership themselves before
* calling. Webhook caller does not check ownership (Xendit's authority).
*
* Optional xenditMeta stamps payment-time data from the webhook body:
* { invoiceId, paymentMethod, amount }
*
* Throws on missing row / wrong status / expiry. Webhook handler swallows
* INVALID_STATE (already confirmed) and EXPIRED (raced sweeper) and ACKs.
*
* Emits 'payment_request.confirmed' after commit.
*/
export const confirmPaymentSession = async (paymentSessionId, customerId) => {
export const confirmPayment = async (paymentRequestId, xenditMeta = {}) => {
const [existing] = await sql`
SELECT id, customer_id, status, expires_at
FROM payment_sessions
WHERE id = ${paymentSessionId}
FROM payment_requests
WHERE id = ${paymentRequestId}
`
if (!existing) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
if (existing.customer_id !== customerId) {
throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
}
if (existing.status !== PaymentSessionStatus.PENDING) {
throw Object.assign(new Error(`Payment session is ${existing.status}, cannot confirm`), {
if (existing.status !== PaymentRequestStatus.PENDING) {
throw Object.assign(new Error(`Payment request is ${existing.status}, cannot confirm`), {
code: 'INVALID_STATE', statusCode: 409,
})
}
if (new Date(existing.expires_at) <= new Date()) {
// Inline expiry check in addition to the background sweeper, since the customer can
// attempt to confirm a row that's already past expires_at before the sweep runs.
// Inline expiry check (sweeper hasn't run yet)
await sql`
UPDATE payment_sessions SET status = ${PaymentSessionStatus.EXPIRED}
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
UPDATE payment_requests SET status = ${PaymentRequestStatus.EXPIRED}
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
`
throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 })
throw Object.assign(new Error('Payment request has expired'), { code: 'EXPIRED', statusCode: 409 })
}
const [updated] = await sql`
UPDATE payment_sessions
SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW()
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
UPDATE payment_requests
SET status = ${PaymentRequestStatus.CONFIRMED},
confirmed_at = NOW(),
xendit_invoice_id = COALESCE(${xenditMeta.invoiceId ?? null}, xendit_invoice_id),
xendit_payment_method = COALESCE(${xenditMeta.paymentMethod ?? null}, xendit_payment_method),
xendit_paid_amount = COALESCE(${xenditMeta.amount ?? null}, xendit_paid_amount)
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
RETURNING *
`
if (!updated) {
throw Object.assign(new Error('Payment session state changed during confirm'), { code: 'CONFLICT', statusCode: 409 })
throw Object.assign(new Error('Payment request state changed during confirm'), { code: 'CONFLICT', statusCode: 409 })
}
emit('payment_request.confirmed', buildEventPayload(updated))
return updated
}
/**
* Transition confirmed → consumed. Called from pairing service when a chat starts.
* Idempotent at higher level (caller should check status first if it matters).
*/
export const consumePaymentSession = async (paymentSessionId) => {
const [updated] = await sql`
UPDATE payment_sessions
SET status = ${PaymentSessionStatus.CONSUMED}, consumed_at = NOW()
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED}
RETURNING id, status, consumed_at
`
return updated || null
// Customer-facing wrapper used by the legacy /payment-requests/:id/confirm route
// (kept only for dev/Maestro — production gates the route on XENDIT_ENABLED).
// Verifies customer ownership before delegating to the internal confirmPayment.
export const confirmPaymentForCustomer = async (paymentRequestId, customerId) => {
const [existing] = await sql`SELECT customer_id FROM payment_requests WHERE id = ${paymentRequestId}`
if (!existing) {
throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
if (existing.customer_id !== customerId) {
throw Object.assign(new Error('Payment request does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
}
return confirmPayment(paymentRequestId)
}
// --- expirePayment (webhook-callable; no ownership check) ---
/**
* TERMINAL: mark a confirmed payment session as failed_pairing AND write a pairing_failures row.
* Idempotent: no-op if already terminal (consumed/failed_pairing/expired/abandoned).
*
* Use only for true terminal failures (no fallback path possible):
* - general blast exhausted, no acceptance
* - all blasted mitras explicitly rejected
* - customer cancels mid-search
* - payment session expires before consumption
*
* For intermediate failures that have a fallback CTA available (targeted-mitra reject/timeout
* during a returning-chat flow), use `recordIntermediateFailure` instead — that writes the
* audit row WITHOUT terminating the payment session. Termination is the caller's decision
* (cancel CTA = terminal, fallback-to-blast CTA = stays confirmed).
* Transition pending → expired. Called by the Xendit EXPIRED webhook handler
* and by the background sweeper. Idempotent if already terminal, no-op.
* Emits 'payment_request.expired' if a transition occurred.
*/
export const failPaymentSession = async (paymentSessionId, causeTag) => {
export const expirePayment = async (paymentRequestId) => {
const [updated] = await sql`
UPDATE payment_requests
SET status = ${PaymentRequestStatus.EXPIRED}
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
RETURNING *
`
if (updated) emit('payment_request.expired', buildEventPayload(updated))
return updated ?? null
}
// --- consumePayment ---
/**
* Transition confirmed → consumed. Called by product code (pairing service for chat)
* after successful delivery. No event — terminal success.
*/
export const consumePayment = async (paymentRequestId) => {
const [updated] = await sql`
UPDATE payment_requests
SET status = ${PaymentRequestStatus.CONSUMED}, consumed_at = NOW()
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.CONFIRMED}
RETURNING id, status, consumed_at
`
return updated ?? null
}
// --- markDeliveryFailed ---
/**
* TERMINAL: mark a confirmed payment as failed_delivery + write a pairing_failures
* audit row. Idempotent: no-op if not currently confirmed.
*
* Use only when no fallback is possible (no mitra available, all rejected, etc.).
* For intermediate failures with a fallback (targeted reject during returning-chat),
* use recordIntermediateFailure() which keeps the payment confirmed.
*
* Emits 'payment_request.delivery_failed' on transition.
*/
export const markDeliveryFailed = async (paymentRequestId, causeTag) => {
if (!Object.values(PairingFailureCause).includes(causeTag)) {
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
const [existing] = await sql`
SELECT id, customer_id, targeted_mitra_id, amount, status
FROM payment_sessions
WHERE id = ${paymentSessionId}
SELECT id, customer_id, targeted_mitra_id, amount, status, product_type, product_metadata, xendit_invoice_id, xendit_payment_method
FROM payment_requests
WHERE id = ${paymentRequestId}
`
if (!existing) {
return null
}
// Idempotent: only confirmed sessions transition to failed_pairing here.
// Pending sessions become expired/abandoned via their own paths.
if (existing.status !== PaymentSessionStatus.CONFIRMED) {
return existing
}
if (!existing) return null
if (existing.status !== PaymentRequestStatus.CONFIRMED) return existing // idempotent
const [updated] = await sql`
UPDATE payment_sessions
SET status = ${PaymentSessionStatus.FAILED_PAIRING}
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED}
RETURNING id, customer_id, targeted_mitra_id, amount, status
UPDATE payment_requests
SET status = ${PaymentRequestStatus.FAILED_DELIVERY}
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.CONFIRMED}
RETURNING *
`
if (!updated) {
return existing
}
if (!updated) return existing
await recordFailure({
paymentSessionId,
paymentRequestId,
customerId: existing.customer_id,
targetedMitraId: existing.targeted_mitra_id,
causeTag,
amount: existing.amount,
})
emit('payment_request.delivery_failed', { ...buildEventPayload(updated), causeTag })
return updated
}
// Backward-compat alias — pairing.service still calls failPaymentSession by name.
// TODO follow-up phase: rename call sites and drop this alias.
export const failPaymentSession = markDeliveryFailed
// --- recordIntermediateFailure (audit only; doesn't terminate) ---
/**
* INTERMEDIATE: write a pairing_failures audit row WITHOUT terminating the payment session.
* INTERMEDIATE: write a pairing_failures audit row WITHOUT terminating the payment.
* Used inside flows with a fallback path (targeted "Curhat lagi" reject can fall back
* to general blast on the same payment). One payment_request may have many audit rows.
*
* Used for failures inside a flow that still has a fallback path: targeted "Curhat lagi"
* reject/timeout (customer can fall back to general blast on the same payment), or any
* other intermediate attempt where the payment_session must remain `confirmed` so it can be
* reused.
*
* One payment_session may FK from multiple pairing_failures rows — that's the desired CC
* UX (each attempt lists as its own row in the Failed Pairings table).
*
* Returns the inserted pairing_failures row, or null if the payment session was missing.
* Returns the inserted pairing_failures row, or null if the payment is missing.
*/
export const recordIntermediateFailure = async ({ paymentSessionId, customerId, targetedMitraId = null, causeTag, amount }) => {
export const recordIntermediateFailure = async ({
paymentRequestId,
customerId,
targetedMitraId = null,
causeTag,
amount,
}) => {
if (!Object.values(PairingFailureCause).includes(causeTag)) {
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
}
// Loose sanity: the row should exist. If not, fall through with null — caller likely
// already moved on; we'd rather skip the audit than throw mid-callback.
const [existing] = await sql`SELECT id FROM payment_sessions WHERE id = ${paymentSessionId}`
const [existing] = await sql`SELECT id FROM payment_requests WHERE id = ${paymentRequestId}`
if (!existing) return null
return recordFailure({
paymentSessionId,
customerId,
targetedMitraId,
causeTag,
amount,
})
return recordFailure({ paymentRequestId, customerId, targetedMitraId, causeTag, amount })
}
// --- cancelPayment (customer-initiated abandonment) ---
/**
* Customer-initiated abandonment of a `pending` payment session (e.g. closed payment screen).
* No pairing_failures row is written — only confirmed-but-no-chat counts as a pairing failure.
* Customer-initiated abandonment of a pending payment (closed payment screen).
* No pairing_failures row (no money moved). Idempotent.
*
* Emits 'payment_request.cancelled' on transition.
*/
export const abandonPaymentSession = async (paymentSessionId, customerId) => {
export const cancelPayment = async (paymentRequestId, customerId) => {
const [existing] = await sql`
SELECT id, customer_id, status FROM payment_sessions WHERE id = ${paymentSessionId}
SELECT id, customer_id, status FROM payment_requests WHERE id = ${paymentRequestId}
`
if (!existing) {
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 })
}
if (existing.customer_id !== customerId) {
throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
throw Object.assign(new Error('Payment request does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
}
if (existing.status !== PaymentSessionStatus.PENDING) {
// Idempotent — already terminal.
return existing
if (existing.status !== PaymentRequestStatus.PENDING) {
return existing // idempotent
}
const [updated] = await sql`
UPDATE payment_sessions SET status = ${PaymentSessionStatus.ABANDONED}
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
RETURNING id, customer_id, status
UPDATE payment_requests SET status = ${PaymentRequestStatus.ABANDONED}
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
RETURNING *
`
return updated || existing
if (updated) emit('payment_request.cancelled', buildEventPayload(updated))
return updated ?? existing
}
// --- Background sweeper + reconciliation ---
/**
* Background sweeper:
* - pending rows past expires_at → expired (no failure row; never confirmed)
* - confirmed rows past expires_at AND not consumed → failed_pairing with cause = payment_session_expired
* Runs every 60s from server.js + once at startup.
*
* Three jobs:
* 1. Pending past expires_at → expired (no failure row; emits .expired)
* 2. Confirmed past expires_at AND not consumed → failed_delivery (writes pairing_failures, emits .delivery_failed)
* 3. Confirmed-with-no-chat-session-yet → re-invoke pairing subscriber (lost-event recovery)
*
* Job 3 is the durability backstop — if a process restart lost the in-process
* EventEmitter notification before pairing started, the sweeper re-triggers it
* on the next tick. Subscribers MUST be idempotent (the pairing subscriber checks
* "chat_sessions WHERE payment_request_id = ?" before doing work).
*/
export const expireStalePaymentSessions = async () => {
// 1) pending → expired
export const expireStalePaymentRequests = async () => {
// 1) pending → expired (emit event for each)
const expired = await sql`
UPDATE payment_sessions
SET status = ${PaymentSessionStatus.EXPIRED}
WHERE status = ${PaymentSessionStatus.PENDING}
UPDATE payment_requests
SET status = ${PaymentRequestStatus.EXPIRED}
WHERE status = ${PaymentRequestStatus.PENDING}
AND expires_at <= NOW()
RETURNING id
RETURNING *
`
for (const row of expired) emit('payment_request.expired', buildEventPayload(row))
// 2) confirmed-but-stale → failed_pairing. Single atomic UPDATE returns the rows we
// actually flipped (vs. the old SELECT + per-row UPDATE which leaked a TOCTOU window
// with concurrent confirmPaymentSession/consumePaymentSession). Audit-row writes and
// customer notifications then fan out in parallel.
// 2) confirmed-but-stale → failed_delivery
const flipped = await sql`
UPDATE payment_sessions
SET status = ${PaymentSessionStatus.FAILED_PAIRING}
WHERE status = ${PaymentSessionStatus.CONFIRMED}
UPDATE payment_requests
SET status = ${PaymentRequestStatus.FAILED_DELIVERY}
WHERE status = ${PaymentRequestStatus.CONFIRMED}
AND expires_at <= NOW()
RETURNING id, customer_id, targeted_mitra_id, amount
RETURNING *
`
await Promise.all(flipped.map(async (row) => {
await recordFailure({
paymentSessionId: row.id,
paymentRequestId: row.id,
customerId: row.customer_id,
targetedMitraId: row.targeted_mitra_id,
causeTag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
causeTag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
amount: row.amount,
})
// Customer may be on searching/waiting; push terminal PAIRING_FAILED in real time.
// FCM fallback when not WS-connected so they're notified at the OS level.
// Customer-facing: push terminal PAIRING_FAILED via WS; FCM fallback if not connected.
try {
const wsSent = sendToUser(UserType.CUSTOMER, row.customer_id, {
type: WsMessage.PAIRING_FAILED,
payment_session_id: row.id,
cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
payment_request_id: row.id,
cause_tag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
is_terminal: true,
})
if (!wsSent) {
@@ -281,43 +521,73 @@ export const expireStalePaymentSessions = async () => {
body: 'Sesi pembayaranmu telah berakhir. Silakan mulai ulang.',
data: {
type: WsMessage.PAIRING_FAILED,
payment_session_id: row.id,
cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
payment_request_id: row.id,
cause_tag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
},
})
}
} catch (err) {
console.error('expireStalePaymentSessions: failed to notify customer', {
paymentSessionId: row.id, customerId: row.customer_id, err,
console.error('expireStalePaymentRequests: failed to notify customer', {
paymentRequestId: row.id, customerId: row.customer_id, err,
})
}
emit('payment_request.delivery_failed', {
...buildEventPayload(row),
causeTag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
})
}))
return { expired: expired.length, failed: flipped.length }
// 3) Reconciliation — confirmed payments that should have started product work but didn't.
// For chat_session: no chat_sessions row exists AND no pairing_failures row exists.
// The 30-second buffer avoids racing with happy-path subscribers mid-flight.
const orphans = await sql`
SELECT *
FROM payment_requests pr
WHERE pr.status = ${PaymentRequestStatus.CONFIRMED}
AND pr.confirmed_at < NOW() - INTERVAL '30 seconds'
AND pr.product_type = 'chat_session'
AND NOT EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.payment_request_id = pr.id)
AND NOT EXISTS (SELECT 1 FROM pairing_failures pf WHERE pf.payment_request_id = pr.id)
LIMIT 100
`
let reconciled = 0
for (const row of orphans) {
try {
// Re-emit the event the subscriber missed. Subscriber's idempotency check
// makes this safe even if the subscriber did actually run (just slow).
emit('payment_request.confirmed', buildEventPayload(row))
reconciled++
} catch (err) {
console.error('[reconciler] re-emit failed', { paymentRequestId: row.id, err })
}
}
return { expired: expired.length, failed: flipped.length, reconciled }
}
export const getPaymentSession = async (id) => {
const [row] = await sql`
SELECT id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
FROM payment_sessions
WHERE id = ${id}
`
return row || null
// Backward-compat alias — server.js previously called expireStalePaymentSessions
export const expireStalePaymentSessions = expireStalePaymentRequests
// --- getPayment ---
export const getPayment = async (id) => {
const [row] = await sql`SELECT * FROM payment_requests WHERE id = ${id}`
return row ?? null
}
// Backward-compat aliases (legacy callers — to be migrated in a follow-up pass)
export const createPaymentSession = (args) => requestPayment(args)
export const confirmPaymentSession = confirmPaymentForCustomer
export const consumePaymentSession = consumePayment
export const abandonPaymentSession = cancelPayment
export const getPaymentSession = getPayment
// --- getCustomerPendingPayments (unchanged shape) ---
/**
* Phase 4 Stage 10 — Chat Tab Pembayaran feed.
*
* Returns the customer's pending payment sessions (initial + extension) that
* haven't paid AND haven't expired. The `expires_at > NOW()` filter is
* defensive: the background sweeper flips stale pending rows to `expired`,
* but rows can be stale between sweeps, so we filter inline too.
*
* Extension rows resolve mitra info via session_extensions → chat_sessions →
* mitras. Initial rows fall back to `payment_sessions.targeted_mitra_id`
* (set for targeted "Curhat lagi" flows); for general-blast initial rows
* the mitra is unknown until pairing succeeds, so mitra fields are null.
* Returns the customer's pending payment requests (initial + extension) that
* haven't paid AND haven't expired.
*/
export const getCustomerPendingPayments = async (customerId) => {
const items = await sql`
@@ -331,15 +601,37 @@ export const getCustomerPendingPayments = async (customerId) => {
ps.expires_at,
COALESCE(ext_m.id, tgt_m.id) AS mitra_id,
COALESCE(ext_m.display_name, tgt_m.display_name) AS mitra_display_name
FROM payment_sessions ps
LEFT JOIN session_extensions se ON se.payment_session_id = ps.id
FROM payment_requests ps
LEFT JOIN session_extensions se ON se.payment_request_id = ps.id
LEFT JOIN chat_sessions cs ON cs.id = se.session_id
LEFT JOIN mitras ext_m ON ext_m.id = cs.mitra_id
LEFT JOIN mitras tgt_m ON tgt_m.id = ps.targeted_mitra_id
WHERE ps.customer_id = ${customerId}
AND ps.status = ${PaymentSessionStatus.PENDING}
AND ps.status = ${PaymentRequestStatus.PENDING}
AND ps.expires_at > NOW()
ORDER BY ps.created_at DESC
`
return { items, total: items.length }
}
// --- Pairing subscriber wiring (called from server.js at startup) ---
/**
* Wires pairing.service as a subscriber to payment_request.confirmed.
* Filters on productType so future product subscribers can coexist.
* Subscriber implements its own idempotency (skip if chat_sessions row exists).
*
* Imported lazily to avoid a circular import: pairing.service imports payment.service
* for failPaymentSession + consumePayment + getPayment.
*/
export const registerPairingSubscriber = () => {
on('payment_request.confirmed', async (evt) => {
if (evt.productType !== 'chat_session') return
const { startPairingFromPaymentRequest } = await import('./pairing.service.js')
await startPairingFromPaymentRequest({
paymentRequestId: evt.paymentRequestId,
productMetadata: evt.productMetadata,
customerId: evt.customerId,
})
})
}

View File

@@ -149,9 +149,9 @@ export const listSessions = async ({ page = 1, limit = 20, status, topic_sensiti
}
export const getSessionById = async (sessionId) => {
// `mode` lives on payment_sessions (chat | call), introduced in Phase 4.1.
// `mode` lives on payment_requests (chat | call), introduced in Phase 4.1.
// The chat header pill needs it, so surface it on every session.info read.
// Falls back to 'chat' for pre-3.7 rows where payment_session_id is null.
// Falls back to 'chat' for pre-3.7 rows where payment_request_id is null.
const [session] = await sql`
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics,
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
@@ -162,7 +162,7 @@ export const getSessionById = async (sessionId) => {
FROM chat_sessions cs
INNER JOIN customers c ON c.id = cs.customer_id
LEFT JOIN mitras m ON m.id = cs.mitra_id
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
WHERE cs.id = ${sessionId}
`
return session
@@ -251,7 +251,7 @@ export const getCustomerHistory = async (customerId, { cursor = null, limit = 20
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
WHERE cs.customer_id = ${customerId}
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
AND (
@@ -275,7 +275,7 @@ export const getCustomerHistory = async (customerId, { cursor = null, limit = 20
FROM chat_sessions cs
LEFT JOIN mitras m ON m.id = cs.mitra_id
LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
WHERE cs.customer_id = ${customerId}
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC, cs.id DESC

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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