Compare commits

..

2 Commits

Author SHA1 Message Date
3fff4b1c6e 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>
2026-05-25 12:52:33 +08:00
e6d991373e Client Profile: save-phone banner for anonymous users
Anonymous customers now see a brand-gradient "Simpan Nomor HP" panel
above the user card on the kamu tab, ported from the Figma SProfile
save-phone banner. Tapping it pushes /auth/register?from=profile, which
hides the "lanjut tanpa verifikasi (harga normal)" link — a user who
re-entered the verif funnel from Profile shouldn't be re-offered the
anon exit. Spec §1.3 added documenting the ?from= entry-point
convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:39:13 +08:00
40 changed files with 2939 additions and 534 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))
})
}
/**
* Create a new payment session in `pending` status.
* Reads `payment_session_timeout_minutes` from config to compute expires_at.
*
* 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.
*/
export const createPaymentSession = async ({
customerId,
durationMinutes,
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 request in `pending` status. When XENDIT_ENABLED=true, also
* creates a Xendit Invoice and stamps invoice_id + invoice_url on the row.
*
* Product-agnostic: callers stamp `productType` + `productMetadata`. The legacy
* top-level chat-specific args (durationMinutes, mode, isExtension, targetedMitraId,
* isFirstSessionDiscount) are accepted for backward compat with existing chat code,
* and also written into product_metadata when productType === 'chat_session'.
*/
export const requestPayment = async ({
productType = 'chat_session',
productMetadata = {},
customerId,
amount,
ttlMinutes,
// Chat-specific legacy fields (still written to top-level columns for now)
durationMinutes,
mode = SessionMode.CHAT,
isFirstSessionDiscount = false,
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 (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 })
}
}
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
return { expired: expired.length, failed: flipped.length, reconciled }
}
// 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}
`

View File

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

View File

@@ -64,7 +64,7 @@ class ExtensionStatus {
ExtensionStatus._();
}
/// Session mode — chat or voice call. Mirrors backend `payment_sessions.mode`
/// Session mode — chat or voice call. Mirrors backend `payment_requests.mode`
/// (added in Phase 4 stage 1). A `call` session is functionally a chat with a
/// "voice call" badge and (eventually) a Meet link the mitra pastes manually;
/// no real audio transport is built yet.
@@ -153,7 +153,7 @@ enum PairingFailureCause {
targetedMitraOffline('targeted_mitra_offline'),
targetedMitraRejected('targeted_mitra_rejected'),
targetedMitraTimeout('targeted_mitra_timeout'),
paymentSessionExpired('payment_session_expired'),
paymentSessionExpired('payment_request_expired'),
customerCancelled('customer_cancelled'),
unknown('unknown');
@@ -165,13 +165,13 @@ enum PairingFailureCause {
}
/// Payment session lifecycle. Mirror of backend
/// `PaymentSessionStatus`.
class PaymentSessionStatus {
/// `PaymentRequestStatus`.
class PaymentRequestStatus {
static const pending = 'pending';
static const confirmed = 'confirmed';
static const consumed = 'consumed';
static const failedPairing = 'failed_pairing';
static const failedPairing = 'failed_delivery';
static const abandoned = 'abandoned';
static const expired = 'expired';
PaymentSessionStatus._();
PaymentRequestStatus._();
}

View File

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

View File

@@ -123,6 +123,12 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final name = _greetingName(authState.valueOrNull);
final shownName = name.isEmpty ? 'kamu' : name;
// When the user arrives via the Profile "Simpan Nomor HP" CTA they've
// already committed to identifying — drop the anonymous escape hatch so
// there's only one way forward.
final fromProfile =
GoRouterState.of(context).uri.queryParameters['from'] == 'profile';
return Scaffold(
backgroundColor: HaloTokens.bg,
body: SafeArea(
@@ -207,6 +213,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
ref.read(authProvider.notifier).requestOtp(_e164Phone())
: null,
),
if (!fromProfile) ...[
const SizedBox(height: 4),
TextButton(
onPressed: isLoading
@@ -229,6 +236,7 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
),
),
],
],
),
),
),

View File

@@ -6,7 +6,7 @@ import '../../../core/pairing/pairing_notifier.dart';
/// Terminal failed-pairing screen.
///
/// Reached when the pairing notifier transitions to [PairingFailedData]
/// (terminal — payment session is `failed_pairing` server-side, audit row
/// (terminal — payment session is `failed_delivery` server-side, audit row
/// recorded). Copy is intentionally identical regardless of `cause_tag` for
/// now (the design pass will revise this later).
///

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../core/api/api_client_provider.dart';
import '../../../core/constants.dart';
import '../../../core/theme/halo_tokens.dart';
@@ -36,6 +37,7 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
bool _initialLoading = true;
bool _terminal = false;
String? _error;
bool _invoiceUrlLaunched = false; // Phase 5: only auto-launch the Custom Tab once
Duration get _remaining {
final exp = _expiresAt;
@@ -80,11 +82,34 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
_qrPayload = (session['qr_string'] as String?) ?? widget.paymentId;
_initialLoading = false;
});
// Phase 5: when Xendit is on, the backend returns an `xendit_invoice_url`
// (Xendit's hosted checkout). Open it in a Custom Tab (Android) /
// SFSafariViewController (iOS) so the customer stays inside the app's
// browser context. Fire-and-forget — polling continues regardless.
// When Xendit is off (dev/Maestro), invoice_url is null and the QR fallback below is used.
await _maybeLaunchInvoiceUrl(session);
_maybeHandleStatus(session);
_startTicker();
_resumePolling();
}
Future<void> _maybeLaunchInvoiceUrl(Map<String, dynamic> session) async {
if (_invoiceUrlLaunched) return;
final url = (session['xendit_invoice_url'] as String?) ?? (session['invoice_url'] as String?);
if (url == null || url.isEmpty) return;
_invoiceUrlLaunched = true;
try {
await launchUrl(
Uri.parse(url),
mode: LaunchMode.inAppBrowserView, // Custom Tab on Android, SFVC on iOS
);
} catch (e) {
// Silent — polling will eventually resolve to expired if the customer can't pay.
// Don't surface an error toast; the user might have a non-Custom-Tab-capable env
// and url_launcher falls back to the system browser automatically.
}
}
void _startTicker() {
_ticker?.cancel();
_ticker = Timer.periodic(_tickInterval, (_) {
@@ -111,7 +136,7 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
Future<Map<String, dynamic>?> _fetchSession() async {
try {
final api = ref.read(apiClientProvider);
final response = await api.get('/api/client/payment-sessions/${widget.paymentId}');
final response = await api.get('/api/client/payment-requests/${widget.paymentId}');
return response['data'] as Map<String, dynamic>?;
} catch (e) {
if (!mounted) return null;
@@ -122,12 +147,12 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
void _maybeHandleStatus(Map<String, dynamic> session) {
final status = session['status'] as String?;
if (status == PaymentSessionStatus.confirmed ||
status == PaymentSessionStatus.consumed) {
if (status == PaymentRequestStatus.confirmed ||
status == PaymentRequestStatus.consumed) {
_markTerminal();
_navigateTerminal('/onboarding/notif-gate');
} else if (status == PaymentSessionStatus.expired ||
status == PaymentSessionStatus.abandoned) {
} else if (status == PaymentRequestStatus.expired ||
status == PaymentRequestStatus.abandoned) {
_markTerminal();
_navigateTerminal('/payment/expired/${widget.paymentId}');
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_notifier.dart';
import '../../core/theme/halo_tokens.dart';
import '../home/widgets/halo_tab_bar.dart';
@@ -19,6 +20,7 @@ class ProfileScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final authData = ref.watch(authProvider).valueOrNull;
final isAnonymous = authData is AuthAnonymousData;
final (name, phone) = switch (authData) {
AuthAuthenticatedData d => (
@@ -53,6 +55,12 @@ class ProfileScreen extends ConsumerWidget {
),
),
const SizedBox(height: 18),
if (isAnonymous) ...[
_SavePhoneBanner(
onTap: () => context.push('/auth/register?from=profile'),
),
const SizedBox(height: 14),
],
_UserCard(name: name, phone: phone),
const SizedBox(height: 20),
_MenuCard(items: [
@@ -154,6 +162,94 @@ class ProfileScreen extends ConsumerWidget {
}
}
class _SavePhoneBanner extends StatelessWidget {
final VoidCallback onTap;
const _SavePhoneBanner({required this.onTap});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [HaloTokens.brandSofter, HaloTokens.brandSoft],
),
borderRadius: HaloRadius.lg,
border: Border.all(color: HaloTokens.brand, width: 1.5),
boxShadow: [
BoxShadow(
color: HaloTokens.brand.withValues(alpha: 0.10),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: const BoxDecoration(
color: HaloTokens.brand,
borderRadius: BorderRadius.all(Radius.circular(10)),
),
alignment: Alignment.center,
child: const Icon(
Icons.smartphone,
size: 18,
color: Colors.white,
),
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Biar riwayat curhat kamu tersimpan, yuk simpan Nomor Handphone kamu…',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13,
fontWeight: FontWeight.w600,
color: HaloTokens.brandDark,
height: 1.4,
),
),
),
],
),
const SizedBox(height: 12),
Material(
color: HaloTokens.brand,
borderRadius: HaloRadius.md,
child: InkWell(
onTap: onTap,
borderRadius: HaloRadius.md,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
alignment: Alignment.center,
child: const Text(
'Simpan Nomor HP',
style: TextStyle(
fontFamily: HaloTokens.fontBody,
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: Colors.white,
letterSpacing: -0.07,
),
),
),
),
),
],
),
);
}
}
class _UserCard extends StatelessWidget {
final String name;
final String? phone;

View File

@@ -8,7 +8,7 @@ export const PairingFailureCause = Object.freeze({
TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline',
TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected',
TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout',
PAYMENT_SESSION_EXPIRED: 'payment_session_expired',
PAYMENT_REQUEST_EXPIRED: 'payment_request_expired',
CUSTOMER_CANCELLED: 'customer_cancelled',
EXTENSION_REJECTED: 'extension_rejected',
EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped',
@@ -20,7 +20,7 @@ export const PairingFailureCauseLabel = Object.freeze({
[PairingFailureCause.TARGETED_MITRA_OFFLINE]: 'Targeted mitra offline',
[PairingFailureCause.TARGETED_MITRA_REJECTED]: 'Targeted mitra rejected',
[PairingFailureCause.TARGETED_MITRA_TIMEOUT]: 'Targeted mitra timeout',
[PairingFailureCause.PAYMENT_SESSION_EXPIRED]: 'Payment session expired',
[PairingFailureCause.PAYMENT_REQUEST_EXPIRED]: 'Payment session expired',
[PairingFailureCause.CUSTOMER_CANCELLED]: 'Customer cancelled',
[PairingFailureCause.EXTENSION_REJECTED]: 'Extension rejected',
[PairingFailureCause.EXTENSION_SAFEGUARD_TRIPPED]: 'Extension safeguard tripped',

View File

@@ -85,6 +85,17 @@ this doc.
### 1.2 Backend
- New: `GET /api/client/onboarding-state` → returns `{ has_paid_first_session: bool }`. Used by client_app to pick whether to show this sheet.
### 1.3 Profile re-prompt for anon users (shipped 2026-05-22)
> Figma: `screens/extras.jsx::SProfile` save-phone banner (lines 170-204).
> Code: `client_app/lib/features/profile/profile_screen.dart::_SavePhoneBanner`.
For users who chose the anon path in §1 and later open the **kamu** (Profile) tab, a brand-gradient banner appears between the page title and the user card. Body copy: *"Biar riwayat curhat kamu tersimpan, yuk simpan Nomor Handphone kamu…"*. CTA `Simpan Nomor HP` pushes `/auth/register?from=profile`.
- Banner gates on `authData is AuthAnonymousData` — disappears the moment the auth state flips to `AuthAuthenticatedData` after a successful OTP verify.
- The `?from=profile` query param causes `RegisterScreen` to **omit** the *"lanjut tanpa verifikasi (harga normal)"* escape-hatch link. A user who tapped the banner deliberately re-entered the verif funnel, so we don't re-offer the anon exit.
- Convention for future entry points: when pushing into `/auth/register` from somewhere that should branch the screen (copy, escape hatch, post-OTP destination), pass `?from=<callsite-slug>` and read `GoRouterState.of(context).uri.queryParameters['from']` in `RegisterScreen.build()`. The router's redirect preserves query params for anonymous users on `/auth/*` routes.
## 2. ESP screening + USP screen
> Figma: `screens/onboarding.jsx::S5ESP` and `S5USP`. Existing

File diff suppressed because it is too large Load Diff