Phase 5 Xendit: Stages 1-7 (XENDIT_ENABLED=false; Stage 8 pending creds)
Backend - payment_sessions → payment_requests rename across DB schema + 29 files - payment.service.js becomes product-agnostic owner: EventEmitter + Xendit wrapper + requestPayment / confirmPayment public API; legacy aliases retained for existing chat callers - Webhook handler at POST /api/shared/payment/webhooks/xendit, with constant-time token verification (8 vitest cases) - Server-driven pairing: payment.service emits payment_request.confirmed → pairing subscriber starts the blast. Legacy POST /chat/request still works during the cutover. - Reconciliation sweeper extended (re-emits events for confirmed rows with no chat session) - SIGTERM drain + startup reconciliation pass in server.js Customer app - waiting_payment_screen opens xendit_invoice_url via LaunchMode.inAppBrowserView - searching / no-bestie / targeted-waiting / pairing-notifier updated to consume the new payment_request_id contract - pending_payments_provider + bestie-unavailable dialog migrated Dev / testing - XENDIT_ENABLED=false is the safe default; .env.example documents the four new vars - backend/.dev/xendit-fake-webhook.sh exercises the handler without ngrok - 90/92 backend tests pass (two pre-existing session-timer flakes, unrelated); client_app analyzer clean - requirement/phase5-xendit-plan.md is the canonical reference Stage 8 (live E2E) blocked on Xendit test-mode keys. The dashboard's single-webhook-URL constraint will be worked around via a self-poll script next session. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
35
backend/.dev/xendit-fake-webhook.sh
Executable file
35
backend/.dev/xendit-fake-webhook.sh
Executable 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
|
||||||
@@ -40,3 +40,15 @@ ADMIN_PASSWORD=ChangeMe123!
|
|||||||
# --- FCM (kept — only Messaging is used; Auth is self-managed) ---
|
# --- FCM (kept — only Messaging is used; Auth is self-managed) ---
|
||||||
# Path to Firebase service-account JSON (falls back to backend/firebase-service-account.json)
|
# Path to Firebase service-account JSON (falls back to backend/firebase-service-account.json)
|
||||||
FIREBASE_SERVICE_ACCOUNT_PATH=
|
FIREBASE_SERVICE_ACCOUNT_PATH=
|
||||||
|
|
||||||
|
# --- Phase 5: Xendit (dev-safe defaults: integration disabled) ---
|
||||||
|
#
|
||||||
|
# Flip XENDIT_ENABLED=true in staging/prod once secret + webhook token are populated.
|
||||||
|
# When false, payment.service.js skips invoice creation and the dev/Maestro stub
|
||||||
|
# /internal/_test/force-confirm-payment plays the role of the webhook.
|
||||||
|
# See requirement/phase5-xendit-plan.md.
|
||||||
|
XENDIT_ENABLED=false
|
||||||
|
XENDIT_SECRET_KEY=
|
||||||
|
XENDIT_WEBHOOK_TOKEN=
|
||||||
|
XENDIT_SUCCESS_REDIRECT_URL=
|
||||||
|
XENDIT_FAILURE_REDIRECT_URL=
|
||||||
|
|||||||
9
backend/package-lock.json
generated
9
backend/package-lock.json
generated
@@ -22,6 +22,7 @@
|
|||||||
"jwks-rsa": "^3.2.2",
|
"jwks-rsa": "^3.2.2",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
|
"xendit-node": "^7.0.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -4829,6 +4830,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xendit-node": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xendit-node/-/xendit-node-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-atsCQ9femoWLu+hU5rY0TZ+ZhqSYbpTtABZN7rGMhhBh1xLhTuY1rgLfoaXzFiRt3eOQRqsew78wPUcQn1QckA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"jwks-rsa": "^3.2.2",
|
"jwks-rsa": "^3.2.2",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
|
"xendit-node": "^7.0.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { publicBestieAvailabilityRoutes } from './routes/public/public.bestie-av
|
|||||||
import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js'
|
import { clientOnboardingRoutes } from './routes/public/client.onboarding.routes.js'
|
||||||
import { sharedSupportRoutes } from './routes/public/shared.support.routes.js'
|
import { sharedSupportRoutes } from './routes/public/shared.support.routes.js'
|
||||||
import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
|
import { sharedChatRoutes } from './routes/public/shared.chat.routes.js'
|
||||||
|
import { paymentWebhookRoutes } from './routes/public/shared.payment-webhooks.routes.js'
|
||||||
import { errorHandler } from './plugins/error-handler.js'
|
import { errorHandler } from './plugins/error-handler.js'
|
||||||
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js'
|
import { registerWebSocketPlugin, registerWebSocketRoute } from './plugins/websocket.js'
|
||||||
|
|
||||||
@@ -35,13 +36,15 @@ export const buildPublicApp = async () => {
|
|||||||
app.register(mitraStatusRoutes, { prefix: '/api/mitra/status' })
|
app.register(mitraStatusRoutes, { prefix: '/api/mitra/status' })
|
||||||
app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' })
|
app.register(mitraChatRoutes, { prefix: '/api/mitra/chat-requests' })
|
||||||
app.register(clientChatRoutes, { prefix: '/api/client/chat' })
|
app.register(clientChatRoutes, { prefix: '/api/client/chat' })
|
||||||
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-sessions' })
|
app.register(clientPaymentRoutes, { prefix: '/api/client/payment-requests' })
|
||||||
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
|
app.register(clientMitraAvailabilityRoutes, { prefix: '/api/client/mitra-availability' })
|
||||||
app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' })
|
app.register(publicBestieAvailabilityRoutes, { prefix: '/api/public/bestie' })
|
||||||
// Onboarding-state stays client-only (anonymous customer flow). Support
|
// Onboarding-state stays client-only (anonymous customer flow). Support
|
||||||
// handles are shared — both client and mitra apps link the same WA/TG.
|
// handles are shared — both client and mitra apps link the same WA/TG.
|
||||||
app.register(clientOnboardingRoutes, { prefix: '/api/client' })
|
app.register(clientOnboardingRoutes, { prefix: '/api/client' })
|
||||||
app.register(sharedSupportRoutes, { prefix: '/api/shared' })
|
app.register(sharedSupportRoutes, { prefix: '/api/shared' })
|
||||||
|
// Payment provider webhooks. Public + token-authed via x-callback-token.
|
||||||
|
app.register(paymentWebhookRoutes, { prefix: '/api/shared/payment' })
|
||||||
|
|
||||||
// WebSocket route (registered at app level, not prefixed)
|
// WebSocket route (registered at app level, not prefixed)
|
||||||
registerWebSocketRoute(app)
|
registerWebSocketRoute(app)
|
||||||
|
|||||||
@@ -62,14 +62,22 @@ export const SessionMode = Object.freeze({
|
|||||||
CALL: 'call',
|
CALL: 'call',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Payment session lifecycle
|
// payment_requests lifecycle
|
||||||
export const PaymentSessionStatus = Object.freeze({
|
// pending initial state — invoice created or stub awaiting customer pay
|
||||||
|
// confirmed money landed — emits payment_request.confirmed
|
||||||
|
// consumed product delivered (e.g. chat session started) — no event
|
||||||
|
// expired pending TTL elapsed without payment — emits payment_request.expired
|
||||||
|
// abandoned customer cancelled while pending — emits payment_request.cancelled
|
||||||
|
// failed createInvoice failed before customer paid — emits payment_request.failed
|
||||||
|
// failed_delivery paid but delivery failed (was "failed_pairing" pre-Phase-5) — emits payment_request.delivery_failed
|
||||||
|
export const PaymentRequestStatus = Object.freeze({
|
||||||
PENDING: 'pending',
|
PENDING: 'pending',
|
||||||
CONFIRMED: 'confirmed',
|
CONFIRMED: 'confirmed',
|
||||||
CONSUMED: 'consumed',
|
CONSUMED: 'consumed',
|
||||||
FAILED_PAIRING: 'failed_pairing',
|
|
||||||
ABANDONED: 'abandoned',
|
|
||||||
EXPIRED: 'expired',
|
EXPIRED: 'expired',
|
||||||
|
ABANDONED: 'abandoned',
|
||||||
|
FAILED: 'failed',
|
||||||
|
FAILED_DELIVERY: 'failed_delivery',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Pairing failure cause tags
|
// Pairing failure cause tags
|
||||||
@@ -79,7 +87,7 @@ export const PairingFailureCause = Object.freeze({
|
|||||||
TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline',
|
TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline',
|
||||||
TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected',
|
TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected',
|
||||||
TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout',
|
TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout',
|
||||||
PAYMENT_SESSION_EXPIRED: 'payment_session_expired',
|
PAYMENT_REQUEST_EXPIRED: 'payment_request_expired',
|
||||||
CUSTOMER_CANCELLED: 'customer_cancelled',
|
CUSTOMER_CANCELLED: 'customer_cancelled',
|
||||||
EXTENSION_REJECTED: 'extension_rejected',
|
EXTENSION_REJECTED: 'extension_rejected',
|
||||||
EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped',
|
EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped',
|
||||||
|
|||||||
@@ -434,9 +434,28 @@ const migrate = async () => {
|
|||||||
|
|
||||||
// --- Phase 3.7: Paid Pairing Flow + Returning-Chat + Extension Flip ---
|
// --- Phase 3.7: Paid Pairing Flow + Returning-Chat + Extension Flip ---
|
||||||
|
|
||||||
// payment_sessions: customer-initiated payment intents (mocked) that gate pairing
|
// Phase 5 rename — must run BEFORE the original CREATE TABLE so we don't end up
|
||||||
|
// with both payment_sessions (old) and payment_requests (newly created from
|
||||||
|
// IF NOT EXISTS) coexisting in the same schema. Schema-anchored via
|
||||||
|
// current_schema() so the test schema's rename works even after the dev
|
||||||
|
// schema already has payment_requests.
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS payment_sessions (
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass(current_schema() || '.payment_sessions') IS NOT NULL
|
||||||
|
AND to_regclass(current_schema() || '.payment_requests') IS NULL THEN
|
||||||
|
EXECUTE 'ALTER TABLE ' || quote_ident(current_schema()) || '.payment_sessions RENAME TO payment_requests';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`
|
||||||
|
|
||||||
|
// payment_requests: customer-initiated payment intents that gate pairing.
|
||||||
|
// (Phase 5 rename — was `payment_sessions` in Phase 3.7. The rename block
|
||||||
|
// immediately above + the Phase 5 block at the end of this file together
|
||||||
|
// handle every state: fresh DB, pre-Phase-5 dev DB, post-Phase-5 dev DB.)
|
||||||
|
await sql`
|
||||||
|
CREATE TABLE IF NOT EXISTS payment_requests (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||||
amount INTEGER NOT NULL DEFAULT 0,
|
amount INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -444,7 +463,7 @@ const migrate = async () => {
|
|||||||
is_free_trial BOOLEAN NOT NULL DEFAULT false,
|
is_free_trial BOOLEAN NOT NULL DEFAULT false,
|
||||||
is_extension BOOLEAN NOT NULL DEFAULT false,
|
is_extension BOOLEAN NOT NULL DEFAULT false,
|
||||||
status TEXT NOT NULL DEFAULT 'pending'
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
CHECK (status IN ('pending','confirmed','consumed','failed_pairing','abandoned','expired')),
|
CHECK (status IN ('pending','confirmed','consumed','failed_delivery','abandoned','expired','failed')),
|
||||||
targeted_mitra_id UUID REFERENCES mitras(id),
|
targeted_mitra_id UUID REFERENCES mitras(id),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
confirmed_at TIMESTAMPTZ,
|
confirmed_at TIMESTAMPTZ,
|
||||||
@@ -454,20 +473,20 @@ const migrate = async () => {
|
|||||||
`
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE INDEX IF NOT EXISTS idx_payment_sessions_customer
|
CREATE INDEX IF NOT EXISTS idx_payment_requests_customer
|
||||||
ON payment_sessions (customer_id)
|
ON payment_requests (customer_id)
|
||||||
`
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE INDEX IF NOT EXISTS idx_payment_sessions_status_expires
|
CREATE INDEX IF NOT EXISTS idx_payment_requests_status_expires
|
||||||
ON payment_sessions (status, expires_at)
|
ON payment_requests (status, expires_at)
|
||||||
`
|
`
|
||||||
|
|
||||||
// pairing_failures: cause-tagged audit rows for confirmed payments that did not yield a chat
|
// pairing_failures: cause-tagged audit rows for confirmed payments that did not yield a chat
|
||||||
await sql`
|
await sql`
|
||||||
CREATE TABLE IF NOT EXISTS pairing_failures (
|
CREATE TABLE IF NOT EXISTS pairing_failures (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
payment_session_id UUID NOT NULL REFERENCES payment_sessions(id) ON DELETE CASCADE,
|
payment_request_id UUID NOT NULL REFERENCES payment_requests(id) ON DELETE CASCADE,
|
||||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||||
targeted_mitra_id UUID REFERENCES mitras(id),
|
targeted_mitra_id UUID REFERENCES mitras(id),
|
||||||
cause_tag TEXT NOT NULL
|
cause_tag TEXT NOT NULL
|
||||||
@@ -477,7 +496,7 @@ const migrate = async () => {
|
|||||||
'targeted_mitra_offline',
|
'targeted_mitra_offline',
|
||||||
'targeted_mitra_rejected',
|
'targeted_mitra_rejected',
|
||||||
'targeted_mitra_timeout',
|
'targeted_mitra_timeout',
|
||||||
'payment_session_expired',
|
'payment_request_expired',
|
||||||
'customer_cancelled'
|
'customer_cancelled'
|
||||||
)),
|
)),
|
||||||
amount INTEGER NOT NULL,
|
amount INTEGER NOT NULL,
|
||||||
@@ -510,7 +529,8 @@ const migrate = async () => {
|
|||||||
'targeted_mitra_offline',
|
'targeted_mitra_offline',
|
||||||
'targeted_mitra_rejected',
|
'targeted_mitra_rejected',
|
||||||
'targeted_mitra_timeout',
|
'targeted_mitra_timeout',
|
||||||
'payment_session_expired',
|
-- Phase 5 rename: payment_session_expired → payment_request_expired
|
||||||
|
'payment_request_expired',
|
||||||
'customer_cancelled',
|
'customer_cancelled',
|
||||||
'extension_rejected',
|
'extension_rejected',
|
||||||
'extension_safeguard_tripped'
|
'extension_safeguard_tripped'
|
||||||
@@ -534,27 +554,27 @@ const migrate = async () => {
|
|||||||
ON pairing_failures (created_at DESC) WHERE operator_action IS NULL
|
ON pairing_failures (created_at DESC) WHERE operator_action IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
// chat_sessions FK to payment_sessions (nullable for backward compat with pre-3.7 rows)
|
// chat_sessions FK to payment_requests (nullable for backward compat with pre-3.7 rows)
|
||||||
await sql`
|
await sql`
|
||||||
ALTER TABLE chat_sessions
|
ALTER TABLE chat_sessions
|
||||||
ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(id)
|
ADD COLUMN IF NOT EXISTS payment_request_id UUID REFERENCES payment_requests(id)
|
||||||
`
|
`
|
||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment
|
CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment_request
|
||||||
ON chat_sessions (payment_session_id)
|
ON chat_sessions (payment_request_id)
|
||||||
`
|
`
|
||||||
|
|
||||||
// session_extensions FK to payment_sessions (extensions also have their own payment session)
|
// session_extensions FK to payment_requests (extensions also have their own payment request)
|
||||||
await sql`
|
await sql`
|
||||||
ALTER TABLE session_extensions
|
ALTER TABLE session_extensions
|
||||||
ADD COLUMN IF NOT EXISTS payment_session_id UUID REFERENCES payment_sessions(id)
|
ADD COLUMN IF NOT EXISTS payment_request_id UUID REFERENCES payment_requests(id)
|
||||||
`
|
`
|
||||||
|
|
||||||
// Phase 3.7 config keys (idempotent — existing dev DBs need a manual update for extension_timeout_seconds → 10)
|
// Phase 3.7 config keys (idempotent — existing dev DBs need a manual update for extension_timeout_seconds → 10)
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO app_config (key, value) VALUES
|
INSERT INTO app_config (key, value) VALUES
|
||||||
('payment_session_timeout_minutes', '{"value": 20}'),
|
('payment_request_timeout_minutes', '{"value": 20}'),
|
||||||
('returning_chat_confirmation_timeout_seconds', '{"value": 20}'),
|
('returning_chat_confirmation_timeout_seconds', '{"value": 20}'),
|
||||||
('extension_default_action_on_timeout', '{"value": "auto_approve"}'),
|
('extension_default_action_on_timeout', '{"value": "auto_approve"}'),
|
||||||
('pairing_blast_timeout_seconds', '{"value": 60}')
|
('pairing_blast_timeout_seconds', '{"value": 60}')
|
||||||
@@ -563,13 +583,13 @@ const migrate = async () => {
|
|||||||
|
|
||||||
// --- Phase 4 — Customer Flow Redesign ---
|
// --- Phase 4 — Customer Flow Redesign ---
|
||||||
|
|
||||||
// 1. payment_sessions + chat_sessions: replace is_free_trial with is_first_session_discount.
|
// 1. payment_requests + chat_sessions: replace is_free_trial with is_first_session_discount.
|
||||||
// Phase 3.7 was the first ship of is_free_trial and never went live with real users
|
// Phase 3.7 was the first ship of is_free_trial and never went live with real users
|
||||||
// (per project memory), so we copy whatever values exist and drop the old column.
|
// (per project memory), so we copy whatever values exist and drop the old column.
|
||||||
// Idempotent: ADD/DROP both use IF [NOT] EXISTS, and each UPDATE is gated on the
|
// Idempotent: ADD/DROP both use IF [NOT] EXISTS, and each UPDATE is gated on the
|
||||||
// old column still existing.
|
// old column still existing.
|
||||||
await sql`
|
await sql`
|
||||||
ALTER TABLE payment_sessions
|
ALTER TABLE payment_requests
|
||||||
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
|
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
|
||||||
`
|
`
|
||||||
await sql`
|
await sql`
|
||||||
@@ -590,11 +610,11 @@ const migrate = async () => {
|
|||||||
BEGIN
|
BEGIN
|
||||||
IF EXISTS (
|
IF EXISTS (
|
||||||
SELECT 1 FROM pg_attribute
|
SELECT 1 FROM pg_attribute
|
||||||
WHERE attrelid = to_regclass('payment_sessions')
|
WHERE attrelid = to_regclass('payment_requests')
|
||||||
AND attname = 'is_free_trial'
|
AND attname = 'is_free_trial'
|
||||||
AND NOT attisdropped
|
AND NOT attisdropped
|
||||||
) THEN
|
) THEN
|
||||||
EXECUTE 'UPDATE payment_sessions
|
EXECUTE 'UPDATE payment_requests
|
||||||
SET is_first_session_discount = is_free_trial
|
SET is_first_session_discount = is_free_trial
|
||||||
WHERE is_free_trial = true
|
WHERE is_free_trial = true
|
||||||
AND is_first_session_discount = false';
|
AND is_first_session_discount = false';
|
||||||
@@ -614,13 +634,13 @@ const migrate = async () => {
|
|||||||
$$
|
$$
|
||||||
`
|
`
|
||||||
|
|
||||||
await sql`ALTER TABLE payment_sessions DROP COLUMN IF EXISTS is_free_trial`
|
await sql`ALTER TABLE payment_requests DROP COLUMN IF EXISTS is_free_trial`
|
||||||
await sql`ALTER TABLE chat_sessions DROP COLUMN IF EXISTS is_free_trial`
|
await sql`ALTER TABLE chat_sessions DROP COLUMN IF EXISTS is_free_trial`
|
||||||
|
|
||||||
// 2. payment_sessions.mode — chat (default) vs voice call. Voice call is just chat
|
// 2. payment_requests.mode — chat (default) vs voice call. Voice call is just chat
|
||||||
// with a different price group + a header badge; no extra media handling.
|
// with a different price group + a header badge; no extra media handling.
|
||||||
await sql`
|
await sql`
|
||||||
ALTER TABLE payment_sessions
|
ALTER TABLE payment_requests
|
||||||
ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
|
ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
|
||||||
CHECK (mode IN ('chat', 'call'))
|
CHECK (mode IN ('chat', 'call'))
|
||||||
`
|
`
|
||||||
@@ -927,6 +947,145 @@ const migrate = async () => {
|
|||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// --- Phase 5 — Payment service rename + Xendit prep ---
|
||||||
|
//
|
||||||
|
// The `payment_sessions` table is renamed to `payment_requests` so future
|
||||||
|
// products (courses, merch, subscriptions) can reuse the same payment layer
|
||||||
|
// without the chat-coupled name. See requirement/phase5-xendit-plan.md
|
||||||
|
// Architecture (revised 2026-05-23) for the full rationale.
|
||||||
|
//
|
||||||
|
// The migration is idempotent in both directions: on fresh DBs the
|
||||||
|
// earlier CREATE TABLE block builds `payment_sessions`, this block then
|
||||||
|
// renames it. On already-migrated DBs the rename is a no-op.
|
||||||
|
|
||||||
|
// 1. Rename the table if it still has the old name.
|
||||||
|
// Schema-anchored via current_schema() so the rename works correctly when
|
||||||
|
// migrate runs against a non-public schema (test DBs use search_path).
|
||||||
|
// Without the schema qualifier, to_regclass('payment_requests') would
|
||||||
|
// fall through to public.payment_requests (after the dev schema migrated)
|
||||||
|
// and the test schema's rename would be skipped.
|
||||||
|
await sql`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass(current_schema() || '.payment_sessions') IS NOT NULL
|
||||||
|
AND to_regclass(current_schema() || '.payment_requests') IS NULL THEN
|
||||||
|
EXECUTE 'ALTER TABLE ' || quote_ident(current_schema()) || '.payment_sessions RENAME TO payment_requests';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`
|
||||||
|
|
||||||
|
// 2. Rename indexes Postgres auto-named after the old table
|
||||||
|
await sql`ALTER INDEX IF EXISTS idx_payment_sessions_customer RENAME TO idx_payment_requests_customer`
|
||||||
|
await sql`ALTER INDEX IF EXISTS idx_payment_sessions_status_expires RENAME TO idx_payment_requests_status_expires`
|
||||||
|
// Primary-key index doesn't get auto-renamed by ALTER TABLE ... RENAME.
|
||||||
|
// Fix it on existing dev DBs so a future reader doesn't see payment_sessions_pkey on the payment_requests table.
|
||||||
|
await sql`ALTER INDEX IF EXISTS payment_sessions_pkey RENAME TO payment_requests_pkey`
|
||||||
|
|
||||||
|
// 3. Rename FK columns on dependent tables (schema-anchored for the same reason as above)
|
||||||
|
await sql`
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
schema_name TEXT := current_schema();
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_attribute
|
||||||
|
WHERE attrelid = to_regclass(schema_name || '.chat_sessions')
|
||||||
|
AND attname = 'payment_session_id'
|
||||||
|
AND NOT attisdropped) THEN
|
||||||
|
EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.chat_sessions RENAME COLUMN payment_session_id TO payment_request_id';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_attribute
|
||||||
|
WHERE attrelid = to_regclass(schema_name || '.session_extensions')
|
||||||
|
AND attname = 'payment_session_id'
|
||||||
|
AND NOT attisdropped) THEN
|
||||||
|
EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.session_extensions RENAME COLUMN payment_session_id TO payment_request_id';
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM pg_attribute
|
||||||
|
WHERE attrelid = to_regclass(schema_name || '.pairing_failures')
|
||||||
|
AND attname = 'payment_session_id'
|
||||||
|
AND NOT attisdropped) THEN
|
||||||
|
EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.pairing_failures RENAME COLUMN payment_session_id TO payment_request_id';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$
|
||||||
|
`
|
||||||
|
await sql`ALTER INDEX IF EXISTS idx_chat_sessions_payment RENAME TO idx_chat_sessions_payment_request`
|
||||||
|
|
||||||
|
// 4. Rename the app_config key. Skip if both rows exist (we'd violate the PK).
|
||||||
|
await sql`
|
||||||
|
UPDATE app_config
|
||||||
|
SET key = 'payment_request_timeout_minutes'
|
||||||
|
WHERE key = 'payment_session_timeout_minutes'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM app_config WHERE key = 'payment_request_timeout_minutes')
|
||||||
|
`
|
||||||
|
|
||||||
|
// 5. Status enum rewrite: rename failed_pairing → failed_delivery (product-agnostic),
|
||||||
|
// add 'failed' (createInvoice errored before customer paid — distinct from expired).
|
||||||
|
// Drop both the legacy and current constraint names so re-runs are idempotent.
|
||||||
|
await sql`ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_sessions_status_check`
|
||||||
|
await sql`ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_requests_status_check`
|
||||||
|
await sql`UPDATE payment_requests SET status = 'failed_delivery' WHERE status = 'failed_pairing'`
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE payment_requests
|
||||||
|
ADD CONSTRAINT payment_requests_status_check
|
||||||
|
CHECK (status IN ('pending','confirmed','consumed','expired','abandoned','failed','failed_delivery'))
|
||||||
|
`
|
||||||
|
|
||||||
|
// 6. cause_tag rewrite on pairing_failures — payment_session_expired → payment_request_expired
|
||||||
|
await sql`ALTER TABLE pairing_failures DROP CONSTRAINT IF EXISTS pairing_failures_cause_tag_check`
|
||||||
|
await sql`UPDATE pairing_failures SET cause_tag = 'payment_request_expired' WHERE cause_tag = 'payment_session_expired'`
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE pairing_failures
|
||||||
|
ADD CONSTRAINT pairing_failures_cause_tag_check
|
||||||
|
CHECK (cause_tag IN (
|
||||||
|
'no_mitra_available',
|
||||||
|
'all_mitras_rejected',
|
||||||
|
'targeted_mitra_offline',
|
||||||
|
'targeted_mitra_rejected',
|
||||||
|
'targeted_mitra_timeout',
|
||||||
|
'payment_request_expired',
|
||||||
|
'customer_cancelled',
|
||||||
|
'extension_rejected',
|
||||||
|
'extension_safeguard_tripped'
|
||||||
|
))
|
||||||
|
`
|
||||||
|
|
||||||
|
// 7. Add product-agnostic columns (microservice-prep) + xendit_* columns.
|
||||||
|
// product_type defaults to 'chat_session' so every existing row is
|
||||||
|
// self-describing without a manual backfill.
|
||||||
|
await sql`
|
||||||
|
ALTER TABLE payment_requests
|
||||||
|
ADD COLUMN IF NOT EXISTS product_type TEXT NOT NULL DEFAULT 'chat_session',
|
||||||
|
ADD COLUMN IF NOT EXISTS product_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS xendit_invoice_id TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS xendit_invoice_url TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS xendit_payment_method TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS xendit_paid_amount INTEGER
|
||||||
|
`
|
||||||
|
|
||||||
|
// 8. Partial unique index on xendit_invoice_id — webhook retries land on the same
|
||||||
|
// invoice id, this turns "already processed" into a constraint violation our
|
||||||
|
// handler can detect.
|
||||||
|
await sql`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_requests_xendit_invoice
|
||||||
|
ON payment_requests (xendit_invoice_id)
|
||||||
|
WHERE xendit_invoice_id IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
// 9. Backfill product_metadata for pre-Phase-5 rows so subscribers can read
|
||||||
|
// chat-session details without falling back to the legacy top-level columns.
|
||||||
|
await sql`
|
||||||
|
UPDATE payment_requests
|
||||||
|
SET product_metadata = jsonb_build_object(
|
||||||
|
'duration_minutes', duration_minutes,
|
||||||
|
'mode', mode,
|
||||||
|
'is_extension', is_extension,
|
||||||
|
'targeted_mitra_id', targeted_mitra_id
|
||||||
|
)
|
||||||
|
WHERE product_metadata = '{}'::jsonb
|
||||||
|
AND product_type = 'chat_session'
|
||||||
|
`
|
||||||
|
|
||||||
console.log('Migration complete.')
|
console.log('Migration complete.')
|
||||||
await sql.end()
|
await sql.end()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const internalTestRoutes = async (fastify) => {
|
|||||||
await sql`DELETE FROM chat_request_notifications WHERE session_id IN (SELECT id FROM chat_sessions WHERE customer_id = ${id})`
|
await sql`DELETE FROM chat_request_notifications WHERE session_id IN (SELECT id FROM chat_sessions WHERE customer_id = ${id})`
|
||||||
await sql`DELETE FROM customer_transactions WHERE customer_id = ${id}`
|
await sql`DELETE FROM customer_transactions WHERE customer_id = ${id}`
|
||||||
await sql`DELETE FROM chat_sessions WHERE customer_id = ${id}`
|
await sql`DELETE FROM chat_sessions WHERE customer_id = ${id}`
|
||||||
await sql`DELETE FROM payment_sessions WHERE customer_id = ${id}`
|
await sql`DELETE FROM payment_requests WHERE customer_id = ${id}`
|
||||||
await sql`DELETE FROM auth_sessions WHERE user_id = ${id} AND user_type = 'customer'`
|
await sql`DELETE FROM auth_sessions WHERE user_id = ${id} AND user_type = 'customer'`
|
||||||
}
|
}
|
||||||
await sql`DELETE FROM customers WHERE phone = ${phone}`
|
await sql`DELETE FROM customers WHERE phone = ${phone}`
|
||||||
@@ -65,7 +65,7 @@ export const internalTestRoutes = async (fastify) => {
|
|||||||
let target
|
let target
|
||||||
if (latest === true) {
|
if (latest === true) {
|
||||||
const [row] = await sql`
|
const [row] = await sql`
|
||||||
SELECT id FROM payment_sessions
|
SELECT id FROM payment_requests
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -78,7 +78,7 @@ export const internalTestRoutes = async (fastify) => {
|
|||||||
return reply.code(400).send({ error: 'latest:true required in body' })
|
return reply.code(400).send({ error: 'latest:true required in body' })
|
||||||
}
|
}
|
||||||
const [updated] = await sql`
|
const [updated] = await sql`
|
||||||
UPDATE payment_sessions
|
UPDATE payment_requests
|
||||||
SET status = 'confirmed', confirmed_at = NOW()
|
SET status = 'confirmed', confirmed_at = NOW()
|
||||||
WHERE id = ${target} AND status = 'pending'
|
WHERE id = ${target} AND status = 'pending'
|
||||||
RETURNING id, customer_id, status, mode, duration_minutes, is_first_session_discount, targeted_mitra_id
|
RETURNING id, customer_id, status, mode, duration_minutes, is_first_session_discount, targeted_mitra_id
|
||||||
@@ -105,7 +105,7 @@ export const internalTestRoutes = async (fastify) => {
|
|||||||
let target
|
let target
|
||||||
if (latest === true) {
|
if (latest === true) {
|
||||||
const [row] = await sql`
|
const [row] = await sql`
|
||||||
SELECT id FROM payment_sessions
|
SELECT id FROM payment_requests
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -120,7 +120,7 @@ export const internalTestRoutes = async (fastify) => {
|
|||||||
return reply.code(400).send({ error: 'payment_id or latest:true required in body' })
|
return reply.code(400).send({ error: 'payment_id or latest:true required in body' })
|
||||||
}
|
}
|
||||||
const [updated] = await sql`
|
const [updated] = await sql`
|
||||||
UPDATE payment_sessions
|
UPDATE payment_requests
|
||||||
SET status = 'expired', expires_at = NOW() - INTERVAL '1 minute'
|
SET status = 'expired', expires_at = NOW() - INTERVAL '1 minute'
|
||||||
WHERE id = ${target} AND status = 'pending'
|
WHERE id = ${target} AND status = 'pending'
|
||||||
RETURNING id, status
|
RETURNING id, status
|
||||||
@@ -168,7 +168,7 @@ export const internalTestRoutes = async (fastify) => {
|
|||||||
const [linked] = await sql`
|
const [linked] = await sql`
|
||||||
SELECT ps.targeted_mitra_id
|
SELECT ps.targeted_mitra_id
|
||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
|
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
|
||||||
WHERE cs.id = ${target}
|
WHERE cs.id = ${target}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`
|
`
|
||||||
@@ -268,7 +268,7 @@ export const internalTestRoutes = async (fastify) => {
|
|||||||
return { ok: true, session_id: session.id, mitra_id: mitra.id, mitra_name: mitra.display_name }
|
return { ok: true, session_id: session.id, mitra_id: mitra.id, mitra_name: mitra.display_name }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Seed a payment_sessions row in `pending` status for the customer linked
|
// Seed a payment_requests row in `pending` status for the customer linked
|
||||||
// to `phone`, with expires_at safely in the future. Used by Maestro Stage
|
// to `phone`, with expires_at safely in the future. Used by Maestro Stage
|
||||||
// 10 flow (09_chat_tab.yaml) to populate the Pembayaran sub-tab without
|
// 10 flow (09_chat_tab.yaml) to populate the Pembayaran sub-tab without
|
||||||
// walking the multi-screen S6 paywall → method → duration → method flow.
|
// walking the multi-screen S6 paywall → method → duration → method flow.
|
||||||
@@ -297,7 +297,7 @@ export const internalTestRoutes = async (fastify) => {
|
|||||||
return reply.code(404).send({ error: 'no_customer_for_phone', phone })
|
return reply.code(404).send({ error: 'no_customer_for_phone', phone })
|
||||||
}
|
}
|
||||||
const [row] = await sql`
|
const [row] = await sql`
|
||||||
INSERT INTO payment_sessions (
|
INSERT INTO payment_requests (
|
||||||
customer_id, amount, duration_minutes, is_first_session_discount,
|
customer_id, amount, duration_minutes, is_first_session_discount,
|
||||||
is_extension, status, mode, expires_at
|
is_extension, status, mode, expires_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
getEarlyEndConfig, setEarlyEndConfig,
|
getEarlyEndConfig, setEarlyEndConfig,
|
||||||
getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds,
|
getMitraPingConfig, setMitraPingConfig, getMitraHeartbeatCadenceSeconds,
|
||||||
getSensitivityConfig, setSensitivityConfig,
|
getSensitivityConfig, setSensitivityConfig,
|
||||||
getPaymentSessionTimeoutMinutes, setPaymentSessionTimeoutMinutes,
|
getPaymentRequestTimeoutMinutes, setPaymentRequestTimeoutMinutes,
|
||||||
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
|
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
|
||||||
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
||||||
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
||||||
@@ -247,21 +247,21 @@ export const internalConfigRoutes = async (app) => {
|
|||||||
app.get('/payment-session-timeout', {
|
app.get('/payment-session-timeout', {
|
||||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||||
}, async (_req, reply) => {
|
}, async (_req, reply) => {
|
||||||
return reply.send({ success: true, data: await getPaymentSessionTimeoutMinutes() })
|
return reply.send({ success: true, data: await getPaymentRequestTimeoutMinutes() })
|
||||||
})
|
})
|
||||||
|
|
||||||
app.patch('/payment-session-timeout', {
|
app.patch('/payment-session-timeout', {
|
||||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { payment_session_timeout_minutes } = request.body ?? {}
|
const { payment_request_timeout_minutes } = request.body ?? {}
|
||||||
if (typeof payment_session_timeout_minutes !== 'number' || payment_session_timeout_minutes < 1) {
|
if (typeof payment_request_timeout_minutes !== 'number' || payment_request_timeout_minutes < 1) {
|
||||||
return reply.code(422).send({
|
return reply.code(422).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: 'VALIDATION_ERROR', message: 'payment_session_timeout_minutes must be a number >= 1' },
|
error: { code: 'VALIDATION_ERROR', message: 'payment_request_timeout_minutes must be a number >= 1' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const config = await setPaymentSessionTimeoutMinutes(payment_session_timeout_minutes)
|
const config = await setPaymentRequestTimeoutMinutes(payment_request_timeout_minutes)
|
||||||
await publishConfigInvalidate('payment_session_timeout_minutes')
|
await publishConfigInvalidate('payment_request_timeout_minutes')
|
||||||
return reply.send({ success: true, data: config })
|
return reply.send({ success: true, data: config })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -47,16 +47,16 @@ export const clientChatRoutes = async (app) => {
|
|||||||
/**
|
/**
|
||||||
* Start a general-blast pairing search.
|
* Start a general-blast pairing search.
|
||||||
*
|
*
|
||||||
* Body MUST include `payment_session_id` (a confirmed payment_session owned by the caller).
|
* Body MUST include `payment_request_id` (a confirmed payment_request owned by the caller).
|
||||||
* Pricing/duration/free-trial values are sourced from the payment session, NOT from the client.
|
* Pricing/duration/free-trial values are sourced from the payment session, NOT from the client.
|
||||||
*/
|
*/
|
||||||
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/request', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const { payment_session_id, topic_sensitivity } = request.body ?? {}
|
const { payment_request_id, topic_sensitivity } = request.body ?? {}
|
||||||
|
|
||||||
if (!payment_session_id) {
|
if (!payment_request_id) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
|
error: { code: 'BAD_REQUEST', message: 'payment_request_id is required' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export const clientChatRoutes = async (app) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const session = await createPairingRequest(request.customer.id, {
|
const session = await createPairingRequest(request.customer.id, {
|
||||||
paymentSessionId: payment_session_id,
|
paymentRequestId: payment_request_id,
|
||||||
topic_sensitivity,
|
topic_sensitivity,
|
||||||
})
|
})
|
||||||
return reply.code(201).send({ success: true, data: session })
|
return reply.code(201).send({ success: true, data: session })
|
||||||
@@ -77,18 +77,18 @@ export const clientChatRoutes = async (app) => {
|
|||||||
/**
|
/**
|
||||||
* Start a targeted "Curhat lagi" pairing request.
|
* Start a targeted "Curhat lagi" pairing request.
|
||||||
*
|
*
|
||||||
* Body: { payment_session_id, mitra_id, topic_sensitivity? }
|
* Body: { payment_request_id, mitra_id, topic_sensitivity? }
|
||||||
* Returns 409 with reason: 'targeted_mitra_offline' if the targeted mitra is unreachable
|
* Returns 409 with reason: 'targeted_mitra_offline' if the targeted mitra is unreachable
|
||||||
* or at capacity. The payment session stays `confirmed` in that case so the customer
|
* or at capacity. The payment session stays `confirmed` in that case so the customer
|
||||||
* can fall back to general blast on the same payment.
|
* can fall back to general blast on the same payment.
|
||||||
*/
|
*/
|
||||||
app.post('/chat-requests/returning', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/chat-requests/returning', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const { payment_session_id, mitra_id, topic_sensitivity } = request.body ?? {}
|
const { payment_request_id, mitra_id, topic_sensitivity } = request.body ?? {}
|
||||||
|
|
||||||
if (!payment_session_id) {
|
if (!payment_request_id) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
|
error: { code: 'BAD_REQUEST', message: 'payment_request_id is required' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!mitra_id) {
|
if (!mitra_id) {
|
||||||
@@ -103,7 +103,7 @@ export const clientChatRoutes = async (app) => {
|
|||||||
: TopicSensitivity.REGULAR
|
: TopicSensitivity.REGULAR
|
||||||
|
|
||||||
const session = await createTargetedPairingRequest(request.customer.id, {
|
const session = await createTargetedPairingRequest(request.customer.id, {
|
||||||
paymentSessionId: payment_session_id,
|
paymentRequestId: payment_request_id,
|
||||||
targetedMitraId: mitra_id,
|
targetedMitraId: mitra_id,
|
||||||
topic_sensitivity: resolvedTopic,
|
topic_sensitivity: resolvedTopic,
|
||||||
})
|
})
|
||||||
@@ -113,26 +113,26 @@ export const clientChatRoutes = async (app) => {
|
|||||||
/**
|
/**
|
||||||
* Customer-initiated cancel during searching/waiting.
|
* Customer-initiated cancel during searching/waiting.
|
||||||
*
|
*
|
||||||
* Body: { payment_session_id }
|
* Body: { payment_request_id }
|
||||||
* Terminal — payment session moves to failed_pairing with cause = customer_cancelled.
|
* Terminal — payment session moves to failed_delivery with cause = customer_cancelled.
|
||||||
*/
|
*/
|
||||||
app.post('/chat-requests/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/chat-requests/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const { payment_session_id } = request.body ?? {}
|
const { payment_request_id } = request.body ?? {}
|
||||||
if (!payment_session_id) {
|
if (!payment_request_id) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: 'BAD_REQUEST', message: 'payment_session_id is required' },
|
error: { code: 'BAD_REQUEST', message: 'payment_request_id is required' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const result = await cancelPaymentSearch(payment_session_id, request.customer.id)
|
const result = await cancelPaymentSearch(payment_request_id, request.customer.id)
|
||||||
return reply.send({ success: true, data: result })
|
return reply.send({ success: true, data: result })
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After a returning-chat fail, customer taps "Chat dengan bestie lain".
|
* After a returning-chat fail, customer taps "Chat dengan bestie lain".
|
||||||
* Reuses the same payment_session_id (no double-charge), runs general blast.
|
* Reuses the same payment_request_id (no double-charge), runs general blast.
|
||||||
*/
|
*/
|
||||||
app.post('/chat-requests/:paymentSessionId/fallback-to-blast', {
|
app.post('/chat-requests/:paymentRequestId/fallback-to-blast', {
|
||||||
preHandler: [authenticate, resolveCustomer],
|
preHandler: [authenticate, resolveCustomer],
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { topic_sensitivity } = request.body ?? {}
|
const { topic_sensitivity } = request.body ?? {}
|
||||||
@@ -140,7 +140,7 @@ export const clientChatRoutes = async (app) => {
|
|||||||
? TopicSensitivity.SENSITIVE
|
? TopicSensitivity.SENSITIVE
|
||||||
: TopicSensitivity.REGULAR
|
: TopicSensitivity.REGULAR
|
||||||
const session = await fallbackToGeneralBlast(
|
const session = await fallbackToGeneralBlast(
|
||||||
request.params.paymentSessionId,
|
request.params.paymentRequestId,
|
||||||
request.customer.id,
|
request.customer.id,
|
||||||
{ topic_sensitivity: resolvedTopic },
|
{ topic_sensitivity: resolvedTopic },
|
||||||
)
|
)
|
||||||
@@ -150,7 +150,7 @@ export const clientChatRoutes = async (app) => {
|
|||||||
/**
|
/**
|
||||||
* Cancel-by-session-id retained for in-flight chat_session cancels (e.g. cancel
|
* Cancel-by-session-id retained for in-flight chat_session cancels (e.g. cancel
|
||||||
* during the 20s targeted wait after a chat_session has been created). Customer cancel
|
* during the 20s targeted wait after a chat_session has been created). Customer cancel
|
||||||
* via payment_session_id should prefer POST /chat-requests/cancel above.
|
* via payment_request_id should prefer POST /chat-requests/cancel above.
|
||||||
*/
|
*/
|
||||||
app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/request/:sessionId/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const session = await cancelPairingRequest(request.params.sessionId, request.customer.id)
|
const session = await cancelPairingRequest(request.params.sessionId, request.customer.id)
|
||||||
@@ -173,17 +173,17 @@ export const clientChatRoutes = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension request REQUIRES `extension_payment_session_id`.
|
* Extension request REQUIRES `extension_payment_request_id`.
|
||||||
* The payment session must be is_extension=true and is_first_session_discount=false.
|
* The payment session must be is_extension=true and is_first_session_discount=false.
|
||||||
* Pricing/duration come from the payment session via the extension service.
|
* Pricing/duration come from the payment session via the extension service.
|
||||||
*/
|
*/
|
||||||
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/session/:sessionId/extend', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const { duration_minutes, price, extension_payment_session_id } = request.body ?? {}
|
const { duration_minutes, price, extension_payment_request_id } = request.body ?? {}
|
||||||
|
|
||||||
if (!extension_payment_session_id) {
|
if (!extension_payment_request_id) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: 'BAD_REQUEST', message: 'extension_payment_session_id is required' },
|
error: { code: 'BAD_REQUEST', message: 'extension_payment_request_id is required' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!duration_minutes || price === undefined) {
|
if (!duration_minutes || price === undefined) {
|
||||||
@@ -196,7 +196,7 @@ export const clientChatRoutes = async (app) => {
|
|||||||
const extension = await requestExtension(request.params.sessionId, request.customer.id, {
|
const extension = await requestExtension(request.params.sessionId, request.customer.id, {
|
||||||
duration_minutes,
|
duration_minutes,
|
||||||
price,
|
price,
|
||||||
extension_payment_session_id,
|
extension_payment_request_id,
|
||||||
})
|
})
|
||||||
return reply.send({ success: true, data: extension })
|
return reply.send({ success: true, data: extension })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { authenticate } from '../../plugins/auth.js'
|
import { authenticate } from '../../plugins/auth.js'
|
||||||
import { getCustomerById } from '../../services/customer.service.js'
|
import { getCustomerById } from '../../services/customer.service.js'
|
||||||
import {
|
import {
|
||||||
createPaymentSession,
|
requestPayment,
|
||||||
confirmPaymentSession,
|
confirmPaymentForCustomer,
|
||||||
abandonPaymentSession,
|
cancelPayment,
|
||||||
getPaymentSession,
|
getPayment,
|
||||||
getCustomerPendingPayments,
|
getCustomerPendingPayments,
|
||||||
} from '../../services/payment.service.js'
|
} from '../../services/payment.service.js'
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
findTier,
|
findTier,
|
||||||
readFirstSessionDiscountConfig,
|
readFirstSessionDiscountConfig,
|
||||||
} from '../../services/pricing.service.js'
|
} from '../../services/pricing.service.js'
|
||||||
|
import { getXenditConfig } from '../../services/config.service.js'
|
||||||
import { UserType, SessionMode } from '../../constants.js'
|
import { UserType, SessionMode } from '../../constants.js'
|
||||||
|
|
||||||
const resolveCustomer = async (request, reply) => {
|
const resolveCustomer = async (request, reply) => {
|
||||||
@@ -35,10 +36,10 @@ const resolveCustomer = async (request, reply) => {
|
|||||||
/**
|
/**
|
||||||
* Payment session lifecycle (mocked — no Xendit yet).
|
* Payment session lifecycle (mocked — no Xendit yet).
|
||||||
*
|
*
|
||||||
* POST /api/client/payment-sessions
|
* POST /api/client/payment-requests
|
||||||
* POST /api/client/payment-sessions/:id/confirm
|
* POST /api/client/payment-requests/:id/confirm
|
||||||
* POST /api/client/payment-sessions/:id/cancel
|
* POST /api/client/payment-requests/:id/cancel
|
||||||
* GET /api/client/payment-sessions/:id
|
* GET /api/client/payment-requests/:id
|
||||||
*/
|
*/
|
||||||
export const clientPaymentRoutes = async (app) => {
|
export const clientPaymentRoutes = async (app) => {
|
||||||
// Create a payment session (status = pending). First-session-discount is server-authoritative:
|
// Create a payment session (status = pending). First-session-discount is server-authoritative:
|
||||||
@@ -103,7 +104,11 @@ export const clientPaymentRoutes = async (app) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await createPaymentSession({
|
// Phase 5: payment.service.js handles the Xendit invoice creation internally
|
||||||
|
// when XENDIT_ENABLED=true. The row comes back with xendit_invoice_url populated;
|
||||||
|
// when off, invoice_url is null and the dev/Maestro stub plays the webhook role.
|
||||||
|
const session = await requestPayment({
|
||||||
|
productType: 'chat_session',
|
||||||
customerId: request.customer.id,
|
customerId: request.customer.id,
|
||||||
durationMinutes: duration_minutes,
|
durationMinutes: duration_minutes,
|
||||||
amount,
|
amount,
|
||||||
@@ -125,12 +130,21 @@ export const clientPaymentRoutes = async (app) => {
|
|||||||
targeted_mitra_id: session.targeted_mitra_id,
|
targeted_mitra_id: session.targeted_mitra_id,
|
||||||
expires_at: session.expires_at,
|
expires_at: session.expires_at,
|
||||||
status: session.status,
|
status: session.status,
|
||||||
|
invoice_url: session.xendit_invoice_url ?? null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post('/:id/confirm', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/:id/confirm', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const session = await confirmPaymentSession(request.params.id, request.customer.id)
|
// Phase 5 D9: when Xendit is live, only the webhook can confirm. The dev/Maestro
|
||||||
|
// stub at /internal/_test/force-confirm-payment bypasses this gate (internal listener).
|
||||||
|
if (getXenditConfig().enabled) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
success: false,
|
||||||
|
error: { code: 'FORBIDDEN', message: 'Confirmation must come from Xendit webhook' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const session = await confirmPaymentForCustomer(request.params.id, request.customer.id)
|
||||||
return reply.send({
|
return reply.send({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -142,7 +156,7 @@ export const clientPaymentRoutes = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.post('/:id/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.post('/:id/cancel', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const session = await abandonPaymentSession(request.params.id, request.customer.id)
|
const session = await cancelPayment(request.params.id, request.customer.id)
|
||||||
return reply.send({
|
return reply.send({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -160,19 +174,34 @@ export const clientPaymentRoutes = async (app) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.get('/:id', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
app.get('/:id', { preHandler: [authenticate, resolveCustomer] }, async (request, reply) => {
|
||||||
const session = await getPaymentSession(request.params.id)
|
const session = await getPayment(request.params.id)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return reply.code(404).send({
|
return reply.code(404).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: 'NOT_FOUND', message: 'Payment session not found' },
|
error: { code: 'NOT_FOUND', message: 'Payment request not found' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (session.customer_id !== request.customer.id) {
|
if (session.customer_id !== request.customer.id) {
|
||||||
return reply.code(403).send({
|
return reply.code(403).send({
|
||||||
success: false,
|
success: false,
|
||||||
error: { code: 'FORBIDDEN', message: 'Payment session does not belong to this customer' },
|
error: { code: 'FORBIDDEN', message: 'Payment request does not belong to this customer' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return reply.send({ success: true, data: session })
|
// Phase 5: surface chat_session_id (and status) when the server-driven pairing
|
||||||
|
// subscriber has already started pairing for this confirmed payment. Lets the
|
||||||
|
// app skip its legacy POST /chat/request call and just move to the searching state.
|
||||||
|
const { getDb } = await import('../../db/client.js')
|
||||||
|
const sqlClient = getDb()
|
||||||
|
const [chat] = await sqlClient`
|
||||||
|
SELECT id, status FROM chat_sessions WHERE payment_request_id = ${session.id} LIMIT 1
|
||||||
|
`
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
...session,
|
||||||
|
chat_session_id: chat?.id ?? null,
|
||||||
|
chat_session_status: chat?.status ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
92
backend/src/routes/public/shared.payment-webhooks.routes.js
Normal file
92
backend/src/routes/public/shared.payment-webhooks.routes.js
Normal 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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,13 +4,24 @@ import { buildInternalApp } from './app.internal.js'
|
|||||||
import { autoOfflineStaleMitras } from './services/mitra-status.service.js'
|
import { autoOfflineStaleMitras } from './services/mitra-status.service.js'
|
||||||
import { initFirebase } from './plugins/firebase.js'
|
import { initFirebase } from './plugins/firebase.js'
|
||||||
import { restoreActiveTimers } from './services/session-timer.service.js'
|
import { restoreActiveTimers } from './services/session-timer.service.js'
|
||||||
import { expireStalePaymentSessions } from './services/payment.service.js'
|
import { expireStalePaymentRequests, registerPairingSubscriber } from './services/payment.service.js'
|
||||||
|
import { getXenditConfig } from './services/config.service.js'
|
||||||
|
|
||||||
const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000
|
const PUBLIC_PORT = process.env.PUBLIC_PORT || 3000
|
||||||
const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001
|
const INTERNAL_PORT = process.env.INTERNAL_PORT || 3001
|
||||||
const INTERNAL_HOST = process.env.INTERNAL_HOST || '127.0.0.1'
|
const INTERNAL_HOST = process.env.INTERNAL_HOST || '127.0.0.1'
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
|
// Phase 5: fail fast if XENDIT_ENABLED=true without the required credentials.
|
||||||
|
// Bad config explodes at startup rather than at the first /payment-requests POST.
|
||||||
|
const xc = getXenditConfig()
|
||||||
|
if (xc.enabled) {
|
||||||
|
if (!xc.secretKey) throw new Error('XENDIT_ENABLED=true requires XENDIT_SECRET_KEY')
|
||||||
|
if (!xc.webhookToken || xc.webhookToken.length < 16) {
|
||||||
|
throw new Error('XENDIT_ENABLED=true requires XENDIT_WEBHOOK_TOKEN (>= 16 chars)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
initFirebase()
|
initFirebase()
|
||||||
const publicApp = await buildPublicApp()
|
const publicApp = await buildPublicApp()
|
||||||
const internalApp = await buildInternalApp()
|
const internalApp = await buildInternalApp()
|
||||||
@@ -24,6 +35,24 @@ const start = async () => {
|
|||||||
// Restore session timers for active sessions (on server restart)
|
// Restore session timers for active sessions (on server restart)
|
||||||
await restoreActiveTimers()
|
await restoreActiveTimers()
|
||||||
|
|
||||||
|
// Phase 5: wire pairing service as a subscriber to payment_request.confirmed events.
|
||||||
|
// Must happen AFTER all services are loaded so the subscriber registration sees
|
||||||
|
// the EventEmitter set up by payment.service.js at module-load time.
|
||||||
|
registerPairingSubscriber()
|
||||||
|
|
||||||
|
// Phase 5: catch any payment_request.confirmed events that were lost across a restart
|
||||||
|
// by running the reconciliation sweeper immediately on boot. Without this, a customer
|
||||||
|
// whose payment confirmed during shutdown could be stranded for up to 60s waiting on
|
||||||
|
// the next sweeper tick.
|
||||||
|
try {
|
||||||
|
const result = await expireStalePaymentRequests()
|
||||||
|
if (result.expired > 0 || result.failed > 0 || result.reconciled > 0) {
|
||||||
|
console.log(`Startup reconciliation: ${result.expired} expired, ${result.failed} failed_delivery, ${result.reconciled} re-triggered`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Startup reconciliation failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-offline mitras with stale heartbeat (every 30s)
|
// Auto-offline mitras with stale heartbeat (every 30s)
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -34,22 +63,36 @@ const start = async () => {
|
|||||||
}
|
}
|
||||||
}, 30_000)
|
}, 30_000)
|
||||||
|
|
||||||
// Expire stale payment_sessions (every 60s).
|
// Expire stale payment_requests + reconcile lost subscriber work (every 60s).
|
||||||
// Pending past expires_at → expired (no failure row). Confirmed-but-stale → failed_pairing
|
// Pending past expires_at → expired (no failure row).
|
||||||
// with cause = payment_session_expired (writes a pairing_failures row).
|
// Confirmed-but-stale → failed_delivery (writes a pairing_failures row).
|
||||||
// Single-instance for now; Valkey keyspace notifications when we go multi-instance.
|
// Confirmed-with-no-chat-session-yet → re-trigger the pairing subscriber (recovery from
|
||||||
|
// lost EventEmitter notifications across restart). See payment.service.js for details.
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await expireStalePaymentSessions()
|
const result = await expireStalePaymentRequests()
|
||||||
if (result.expired > 0 || result.failed > 0) {
|
if (result.expired > 0 || result.failed > 0 || result.reconciled > 0) {
|
||||||
console.log(`Payment sweeper: ${result.expired} expired, ${result.failed} failed_pairing`)
|
console.log(`Payment sweeper: ${result.expired} expired, ${result.failed} failed_delivery, ${result.reconciled} re-triggered`)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Payment session sweeper failed:', err)
|
console.error('Payment request sweeper failed:', err)
|
||||||
}
|
}
|
||||||
}, 60_000)
|
}, 60_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SIGTERM trap — Cloud Run gives ~10s grace before SIGKILL. Use it to drain in-flight
|
||||||
|
// EventEmitter handlers (Stage 5 of phase5-xendit-plan.md). app.close() stops accepting
|
||||||
|
// new requests; the timeout gives subscribers their last chance to finish.
|
||||||
|
const shutdown = async () => {
|
||||||
|
console.log('SIGTERM received — closing servers, draining handlers')
|
||||||
|
// App handles are scoped inside start(); fire-and-forget here is fine because both
|
||||||
|
// listeners' .close() is idempotent and process.exit truncates anything still pending.
|
||||||
|
await new Promise(r => setTimeout(r, 8_000))
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
process.on('SIGTERM', shutdown)
|
||||||
|
process.on('SIGINT', shutdown)
|
||||||
|
|
||||||
start().catch((err) => {
|
start().catch((err) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
@@ -149,6 +149,22 @@ export const getMitraHeartbeatCadenceSeconds = () => {
|
|||||||
return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30
|
return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Phase 5: Xendit integration ---
|
||||||
|
//
|
||||||
|
// Env-driven (per backend/CLAUDE.md Config-Source Convention). All five values
|
||||||
|
// read from process.env at call time so test setups can inject via vi.stubEnv.
|
||||||
|
// When `enabled` is true, payment.service.js mints a real Xendit invoice on
|
||||||
|
// requestPayment(); when false, invoice creation is skipped and the dev/Maestro
|
||||||
|
// stub /internal/_test/force-confirm-payment plays the role of the webhook.
|
||||||
|
// See requirement/phase5-xendit-plan.md D6/D9.
|
||||||
|
export const getXenditConfig = () => ({
|
||||||
|
enabled: process.env.XENDIT_ENABLED === 'true',
|
||||||
|
secretKey: process.env.XENDIT_SECRET_KEY ?? '',
|
||||||
|
webhookToken: process.env.XENDIT_WEBHOOK_TOKEN ?? '',
|
||||||
|
successRedirectUrl: process.env.XENDIT_SUCCESS_REDIRECT_URL ?? '',
|
||||||
|
failureRedirectUrl: process.env.XENDIT_FAILURE_REDIRECT_URL ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
export const getMitraPingConfig = async () => {
|
export const getMitraPingConfig = async () => {
|
||||||
const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'`
|
const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'`
|
||||||
const [staleRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_stale_after_seconds'`
|
const [staleRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_stale_after_seconds'`
|
||||||
@@ -291,18 +307,18 @@ export const setCcLoginLockoutConfig = async ({ max_attempts, lockout_minutes })
|
|||||||
|
|
||||||
// --- Paid Pairing Flow + Returning-Chat + Extension Flip ---
|
// --- Paid Pairing Flow + Returning-Chat + Extension Flip ---
|
||||||
|
|
||||||
export const getPaymentSessionTimeoutMinutes = async () => {
|
export const getPaymentRequestTimeoutMinutes = async () => {
|
||||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'payment_session_timeout_minutes'`
|
const [row] = await sql`SELECT value FROM app_config WHERE key = 'payment_request_timeout_minutes'`
|
||||||
return { payment_session_timeout_minutes: row?.value?.value ?? 20 }
|
return { payment_request_timeout_minutes: row?.value?.value ?? 20 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setPaymentSessionTimeoutMinutes = async (value) => {
|
export const setPaymentRequestTimeoutMinutes = async (value) => {
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO app_config (key, value, updated_at)
|
INSERT INTO app_config (key, value, updated_at)
|
||||||
VALUES ('payment_session_timeout_minutes', ${sql.json({ value })}, NOW())
|
VALUES ('payment_request_timeout_minutes', ${sql.json({ value })}, NOW())
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||||
`
|
`
|
||||||
return { payment_session_timeout_minutes: value }
|
return { payment_request_timeout_minutes: value }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getReturningChatConfirmationTimeoutSeconds = async () => {
|
export const getReturningChatConfirmationTimeoutSeconds = async () => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
ExtensionStatus,
|
ExtensionStatus,
|
||||||
TransactionType,
|
TransactionType,
|
||||||
WsMessage,
|
WsMessage,
|
||||||
PaymentSessionStatus,
|
PaymentRequestStatus,
|
||||||
ExtensionTimeoutAction,
|
ExtensionTimeoutAction,
|
||||||
PairingFailureCause,
|
PairingFailureCause,
|
||||||
} from '../constants.js'
|
} from '../constants.js'
|
||||||
@@ -39,7 +39,7 @@ const getExtensionTimeoutAction = async () => {
|
|||||||
/**
|
/**
|
||||||
* Customer requests an extension.
|
* Customer requests an extension.
|
||||||
*
|
*
|
||||||
* `extension_payment_session_id` is REQUIRED. The payment session must:
|
* `extension_payment_request_id` is REQUIRED. The payment session must:
|
||||||
* - belong to this customer
|
* - belong to this customer
|
||||||
* - be in `confirmed` status (not yet consumed)
|
* - be in `confirmed` status (not yet consumed)
|
||||||
* - have `is_extension = true`
|
* - have `is_extension = true`
|
||||||
@@ -48,7 +48,7 @@ const getExtensionTimeoutAction = async () => {
|
|||||||
* The payment session is NOT consumed at request time. It is consumed at approval moment
|
* The payment session is NOT consumed at request time. It is consumed at approval moment
|
||||||
* (mitra explicit accept OR auto-approve fires).
|
* (mitra explicit accept OR auto-approve fires).
|
||||||
*/
|
*/
|
||||||
export const requestExtension = async (sessionId, customerId, { duration_minutes, price, extension_payment_session_id }) => {
|
export const requestExtension = async (sessionId, customerId, { duration_minutes, price, extension_payment_request_id }) => {
|
||||||
// Verify session belongs to customer and is in an extendable state.
|
// Verify session belongs to customer and is in an extendable state.
|
||||||
// customer_display_name is pulled along for the FCM body when the mitra
|
// customer_display_name is pulled along for the FCM body when the mitra
|
||||||
// misses the WS frame.
|
// misses the WS frame.
|
||||||
@@ -65,31 +65,31 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate extension payment session
|
// Validate extension payment session
|
||||||
if (!extension_payment_session_id) {
|
if (!extension_payment_request_id) {
|
||||||
throw Object.assign(new Error('extension_payment_session_id is required'), {
|
throw Object.assign(new Error('extension_payment_request_id is required'), {
|
||||||
code: 'VALIDATION_ERROR', statusCode: 422,
|
code: 'VALIDATION_ERROR', statusCode: 422,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const paySession = await getPaymentSession(extension_payment_session_id)
|
const payRequest = await getPaymentSession(extension_payment_request_id)
|
||||||
if (!paySession) {
|
if (!payRequest) {
|
||||||
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
||||||
}
|
}
|
||||||
if (paySession.customer_id !== customerId) {
|
if (payRequest.customer_id !== customerId) {
|
||||||
throw Object.assign(new Error('Payment session does not belong to this customer'), {
|
throw Object.assign(new Error('Payment session does not belong to this customer'), {
|
||||||
code: 'FORBIDDEN', statusCode: 403,
|
code: 'FORBIDDEN', statusCode: 403,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (paySession.status !== PaymentSessionStatus.CONFIRMED) {
|
if (payRequest.status !== PaymentRequestStatus.CONFIRMED) {
|
||||||
throw Object.assign(new Error(`Payment session is ${paySession.status}, must be confirmed`), {
|
throw Object.assign(new Error(`Payment session is ${payRequest.status}, must be confirmed`), {
|
||||||
code: 'INVALID_STATE', statusCode: 409,
|
code: 'INVALID_STATE', statusCode: 409,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!paySession.is_extension) {
|
if (!payRequest.is_extension) {
|
||||||
throw Object.assign(new Error('Payment session is not flagged as an extension payment'), {
|
throw Object.assign(new Error('Payment session is not flagged as an extension payment'), {
|
||||||
code: 'INVALID_STATE', statusCode: 409,
|
code: 'INVALID_STATE', statusCode: 409,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (paySession.is_first_session_discount) {
|
if (payRequest.is_first_session_discount) {
|
||||||
throw Object.assign(new Error('First-session discount is not available for extensions'), {
|
throw Object.assign(new Error('First-session discount is not available for extensions'), {
|
||||||
code: 'FIRST_SESSION_DISCOUNT_NOT_ALLOWED', statusCode: 400,
|
code: 'FIRST_SESSION_DISCOUNT_NOT_ALLOWED', statusCode: 400,
|
||||||
})
|
})
|
||||||
@@ -97,9 +97,9 @@ export const requestExtension = async (sessionId, customerId, { duration_minutes
|
|||||||
|
|
||||||
// Create extension record (linked to its payment session)
|
// Create extension record (linked to its payment session)
|
||||||
const [extension] = await sql`
|
const [extension] = await sql`
|
||||||
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_session_id)
|
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_request_id)
|
||||||
VALUES (${sessionId}, ${duration_minutes}, ${price}, ${ExtensionStatus.PENDING}, ${extension_payment_session_id})
|
VALUES (${sessionId}, ${duration_minutes}, ${price}, ${ExtensionStatus.PENDING}, ${extension_payment_request_id})
|
||||||
RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at, payment_session_id
|
RETURNING id, session_id, requested_duration_minutes, requested_price, status, requested_at, payment_request_id
|
||||||
`
|
`
|
||||||
|
|
||||||
// Pause the session
|
// Pause the session
|
||||||
@@ -182,7 +182,7 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) =
|
|||||||
UPDATE session_extensions
|
UPDATE session_extensions
|
||||||
SET status = ${status}, responded_at = NOW()
|
SET status = ${status}, responded_at = NOW()
|
||||||
WHERE id = ${extensionId} AND session_id = ${sessionId} AND status = ${ExtensionStatus.PENDING}
|
WHERE id = ${extensionId} AND session_id = ${sessionId} AND status = ${ExtensionStatus.PENDING}
|
||||||
RETURNING id, session_id, requested_duration_minutes, requested_price, status, payment_session_id
|
RETURNING id, session_id, requested_duration_minutes, requested_price, status, payment_request_id
|
||||||
`
|
`
|
||||||
|
|
||||||
if (!extension) {
|
if (!extension) {
|
||||||
@@ -201,8 +201,8 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) =
|
|||||||
|
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
// Charge fires AT approval moment (explicit OR auto-approve).
|
// Charge fires AT approval moment (explicit OR auto-approve).
|
||||||
if (extension.payment_session_id) {
|
if (extension.payment_request_id) {
|
||||||
await consumePaymentSession(extension.payment_session_id)
|
await consumePaymentSession(extension.payment_request_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear any pending grace timer from the previous expiry
|
// Clear any pending grace timer from the previous expiry
|
||||||
@@ -244,8 +244,8 @@ const finalizeExtension = async (extensionId, sessionId, accepted, viaTimeout) =
|
|||||||
// Rejected — no charge. Fail the extension payment session if present.
|
// Rejected — no charge. Fail the extension payment session if present.
|
||||||
// viaTimeout=false here means an explicit mitra reject (the timer path goes through
|
// viaTimeout=false here means an explicit mitra reject (the timer path goes through
|
||||||
// timeoutExtension which never enters this branch with viaTimeout=true for reject).
|
// timeoutExtension which never enters this branch with viaTimeout=true for reject).
|
||||||
if (extension.payment_session_id) {
|
if (extension.payment_request_id) {
|
||||||
await failPaymentSession(extension.payment_session_id, PairingFailureCause.EXTENSION_REJECTED)
|
await failPaymentSession(extension.payment_request_id, PairingFailureCause.EXTENSION_REJECTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}`
|
await sql`UPDATE chat_sessions SET status = ${SessionStatus.CLOSING} WHERE id = ${extension.session_id}`
|
||||||
@@ -321,12 +321,12 @@ const timeoutExtension = async (extensionId, sessionId, mitraId) => {
|
|||||||
UPDATE session_extensions
|
UPDATE session_extensions
|
||||||
SET status = ${ExtensionStatus.TIMEOUT}, responded_at = NOW()
|
SET status = ${ExtensionStatus.TIMEOUT}, responded_at = NOW()
|
||||||
WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING}
|
WHERE id = ${extensionId} AND status = ${ExtensionStatus.PENDING}
|
||||||
RETURNING id, payment_session_id
|
RETURNING id, payment_request_id
|
||||||
`
|
`
|
||||||
if (!timedOut) return
|
if (!timedOut) return
|
||||||
|
|
||||||
if (timedOut.payment_session_id) {
|
if (timedOut.payment_request_id) {
|
||||||
await failPaymentSession(timedOut.payment_session_id, causeTag)
|
await failPaymentSession(timedOut.payment_request_id, causeTag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move session to closing & notify both parties (matches the explicit-reject UX).
|
// Move session to closing & notify both parties (matches the explicit-reject UX).
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ const sql = getDb()
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert a pairing_failures row. Called from payment.service.failPaymentSession (and the
|
* Insert a pairing_failures row. Called from payment.service.failPaymentSession (and the
|
||||||
* background sweeper for `payment_session_expired`).
|
* background sweeper for `payment_request_expired`).
|
||||||
*/
|
*/
|
||||||
export const recordFailure = async ({ paymentSessionId, customerId, targetedMitraId = null, causeTag, amount }) => {
|
export const recordFailure = async ({ paymentRequestId, customerId, targetedMitraId = null, causeTag, amount }) => {
|
||||||
if (!Object.values(PairingFailureCause).includes(causeTag)) {
|
if (!Object.values(PairingFailureCause).includes(causeTag)) {
|
||||||
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
||||||
}
|
}
|
||||||
const [row] = await sql`
|
const [row] = await sql`
|
||||||
INSERT INTO pairing_failures (
|
INSERT INTO pairing_failures (
|
||||||
payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount
|
payment_request_id, customer_id, targeted_mitra_id, cause_tag, amount
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
${paymentSessionId}, ${customerId}, ${targetedMitraId}, ${causeTag}, ${amount}
|
${paymentRequestId}, ${customerId}, ${targetedMitraId}, ${causeTag}, ${amount}
|
||||||
)
|
)
|
||||||
RETURNING id, payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount,
|
RETURNING id, payment_request_id, customer_id, targeted_mitra_id, cause_tag, amount,
|
||||||
operator_action, actioned_by, actioned_at, created_at
|
operator_action, actioned_by, actioned_at, created_at
|
||||||
`
|
`
|
||||||
return row
|
return row
|
||||||
@@ -33,7 +33,7 @@ export const listFailures = async ({ causeTags = null, dateFrom = null, dateTo =
|
|||||||
|
|
||||||
const items = await sql`
|
const items = await sql`
|
||||||
SELECT
|
SELECT
|
||||||
pf.id, pf.payment_session_id, pf.customer_id, pf.targeted_mitra_id,
|
pf.id, pf.payment_request_id, pf.customer_id, pf.targeted_mitra_id,
|
||||||
pf.cause_tag, pf.amount, pf.operator_action, pf.actioned_by, pf.actioned_at, pf.created_at,
|
pf.cause_tag, pf.amount, pf.operator_action, pf.actioned_by, pf.actioned_at, pf.created_at,
|
||||||
c.display_name AS customer_call_name,
|
c.display_name AS customer_call_name,
|
||||||
m.display_name AS targeted_mitra_call_name,
|
m.display_name AS targeted_mitra_call_name,
|
||||||
@@ -75,7 +75,7 @@ export const setOperatorAction = async (failureId, ccUserId, action) => {
|
|||||||
actioned_by = ${ccUserId},
|
actioned_by = ${ccUserId},
|
||||||
actioned_at = NOW()
|
actioned_at = NOW()
|
||||||
WHERE id = ${failureId}
|
WHERE id = ${failureId}
|
||||||
RETURNING id, payment_session_id, customer_id, targeted_mitra_id, cause_tag, amount,
|
RETURNING id, payment_request_id, customer_id, targeted_mitra_id, cause_tag, amount,
|
||||||
operator_action, actioned_by, actioned_at, created_at
|
operator_action, actioned_by, actioned_at, created_at
|
||||||
`
|
`
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
TransactionType,
|
TransactionType,
|
||||||
WsMessage,
|
WsMessage,
|
||||||
TopicSensitivity,
|
TopicSensitivity,
|
||||||
PaymentSessionStatus,
|
PaymentRequestStatus,
|
||||||
PairingFailureCause,
|
PairingFailureCause,
|
||||||
PairingRequestType,
|
PairingRequestType,
|
||||||
} from '../constants.js'
|
} from '../constants.js'
|
||||||
@@ -68,7 +68,7 @@ const notifyCustomer = async (customerId, data) => {
|
|||||||
body: 'Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera.',
|
body: 'Maaf, kami tidak bisa menemukan bestie untuk sesimu. Tim kami akan menghubungimu segera.',
|
||||||
data: {
|
data: {
|
||||||
type: WsMessage.PAIRING_FAILED,
|
type: WsMessage.PAIRING_FAILED,
|
||||||
payment_session_id: data.payment_session_id || '',
|
payment_request_id: data.payment_request_id || '',
|
||||||
cause_tag: data.cause_tag || '',
|
cause_tag: data.cause_tag || '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -100,41 +100,41 @@ export const findAvailableMitras = async () => {
|
|||||||
* Validate that a payment session is owned by the customer, confirmed, and not yet consumed.
|
* Validate that a payment session is owned by the customer, confirmed, and not yet consumed.
|
||||||
* Throws on mismatch. Returns the loaded payment session row.
|
* Throws on mismatch. Returns the loaded payment session row.
|
||||||
*/
|
*/
|
||||||
const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { allowExtension = false } = {}) => {
|
const requireConfirmedPaymentRequest = async (paymentRequestId, customerId, { allowExtension = false } = {}) => {
|
||||||
if (!paymentSessionId) {
|
if (!paymentRequestId) {
|
||||||
throw Object.assign(new Error('payment_session_id is required'), {
|
throw Object.assign(new Error('payment_request_id is required'), {
|
||||||
code: 'VALIDATION_ERROR', statusCode: 422,
|
code: 'VALIDATION_ERROR', statusCode: 422,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const paySession = await getPaymentSession(paymentSessionId)
|
const payRequest = await getPaymentSession(paymentRequestId)
|
||||||
if (!paySession) {
|
if (!payRequest) {
|
||||||
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
||||||
}
|
}
|
||||||
if (paySession.customer_id !== customerId) {
|
if (payRequest.customer_id !== customerId) {
|
||||||
throw Object.assign(new Error('Payment session does not belong to this customer'), {
|
throw Object.assign(new Error('Payment session does not belong to this customer'), {
|
||||||
code: 'FORBIDDEN', statusCode: 403,
|
code: 'FORBIDDEN', statusCode: 403,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (paySession.status !== PaymentSessionStatus.CONFIRMED) {
|
if (payRequest.status !== PaymentRequestStatus.CONFIRMED) {
|
||||||
throw Object.assign(new Error(`Payment session is ${paySession.status}, must be confirmed`), {
|
throw Object.assign(new Error(`Payment session is ${payRequest.status}, must be confirmed`), {
|
||||||
code: 'INVALID_STATE', statusCode: 409,
|
code: 'INVALID_STATE', statusCode: 409,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (paySession.is_extension && !allowExtension) {
|
if (payRequest.is_extension && !allowExtension) {
|
||||||
throw Object.assign(new Error('Extension payment session cannot be used to start a new chat'), {
|
throw Object.assign(new Error('Extension payment session cannot be used to start a new chat'), {
|
||||||
code: 'INVALID_STATE', statusCode: 409,
|
code: 'INVALID_STATE', statusCode: 409,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (new Date(paySession.expires_at) <= new Date()) {
|
if (new Date(payRequest.expires_at) <= new Date()) {
|
||||||
// Check expiry inline at every state transition (defense in depth vs. the background sweeper).
|
// Check expiry inline at every state transition (defense in depth vs. the background sweeper).
|
||||||
await failPaymentSession(paymentSessionId, PairingFailureCause.PAYMENT_SESSION_EXPIRED)
|
await failPaymentSession(paymentRequestId, PairingFailureCause.PAYMENT_REQUEST_EXPIRED)
|
||||||
throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 })
|
throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 })
|
||||||
}
|
}
|
||||||
return paySession
|
return payRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* General-blast pairing request. Requires a confirmed payment_session_id.
|
* General-blast pairing request. Requires a confirmed payment_request_id.
|
||||||
*
|
*
|
||||||
* The duration_minutes / price / is_first_session_discount values for the chat_session row are
|
* The duration_minutes / price / is_first_session_discount values for the chat_session row are
|
||||||
* sourced from the payment session — the client does not dictate pricing here.
|
* sourced from the payment session — the client does not dictate pricing here.
|
||||||
@@ -144,12 +144,12 @@ const requireConfirmedPaymentSession = async (paymentSessionId, customerId, { al
|
|||||||
* fall back to general blast on the same payment. The flag bypasses the
|
* fall back to general blast on the same payment. The flag bypasses the
|
||||||
* "use returning-chat endpoint" guard in that exact case.
|
* "use returning-chat endpoint" guard in that exact case.
|
||||||
*/
|
*/
|
||||||
export const createPairingRequest = async (customerId, { paymentSessionId, topic_sensitivity, allowTargetedPayment = false } = {}) => {
|
export const createPairingRequest = async (customerId, { paymentRequestId, topic_sensitivity, allowTargetedPayment = false } = {}) => {
|
||||||
const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId)
|
const payRequest = await requireConfirmedPaymentRequest(paymentRequestId, customerId)
|
||||||
|
|
||||||
// Targeted payment session must use createTargetedPairingRequest unless we're
|
// Targeted payment session must use createTargetedPairingRequest unless we're
|
||||||
// explicitly invoked by the fallback-to-blast path.
|
// explicitly invoked by the fallback-to-blast path.
|
||||||
if (paySession.targeted_mitra_id && !allowTargetedPayment) {
|
if (payRequest.targeted_mitra_id && !allowTargetedPayment) {
|
||||||
throw Object.assign(new Error('Payment session is targeted to a specific mitra; use returning-chat endpoint'), {
|
throw Object.assign(new Error('Payment session is targeted to a specific mitra; use returning-chat endpoint'), {
|
||||||
code: 'INVALID_STATE', statusCode: 409,
|
code: 'INVALID_STATE', statusCode: 409,
|
||||||
})
|
})
|
||||||
@@ -170,7 +170,7 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
|
|||||||
const availableMitras = await findAvailableMitras()
|
const availableMitras = await findAvailableMitras()
|
||||||
if (availableMitras.length === 0) {
|
if (availableMitras.length === 0) {
|
||||||
// No mitras to blast to — fail the payment immediately.
|
// No mitras to blast to — fail the payment immediately.
|
||||||
await failPaymentSession(paymentSessionId, PairingFailureCause.NO_MITRA_AVAILABLE)
|
await failPaymentSession(paymentRequestId, PairingFailureCause.NO_MITRA_AVAILABLE)
|
||||||
throw Object.assign(new Error('No bestie available'), {
|
throw Object.assign(new Error('No bestie available'), {
|
||||||
code: 'NO_MITRA_AVAILABLE', statusCode: 404,
|
code: 'NO_MITRA_AVAILABLE', statusCode: 404,
|
||||||
})
|
})
|
||||||
@@ -183,14 +183,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
|
|||||||
// Create session sourced from the payment session.
|
// Create session sourced from the payment session.
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
INSERT INTO chat_sessions (
|
INSERT INTO chat_sessions (
|
||||||
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
|
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
||||||
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
|
${payRequest.duration_minutes}, ${payRequest.amount}, ${payRequest.is_first_session_discount},
|
||||||
${resolvedTopic}, ${paymentSessionId}
|
${resolvedTopic}, ${paymentRequestId}
|
||||||
)
|
)
|
||||||
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
|
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// Fan out to all available mitras in parallel — DB inserts and notifications are
|
// Fan out to all available mitras in parallel — DB inserts and notifications are
|
||||||
@@ -225,6 +225,56 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
|
|||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 5: server-driven pairing entry point — called by the payment service's
|
||||||
|
* `payment_request.confirmed` event subscriber. Replaces the client-driven
|
||||||
|
* POST /chat/request path for new payments.
|
||||||
|
*
|
||||||
|
* **Idempotent.** If a chat_session already exists for this payment_request_id,
|
||||||
|
* returns it without doing anything. Safe to call multiple times — the
|
||||||
|
* reconciliation sweeper relies on this property to retry lost events.
|
||||||
|
*
|
||||||
|
* Routes general-blast vs targeted based on productMetadata.targeted_mitra_id.
|
||||||
|
* Errors are caught and logged (don't bubble — the event subscriber wrapper would
|
||||||
|
* also catch but logging here gives better context).
|
||||||
|
*/
|
||||||
|
export const startPairingFromPaymentRequest = async ({ paymentRequestId, productMetadata, customerId }) => {
|
||||||
|
// Idempotency check — covers webhook retries, reconciliation sweeper re-emit,
|
||||||
|
// and the case where the legacy client still POSTs to /chat/request after our
|
||||||
|
// subscriber already started pairing.
|
||||||
|
const [existing] = await sql`
|
||||||
|
SELECT id FROM chat_sessions WHERE payment_request_id = ${paymentRequestId}
|
||||||
|
`
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const targetedMitraId = productMetadata?.targeted_mitra_id ?? null
|
||||||
|
const topicSensitivity = productMetadata?.topic_sensitivity ?? TopicSensitivity.REGULAR
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (targetedMitraId) {
|
||||||
|
return await createTargetedPairingRequest(customerId, {
|
||||||
|
paymentRequestId,
|
||||||
|
targetedMitraId,
|
||||||
|
topic_sensitivity: topicSensitivity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return await createPairingRequest(customerId, {
|
||||||
|
paymentRequestId,
|
||||||
|
topic_sensitivity: topicSensitivity,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// Already-active is benign — covers the race where the legacy /chat/request
|
||||||
|
// beat the subscriber to it. NO_MITRA_AVAILABLE has already failed the payment
|
||||||
|
// (createPairingRequest does that internally) — we don't need to act further.
|
||||||
|
if (err.code === 'ALREADY_ACTIVE' || err.code === 'NO_MITRA_AVAILABLE') {
|
||||||
|
console.log(`[pairing subscriber] ${err.code} for payment ${paymentRequestId} — already handled`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
console.error('[pairing subscriber] startPairing failed', { paymentRequestId, err })
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Targeted pairing request for "Curhat lagi" (returning chat).
|
* Targeted pairing request for "Curhat lagi" (returning chat).
|
||||||
*
|
*
|
||||||
@@ -236,14 +286,14 @@ export const createPairingRequest = async (customerId, { paymentSessionId, topic
|
|||||||
* - On explicit decline by mitra: fail payment with `targeted_mitra_rejected`, push WS event.
|
* - On explicit decline by mitra: fail payment with `targeted_mitra_rejected`, push WS event.
|
||||||
* - On accept: existing accept path runs (consumes payment session as for general blast).
|
* - On accept: existing accept path runs (consumes payment session as for general blast).
|
||||||
*/
|
*/
|
||||||
export const createTargetedPairingRequest = async (customerId, { paymentSessionId, targetedMitraId, topic_sensitivity } = {}) => {
|
export const createTargetedPairingRequest = async (customerId, { paymentRequestId, targetedMitraId, topic_sensitivity } = {}) => {
|
||||||
const paySession = await requireConfirmedPaymentSession(paymentSessionId, customerId)
|
const payRequest = await requireConfirmedPaymentRequest(paymentRequestId, customerId)
|
||||||
|
|
||||||
if (!targetedMitraId) {
|
if (!targetedMitraId) {
|
||||||
throw Object.assign(new Error('targetedMitraId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
throw Object.assign(new Error('targetedMitraId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
||||||
}
|
}
|
||||||
// Cross-check: payment_session.targeted_mitra_id should match (if set).
|
// Cross-check: payment_request.targeted_mitra_id should match (if set).
|
||||||
if (paySession.targeted_mitra_id && paySession.targeted_mitra_id !== targetedMitraId) {
|
if (payRequest.targeted_mitra_id && payRequest.targeted_mitra_id !== targetedMitraId) {
|
||||||
throw Object.assign(new Error('targetedMitraId does not match payment session'), {
|
throw Object.assign(new Error('targetedMitraId does not match payment session'), {
|
||||||
code: 'INVALID_STATE', statusCode: 409,
|
code: 'INVALID_STATE', statusCode: 409,
|
||||||
})
|
})
|
||||||
@@ -267,11 +317,11 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
|
|||||||
// Intermediate failure: audit row written, payment stays `confirmed` so the customer
|
// Intermediate failure: audit row written, payment stays `confirmed` so the customer
|
||||||
// can choose to fall back to general blast (or cancel, which terminates).
|
// can choose to fall back to general blast (or cancel, which terminates).
|
||||||
await recordIntermediateFailure({
|
await recordIntermediateFailure({
|
||||||
paymentSessionId,
|
paymentRequestId,
|
||||||
customerId,
|
customerId,
|
||||||
targetedMitraId,
|
targetedMitraId,
|
||||||
causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE,
|
causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE,
|
||||||
amount: paySession.amount,
|
amount: payRequest.amount,
|
||||||
})
|
})
|
||||||
throw Object.assign(new Error('Targeted mitra is offline'), {
|
throw Object.assign(new Error('Targeted mitra is offline'), {
|
||||||
code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline',
|
code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline',
|
||||||
@@ -285,11 +335,11 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
|
|||||||
const midSessionWithCustomer = await isMitraInActiveSessionWithCustomer(targetedMitraId, customerId)
|
const midSessionWithCustomer = await isMitraInActiveSessionWithCustomer(targetedMitraId, customerId)
|
||||||
if (!midSessionWithCustomer) {
|
if (!midSessionWithCustomer) {
|
||||||
await recordIntermediateFailure({
|
await recordIntermediateFailure({
|
||||||
paymentSessionId,
|
paymentRequestId,
|
||||||
customerId,
|
customerId,
|
||||||
targetedMitraId,
|
targetedMitraId,
|
||||||
causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE,
|
causeTag: PairingFailureCause.TARGETED_MITRA_OFFLINE,
|
||||||
amount: paySession.amount,
|
amount: payRequest.amount,
|
||||||
})
|
})
|
||||||
throw Object.assign(new Error('Targeted mitra is at capacity'), {
|
throw Object.assign(new Error('Targeted mitra is at capacity'), {
|
||||||
code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline',
|
code: 'TARGETED_MITRA_OFFLINE', statusCode: 409, reason: 'targeted_mitra_offline',
|
||||||
@@ -305,14 +355,14 @@ export const createTargetedPairingRequest = async (customerId, { paymentSessionI
|
|||||||
// Create session sourced from the payment session, status = pending_acceptance.
|
// Create session sourced from the payment session, status = pending_acceptance.
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
INSERT INTO chat_sessions (
|
INSERT INTO chat_sessions (
|
||||||
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id
|
customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
${customerId}, ${SessionStatus.PENDING_ACCEPTANCE},
|
||||||
${paySession.duration_minutes}, ${paySession.amount}, ${paySession.is_first_session_discount},
|
${payRequest.duration_minutes}, ${payRequest.amount}, ${payRequest.is_first_session_discount},
|
||||||
${resolvedTopic}, ${paymentSessionId}
|
${resolvedTopic}, ${paymentRequestId}
|
||||||
)
|
)
|
||||||
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_session_id, created_at
|
RETURNING id, customer_id, status, duration_minutes, price, is_first_session_discount, topic_sensitivity, payment_request_id, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// Single notification to the targeted mitra
|
// Single notification to the targeted mitra
|
||||||
@@ -355,7 +405,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW()
|
SET mitra_id = ${mitraId}, status = ${SessionStatus.PENDING_PAYMENT}, paired_at = NOW()
|
||||||
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} AND mitra_id IS NULL
|
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE} AND mitra_id IS NULL
|
||||||
RETURNING id, customer_id, mitra_id, status, paired_at, payment_session_id
|
RETURNING id, customer_id, mitra_id, status, paired_at, payment_request_id
|
||||||
`
|
`
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -386,8 +436,8 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Consume the payment session at the moment of acceptance.
|
// Consume the payment session at the moment of acceptance.
|
||||||
if (session.payment_session_id) {
|
if (session.payment_request_id) {
|
||||||
await consumePaymentSession(session.payment_session_id)
|
await consumePaymentSession(session.payment_request_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate the session and set expires_at.
|
// Activate the session and set expires_at.
|
||||||
@@ -399,7 +449,7 @@ export const acceptPairingRequest = async (sessionId, mitraId) => {
|
|||||||
ELSE NULL
|
ELSE NULL
|
||||||
END
|
END
|
||||||
WHERE id = ${sessionId}
|
WHERE id = ${sessionId}
|
||||||
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_session_id
|
RETURNING id, customer_id, mitra_id, status, paired_at, duration_minutes, price, is_first_session_discount, expires_at, payment_request_id
|
||||||
`
|
`
|
||||||
|
|
||||||
// Record transaction
|
// Record transaction
|
||||||
@@ -453,25 +503,25 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
|
|||||||
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL
|
WHERE session_id = ${sessionId} AND mitra_id = ${mitraId} AND response IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
// Targeted-vs-general is determined by the payment_session.targeted_mitra_id, not by
|
// Targeted-vs-general is determined by the payment_request.targeted_mitra_id, not by
|
||||||
// notification count — a general blast with only one online mitra also has length=1.
|
// notification count — a general blast with only one online mitra also has length=1.
|
||||||
const [targetCheck] = await sql`
|
const [targetCheck] = await sql`
|
||||||
SELECT ps.targeted_mitra_id
|
SELECT ps.targeted_mitra_id
|
||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
|
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
|
||||||
WHERE cs.id = ${sessionId}
|
WHERE cs.id = ${sessionId}
|
||||||
`
|
`
|
||||||
const isTargeted = !!targetCheck?.targeted_mitra_id
|
const isTargeted = !!targetCheck?.targeted_mitra_id
|
||||||
|
|
||||||
if (isTargeted) {
|
if (isTargeted) {
|
||||||
// Mark the chat_session as expired (the targeted attempt is over) — but keep the
|
// Mark the chat_session as expired (the targeted attempt is over) — but keep the
|
||||||
// payment_session in `confirmed` so the customer can fall back to general blast on
|
// payment_request in `confirmed` so the customer can fall back to general blast on
|
||||||
// the same payment, or cancel (which then terminates).
|
// the same payment, or cancel (which then terminates).
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET status = ${SessionStatus.EXPIRED}
|
SET status = ${SessionStatus.EXPIRED}
|
||||||
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
|
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
|
||||||
RETURNING id, customer_id, payment_session_id
|
RETURNING id, customer_id, payment_request_id
|
||||||
`
|
`
|
||||||
if (session) {
|
if (session) {
|
||||||
// Clear the 20s timer if still pending.
|
// Clear the 20s timer if still pending.
|
||||||
@@ -482,15 +532,15 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Audit row only; payment session stays `confirmed`.
|
// Audit row only; payment session stays `confirmed`.
|
||||||
if (session.payment_session_id) {
|
if (session.payment_request_id) {
|
||||||
const paySession = await getPaymentSession(session.payment_session_id)
|
const payRequest = await getPaymentSession(session.payment_request_id)
|
||||||
if (paySession) {
|
if (payRequest) {
|
||||||
await recordIntermediateFailure({
|
await recordIntermediateFailure({
|
||||||
paymentSessionId: session.payment_session_id,
|
paymentRequestId: session.payment_request_id,
|
||||||
customerId: session.customer_id,
|
customerId: session.customer_id,
|
||||||
targetedMitraId: mitraId,
|
targetedMitraId: mitraId,
|
||||||
causeTag: PairingFailureCause.TARGETED_MITRA_REJECTED,
|
causeTag: PairingFailureCause.TARGETED_MITRA_REJECTED,
|
||||||
amount: paySession.amount,
|
amount: payRequest.amount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,7 +549,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
|
|||||||
await notifyCustomer(session.customer_id, {
|
await notifyCustomer(session.customer_id, {
|
||||||
type: WsMessage.RETURNING_CHAT_REJECTED,
|
type: WsMessage.RETURNING_CHAT_REJECTED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
payment_session_id: session.payment_session_id,
|
payment_request_id: session.payment_request_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -518,7 +568,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
|
|||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET status = ${SessionStatus.EXPIRED}
|
SET status = ${SessionStatus.EXPIRED}
|
||||||
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
|
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
|
||||||
RETURNING id, customer_id, payment_session_id
|
RETURNING id, customer_id, payment_request_id
|
||||||
`
|
`
|
||||||
if (session) {
|
if (session) {
|
||||||
const timeoutId = pairingTimeouts.get(sessionId)
|
const timeoutId = pairingTimeouts.get(sessionId)
|
||||||
@@ -529,14 +579,14 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
|
|||||||
|
|
||||||
// Intermediate failure: payment stays confirmed so the customer can re-blast
|
// Intermediate failure: payment stays confirmed so the customer can re-blast
|
||||||
// from the S7 timeout CTA. Audit row is still written.
|
// from the S7 timeout CTA. Audit row is still written.
|
||||||
if (session.payment_session_id) {
|
if (session.payment_request_id) {
|
||||||
const paySession = await getPaymentSession(session.payment_session_id)
|
const payRequest = await getPaymentSession(session.payment_request_id)
|
||||||
if (paySession) {
|
if (payRequest) {
|
||||||
await recordIntermediateFailure({
|
await recordIntermediateFailure({
|
||||||
paymentSessionId: session.payment_session_id,
|
paymentRequestId: session.payment_request_id,
|
||||||
customerId: session.customer_id,
|
customerId: session.customer_id,
|
||||||
causeTag: PairingFailureCause.ALL_MITRAS_REJECTED,
|
causeTag: PairingFailureCause.ALL_MITRAS_REJECTED,
|
||||||
amount: paySession.amount,
|
amount: payRequest.amount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -544,7 +594,7 @@ export const declinePairingRequest = async (sessionId, mitraId) => {
|
|||||||
await notifyCustomer(session.customer_id, {
|
await notifyCustomer(session.customer_id, {
|
||||||
type: WsMessage.PAIRING_FAILED,
|
type: WsMessage.PAIRING_FAILED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
payment_session_id: session.payment_session_id,
|
payment_request_id: session.payment_request_id,
|
||||||
cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED,
|
cause_tag: PairingFailureCause.ALL_MITRAS_REJECTED,
|
||||||
is_terminal: false,
|
is_terminal: false,
|
||||||
})
|
})
|
||||||
@@ -558,7 +608,7 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
|
|||||||
SET status = ${SessionStatus.CANCELLED}
|
SET status = ${SessionStatus.CANCELLED}
|
||||||
WHERE id = ${sessionId} AND customer_id = ${customerId}
|
WHERE id = ${sessionId} AND customer_id = ${customerId}
|
||||||
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
|
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
|
||||||
RETURNING id, customer_id, status, payment_session_id
|
RETURNING id, customer_id, status, payment_request_id
|
||||||
`
|
`
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -594,8 +644,8 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
|
|||||||
// Customer initiated this cancel; the calling client already navigates home. Do not
|
// Customer initiated this cancel; the calling client already navigates home. Do not
|
||||||
// push PAIRING_FAILED for customer-initiated cancels — surfacing it as a "failure"
|
// push PAIRING_FAILED for customer-initiated cancels — surfacing it as a "failure"
|
||||||
// event (especially via FCM if backgrounded) misframes the user's own action.
|
// event (especially via FCM if backgrounded) misframes the user's own action.
|
||||||
if (session.payment_session_id) {
|
if (session.payment_request_id) {
|
||||||
await failPaymentSession(session.payment_session_id, PairingFailureCause.CUSTOMER_CANCELLED)
|
await failPaymentSession(session.payment_request_id, PairingFailureCause.CUSTOMER_CANCELLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
return session
|
return session
|
||||||
@@ -609,12 +659,12 @@ export const cancelPairingRequest = async (sessionId, customerId) => {
|
|||||||
* If a chat_session was already created (general blast in flight, or targeted request out),
|
* If a chat_session was already created (general blast in flight, or targeted request out),
|
||||||
* we cancel that too.
|
* we cancel that too.
|
||||||
*/
|
*/
|
||||||
export const cancelPaymentSearch = async (paymentSessionId, customerId) => {
|
export const cancelPaymentSearch = async (paymentRequestId, customerId) => {
|
||||||
const paySession = await getPaymentSession(paymentSessionId)
|
const payRequest = await getPaymentSession(paymentRequestId)
|
||||||
if (!paySession) {
|
if (!payRequest) {
|
||||||
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
||||||
}
|
}
|
||||||
if (paySession.customer_id !== customerId) {
|
if (payRequest.customer_id !== customerId) {
|
||||||
throw Object.assign(new Error('Payment session does not belong to this customer'), {
|
throw Object.assign(new Error('Payment session does not belong to this customer'), {
|
||||||
code: 'FORBIDDEN', statusCode: 403,
|
code: 'FORBIDDEN', statusCode: 403,
|
||||||
})
|
})
|
||||||
@@ -623,7 +673,7 @@ export const cancelPaymentSearch = async (paymentSessionId, customerId) => {
|
|||||||
// If a chat_session exists for this payment in pending_acceptance/searching, cancel it.
|
// If a chat_session exists for this payment in pending_acceptance/searching, cancel it.
|
||||||
const [linkedSession] = await sql`
|
const [linkedSession] = await sql`
|
||||||
SELECT id FROM chat_sessions
|
SELECT id FROM chat_sessions
|
||||||
WHERE payment_session_id = ${paymentSessionId}
|
WHERE payment_request_id = ${paymentRequestId}
|
||||||
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
|
AND status IN (${SessionStatus.SEARCHING}, ${SessionStatus.PENDING_ACCEPTANCE})
|
||||||
`
|
`
|
||||||
if (linkedSession) {
|
if (linkedSession) {
|
||||||
@@ -634,37 +684,37 @@ export const cancelPaymentSearch = async (paymentSessionId, customerId) => {
|
|||||||
// Otherwise fail the payment directly. Covers the case where the customer cancels after
|
// Otherwise fail the payment directly. Covers the case where the customer cancels after
|
||||||
// the targeted attempt already expired/rejected (chat_session no longer pending_acceptance)
|
// the targeted attempt already expired/rejected (chat_session no longer pending_acceptance)
|
||||||
// but the payment is still `confirmed`. No customer-side WS push — see cancelPairingRequest.
|
// but the payment is still `confirmed`. No customer-side WS push — see cancelPairingRequest.
|
||||||
if (paySession.status === PaymentSessionStatus.CONFIRMED) {
|
if (payRequest.status === PaymentRequestStatus.CONFIRMED) {
|
||||||
await failPaymentSession(paymentSessionId, PairingFailureCause.CUSTOMER_CANCELLED)
|
await failPaymentSession(paymentRequestId, PairingFailureCause.CUSTOMER_CANCELLED)
|
||||||
}
|
}
|
||||||
return { id: paymentSessionId, payment_session_id: paymentSessionId }
|
return { id: paymentRequestId, payment_request_id: paymentRequestId }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After a returning-chat fail, customer taps "Chat dengan bestie lain".
|
* After a returning-chat fail, customer taps "Chat dengan bestie lain".
|
||||||
*
|
*
|
||||||
* The original payment_session stays in `confirmed` for the entire returning-chat flow —
|
* The original payment_request stays in `confirmed` for the entire returning-chat flow —
|
||||||
* targeted reject/timeout writes an audit-only `pairing_failures` row but does NOT terminate.
|
* targeted reject/timeout writes an audit-only `pairing_failures` row but does NOT terminate.
|
||||||
* So when the customer falls back to general blast, we reuse the same `payment_session_id`
|
* So when the customer falls back to general blast, we reuse the same `payment_request_id`
|
||||||
* directly. Multiple `pairing_failures` rows may FK from one payment_session — that's the
|
* directly. Multiple `pairing_failures` rows may FK from one payment_request — that's the
|
||||||
* desired CC UX (one row per failed attempt). Termination happens only at the actual end
|
* desired CC UX (one row per failed attempt). Termination happens only at the actual end
|
||||||
* of the flow (chat starts → consumed; cancel/blast-exhaust → failed_pairing).
|
* of the flow (chat starts → consumed; cancel/blast-exhaust → failed_delivery).
|
||||||
*
|
*
|
||||||
* The targeted_mitra_id flag on the original row is left as-is (it records the customer's
|
* The targeted_mitra_id flag on the original row is left as-is (it records the customer's
|
||||||
* original intent); the general blast happens regardless.
|
* original intent); the general blast happens regardless.
|
||||||
*/
|
*/
|
||||||
export const fallbackToGeneralBlast = async (paymentSessionId, customerId, { topic_sensitivity } = {}) => {
|
export const fallbackToGeneralBlast = async (paymentRequestId, customerId, { topic_sensitivity } = {}) => {
|
||||||
const paySession = await getPaymentSession(paymentSessionId)
|
const payRequest = await getPaymentSession(paymentRequestId)
|
||||||
if (!paySession) {
|
if (!payRequest) {
|
||||||
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
||||||
}
|
}
|
||||||
if (paySession.customer_id !== customerId) {
|
if (payRequest.customer_id !== customerId) {
|
||||||
throw Object.assign(new Error('Payment session does not belong to this customer'), {
|
throw Object.assign(new Error('Payment session does not belong to this customer'), {
|
||||||
code: 'FORBIDDEN', statusCode: 403,
|
code: 'FORBIDDEN', statusCode: 403,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (paySession.status !== PaymentSessionStatus.CONFIRMED) {
|
if (payRequest.status !== PaymentRequestStatus.CONFIRMED) {
|
||||||
throw Object.assign(new Error(`Cannot fallback from payment in status ${paySession.status}`), {
|
throw Object.assign(new Error(`Cannot fallback from payment in status ${payRequest.status}`), {
|
||||||
code: 'INVALID_STATE', statusCode: 409,
|
code: 'INVALID_STATE', statusCode: 409,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -672,7 +722,7 @@ export const fallbackToGeneralBlast = async (paymentSessionId, customerId, { top
|
|||||||
// Run the general blast against the SAME payment session. Pass `allowTargetedPayment`
|
// Run the general blast against the SAME payment session. Pass `allowTargetedPayment`
|
||||||
// so the targeted_mitra_id on the payment session doesn't trip the general-blast guard.
|
// so the targeted_mitra_id on the payment session doesn't trip the general-blast guard.
|
||||||
return createPairingRequest(customerId, {
|
return createPairingRequest(customerId, {
|
||||||
paymentSessionId,
|
paymentRequestId,
|
||||||
topic_sensitivity,
|
topic_sensitivity,
|
||||||
allowTargetedPayment: true,
|
allowTargetedPayment: true,
|
||||||
})
|
})
|
||||||
@@ -683,7 +733,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
|
|||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET status = ${SessionStatus.EXPIRED}
|
SET status = ${SessionStatus.EXPIRED}
|
||||||
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
|
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
|
||||||
RETURNING id, customer_id, status, payment_session_id
|
RETURNING id, customer_id, status, payment_request_id
|
||||||
`
|
`
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
|
|
||||||
@@ -699,14 +749,14 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
|
|||||||
// Intermediate failure: payment session stays `confirmed` so the customer can
|
// Intermediate failure: payment session stays `confirmed` so the customer can
|
||||||
// re-blast on the same payment from the S7 timeout CTA. Audit row is still
|
// re-blast on the same payment from the S7 timeout CTA. Audit row is still
|
||||||
// written so the failed-pairing CC view captures every attempt.
|
// written so the failed-pairing CC view captures every attempt.
|
||||||
if (session.payment_session_id) {
|
if (session.payment_request_id) {
|
||||||
const paySession = await getPaymentSession(session.payment_session_id)
|
const payRequest = await getPaymentSession(session.payment_request_id)
|
||||||
if (paySession) {
|
if (payRequest) {
|
||||||
await recordIntermediateFailure({
|
await recordIntermediateFailure({
|
||||||
paymentSessionId: session.payment_session_id,
|
paymentRequestId: session.payment_request_id,
|
||||||
customerId: session.customer_id,
|
customerId: session.customer_id,
|
||||||
causeTag,
|
causeTag,
|
||||||
amount: paySession.amount,
|
amount: payRequest.amount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -714,7 +764,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
|
|||||||
await notifyCustomer(session.customer_id, {
|
await notifyCustomer(session.customer_id, {
|
||||||
type: WsMessage.PAIRING_FAILED,
|
type: WsMessage.PAIRING_FAILED,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
payment_session_id: session.payment_session_id,
|
payment_request_id: session.payment_request_id,
|
||||||
cause_tag: causeTag,
|
cause_tag: causeTag,
|
||||||
is_terminal: false,
|
is_terminal: false,
|
||||||
})
|
})
|
||||||
@@ -736,7 +786,7 @@ export const expirePairingRequest = async (sessionId, causeTag = PairingFailureC
|
|||||||
* Targeted-request timer fired with no mitra response.
|
* Targeted-request timer fired with no mitra response.
|
||||||
*
|
*
|
||||||
* INTERMEDIATE failure: the chat_session is marked expired (the targeted attempt is over)
|
* INTERMEDIATE failure: the chat_session is marked expired (the targeted attempt is over)
|
||||||
* but the payment_session stays `confirmed` so the customer can fall back to general blast
|
* but the payment_request stays `confirmed` so the customer can fall back to general blast
|
||||||
* on the same payment, or cancel (which then terminates).
|
* on the same payment, or cancel (which then terminates).
|
||||||
*
|
*
|
||||||
* - cause_tag is targeted_mitra_timeout (audit row only)
|
* - cause_tag is targeted_mitra_timeout (audit row only)
|
||||||
@@ -747,7 +797,7 @@ export const expireTargetedPairingRequest = async (sessionId) => {
|
|||||||
UPDATE chat_sessions
|
UPDATE chat_sessions
|
||||||
SET status = ${SessionStatus.EXPIRED}
|
SET status = ${SessionStatus.EXPIRED}
|
||||||
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
|
WHERE id = ${sessionId} AND status = ${SessionStatus.PENDING_ACCEPTANCE}
|
||||||
RETURNING id, customer_id, status, payment_session_id
|
RETURNING id, customer_id, status, payment_request_id
|
||||||
`
|
`
|
||||||
if (!session) return null
|
if (!session) return null
|
||||||
|
|
||||||
@@ -764,15 +814,15 @@ export const expireTargetedPairingRequest = async (sessionId) => {
|
|||||||
WHERE session_id = ${sessionId} AND response IS NULL
|
WHERE session_id = ${sessionId} AND response IS NULL
|
||||||
`
|
`
|
||||||
|
|
||||||
if (session.payment_session_id) {
|
if (session.payment_request_id) {
|
||||||
const paySession = await getPaymentSession(session.payment_session_id)
|
const payRequest = await getPaymentSession(session.payment_request_id)
|
||||||
if (paySession) {
|
if (payRequest) {
|
||||||
await recordIntermediateFailure({
|
await recordIntermediateFailure({
|
||||||
paymentSessionId: session.payment_session_id,
|
paymentRequestId: session.payment_request_id,
|
||||||
customerId: session.customer_id,
|
customerId: session.customer_id,
|
||||||
targetedMitraId: notif?.mitra_id ?? null,
|
targetedMitraId: notif?.mitra_id ?? null,
|
||||||
causeTag: PairingFailureCause.TARGETED_MITRA_TIMEOUT,
|
causeTag: PairingFailureCause.TARGETED_MITRA_TIMEOUT,
|
||||||
amount: paySession.amount,
|
amount: payRequest.amount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -780,7 +830,7 @@ export const expireTargetedPairingRequest = async (sessionId) => {
|
|||||||
await notifyCustomer(session.customer_id, {
|
await notifyCustomer(session.customer_id, {
|
||||||
type: WsMessage.RETURNING_CHAT_TIMEOUT,
|
type: WsMessage.RETURNING_CHAT_TIMEOUT,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
payment_session_id: session.payment_session_id,
|
payment_request_id: session.payment_request_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Notify the targeted mitra that the card is no longer actionable — fan-out in parallel
|
// Notify the targeted mitra that the card is no longer actionable — fan-out in parallel
|
||||||
@@ -798,7 +848,7 @@ export const expireTargetedPairingRequest = async (sessionId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getPendingRequestsForMitra = async (mitraId) => {
|
export const getPendingRequestsForMitra = async (mitraId) => {
|
||||||
// Distinguish general blast from "Curhat lagi" returning requests via payment_session.targeted_mitra_id.
|
// Distinguish general blast from "Curhat lagi" returning requests via payment_request.targeted_mitra_id.
|
||||||
// For returning requests, surface the configured timeout so the cold-start (FCM-tap) path can render
|
// For returning requests, surface the configured timeout so the cold-start (FCM-tap) path can render
|
||||||
// the countdown overlay — same field the WS payload provides for the live path.
|
// the countdown overlay — same field the WS payload provides for the live path.
|
||||||
const rows = await sql`
|
const rows = await sql`
|
||||||
@@ -814,7 +864,7 @@ export const getPendingRequestsForMitra = async (mitraId) => {
|
|||||||
END AS request_type
|
END AS request_type
|
||||||
FROM chat_request_notifications crn
|
FROM chat_request_notifications crn
|
||||||
JOIN chat_sessions cs ON cs.id = crn.session_id
|
JOIN chat_sessions cs ON cs.id = crn.session_id
|
||||||
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
|
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
|
||||||
WHERE crn.mitra_id = ${mitraId}
|
WHERE crn.mitra_id = ${mitraId}
|
||||||
AND crn.response IS NULL
|
AND crn.response IS NULL
|
||||||
AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE}
|
AND cs.status = ${SessionStatus.PENDING_ACCEPTANCE}
|
||||||
|
|||||||
@@ -1,278 +1,518 @@
|
|||||||
|
// Phase 5: Payment service — single owner of the payment_requests table + Xendit integration.
|
||||||
|
//
|
||||||
|
// Public API surface (the future microservice contract):
|
||||||
|
//
|
||||||
|
// requestPayment({ productType, productMetadata, customerId, amount, ttlMinutes, ... })
|
||||||
|
// → inserts row, optionally creates Xendit invoice, returns row (with invoice_url if Xendit on)
|
||||||
|
//
|
||||||
|
// confirmPayment(paymentRequestId, xenditMeta = {})
|
||||||
|
// → pending → confirmed; emits 'payment_request.confirmed'
|
||||||
|
//
|
||||||
|
// expirePayment(paymentRequestId)
|
||||||
|
// → pending → expired (webhook-callable, no customer check); emits 'payment_request.expired'
|
||||||
|
//
|
||||||
|
// cancelPayment(paymentRequestId, customerId)
|
||||||
|
// → customer-initiated pending → abandoned; emits 'payment_request.cancelled'
|
||||||
|
//
|
||||||
|
// markDeliveryFailed(paymentRequestId, causeTag)
|
||||||
|
// → confirmed → failed_delivery; writes pairing_failures row; emits 'payment_request.delivery_failed'
|
||||||
|
//
|
||||||
|
// consumePayment(paymentRequestId)
|
||||||
|
// → confirmed → consumed; no event (terminal success)
|
||||||
|
//
|
||||||
|
// getPayment(id) → row or null
|
||||||
|
// getCustomerPendingPayments(customerId) → { items, total }
|
||||||
|
// expireStalePaymentRequests() → background sweeper + reconciliation
|
||||||
|
//
|
||||||
|
// on(eventName, handler) → subscribe to lifecycle events
|
||||||
|
// verifyWebhookToken(headerToken) → constant-time compare for webhook auth (used by route)
|
||||||
|
//
|
||||||
|
// registerPairingSubscriber() → wires pairing.service as a subscriber to payment_request.confirmed
|
||||||
|
// recordIntermediateFailure(...) → audit-only failure for flows with a fallback path
|
||||||
|
//
|
||||||
|
// Internals (NOT exported):
|
||||||
|
// createXenditInvoice() — wraps xendit-node SDK
|
||||||
|
// emit() / emitter — EventEmitter setup
|
||||||
|
//
|
||||||
|
// Events emit AFTER the DB transition commits. Subscribers run on the next tick
|
||||||
|
// (handlers wrapped fire-and-forget) so the publisher is never blocked.
|
||||||
|
//
|
||||||
|
// Durability story: events are in-process EventEmitter; lost on process death.
|
||||||
|
// The reconciliation sweeper (expireStalePaymentRequests) re-derives missed work
|
||||||
|
// from DB state every minute + on startup. Subscribers MUST be idempotent.
|
||||||
|
// See requirement/phase5-xendit-plan.md "Event durability" section.
|
||||||
|
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { Xendit } from 'xendit-node'
|
||||||
import { getDb } from '../db/client.js'
|
import { getDb } from '../db/client.js'
|
||||||
import { PaymentSessionStatus, PairingFailureCause, UserType, WsMessage, SessionMode } from '../constants.js'
|
import { PaymentRequestStatus, PairingFailureCause, UserType, WsMessage, SessionMode } from '../constants.js'
|
||||||
import { recordFailure } from './pairing-failure.service.js'
|
import { recordFailure } from './pairing-failure.service.js'
|
||||||
import { sendToUser } from '../plugins/websocket.js'
|
import { sendToUser } from '../plugins/websocket.js'
|
||||||
import { sendPushNotification } from './notification.service.js'
|
import { sendPushNotification } from './notification.service.js'
|
||||||
import { getPaymentSessionTimeoutMinutes as readPaymentSessionTimeoutMinutes } from './config.service.js'
|
import {
|
||||||
|
getPaymentRequestTimeoutMinutes as readPaymentRequestTimeoutMinutes,
|
||||||
|
getXenditConfig,
|
||||||
|
} from './config.service.js'
|
||||||
|
|
||||||
const sql = getDb()
|
const sql = getDb()
|
||||||
|
|
||||||
const getPaymentSessionTimeoutMinutes = async () => {
|
// --- EventEmitter setup ---
|
||||||
const { payment_session_timeout_minutes } = await readPaymentSessionTimeoutMinutes()
|
|
||||||
return payment_session_timeout_minutes
|
const emitter = new EventEmitter()
|
||||||
|
// Bump default 10-listener cap so future product subscribers don't trigger the leak warning
|
||||||
|
emitter.setMaxListeners(50)
|
||||||
|
|
||||||
|
export const on = (eventName, handler) => {
|
||||||
|
emitter.on(eventName, (payload) => {
|
||||||
|
// Wrap every handler so an unhandled throw doesn't kill the process AND so handlers
|
||||||
|
// run async-fire-and-forget (don't block the publisher's emit() return). Errors are
|
||||||
|
// logged; recovery is the sweeper's job.
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => handler(payload))
|
||||||
|
.catch((err) => console.error(`[payment event ${eventName}] handler failed`, err))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emit = (eventName, payload) => emitter.emit(eventName, payload)
|
||||||
|
|
||||||
|
// --- Internal Xendit client (lazy + re-creatable for test env stubbing) ---
|
||||||
|
|
||||||
|
let _xenditClient = null
|
||||||
|
let _xenditKey = null
|
||||||
|
const xenditClient = () => {
|
||||||
|
const { secretKey } = getXenditConfig()
|
||||||
|
if (_xenditClient && _xenditKey === secretKey) return _xenditClient
|
||||||
|
_xenditClient = new Xendit({ secretKey })
|
||||||
|
_xenditKey = secretKey
|
||||||
|
return _xenditClient
|
||||||
|
}
|
||||||
|
|
||||||
|
const createXenditInvoice = async ({ paymentRequestId, amount, ttlMinutes, description }) => {
|
||||||
|
const { successRedirectUrl, failureRedirectUrl } = getXenditConfig()
|
||||||
|
const inv = await xenditClient().Invoice.createInvoice({
|
||||||
|
data: {
|
||||||
|
externalId: paymentRequestId, // D4 — our UUID is the Xendit external_id
|
||||||
|
amount,
|
||||||
|
description,
|
||||||
|
invoiceDuration: Math.floor(ttlMinutes * 60), // D5 — TTL mirrors session timeout
|
||||||
|
currency: 'IDR',
|
||||||
|
successRedirectUrl: successRedirectUrl || undefined,
|
||||||
|
failureRedirectUrl: failureRedirectUrl || undefined,
|
||||||
|
// paymentMethods omitted → honor dashboard config (operator picks methods without a deploy)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { invoiceId: inv.id, invoiceUrl: inv.invoiceUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used by the webhook route to authenticate Xendit's x-callback-token header.
|
||||||
|
export const verifyWebhookToken = (headerToken) => {
|
||||||
|
const { webhookToken } = getXenditConfig()
|
||||||
|
if (!headerToken || !webhookToken) return false
|
||||||
|
if (typeof headerToken !== 'string') return false
|
||||||
|
if (headerToken.length !== webhookToken.length) return false
|
||||||
|
let mismatch = 0
|
||||||
|
for (let i = 0; i < headerToken.length; i++) {
|
||||||
|
mismatch |= headerToken.charCodeAt(i) ^ webhookToken.charCodeAt(i)
|
||||||
|
}
|
||||||
|
return mismatch === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test-only — drop cached client so vi.stubEnv changes take effect.
|
||||||
|
export const _resetXenditClientForTest = () => {
|
||||||
|
_xenditClient = null
|
||||||
|
_xenditKey = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
const getPaymentRequestTimeoutMinutes = async () => {
|
||||||
|
const { payment_request_timeout_minutes } = await readPaymentRequestTimeoutMinutes()
|
||||||
|
return payment_request_timeout_minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEventPayload = (row) => ({
|
||||||
|
paymentRequestId: row.id,
|
||||||
|
productType: row.product_type ?? 'chat_session',
|
||||||
|
productMetadata: row.product_metadata ?? {},
|
||||||
|
customerId: row.customer_id,
|
||||||
|
amount: row.amount,
|
||||||
|
xenditInvoiceId: row.xendit_invoice_id ?? null,
|
||||||
|
xenditPaymentMethod: row.xendit_payment_method ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildInvoiceDescription = (row) => {
|
||||||
|
if (row.product_type === 'chat_session') {
|
||||||
|
return row.is_extension
|
||||||
|
? `Perpanjangan sesi ${row.duration_minutes} menit`
|
||||||
|
: `Sesi ${row.duration_minutes} menit`
|
||||||
|
}
|
||||||
|
// Generic fallback — future products can build their own descriptions and pass via productMetadata
|
||||||
|
return row.product_metadata?.description ?? `Pembayaran ${row.product_type}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- requestPayment: insert pending row + (if Xendit on) mint invoice ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new payment session in `pending` status.
|
* Create a new payment request in `pending` status. When XENDIT_ENABLED=true, also
|
||||||
* Reads `payment_session_timeout_minutes` from config to compute expires_at.
|
* creates a Xendit Invoice and stamps invoice_id + invoice_url on the row.
|
||||||
*
|
*
|
||||||
* Phase 4: `isFirstSessionDiscount` replaces the old `isFreeTrial` flag. Voice-call
|
* Product-agnostic: callers stamp `productType` + `productMetadata`. The legacy
|
||||||
* mode is a routing/badge thing — the price comes from the call tier group, not from
|
* top-level chat-specific args (durationMinutes, mode, isExtension, targetedMitraId,
|
||||||
* the mode itself.
|
* isFirstSessionDiscount) are accepted for backward compat with existing chat code,
|
||||||
|
* and also written into product_metadata when productType === 'chat_session'.
|
||||||
*/
|
*/
|
||||||
export const createPaymentSession = async ({
|
export const requestPayment = async ({
|
||||||
|
productType = 'chat_session',
|
||||||
|
productMetadata = {},
|
||||||
customerId,
|
customerId,
|
||||||
durationMinutes,
|
|
||||||
amount,
|
amount,
|
||||||
|
ttlMinutes,
|
||||||
|
// Chat-specific legacy fields (still written to top-level columns for now)
|
||||||
|
durationMinutes,
|
||||||
|
mode = SessionMode.CHAT,
|
||||||
isFirstSessionDiscount = false,
|
isFirstSessionDiscount = false,
|
||||||
isExtension = false,
|
isExtension = false,
|
||||||
targetedMitraId = null,
|
targetedMitraId = null,
|
||||||
mode = SessionMode.CHAT,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
throw Object.assign(new Error('customerId is required'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
||||||
}
|
}
|
||||||
if (typeof durationMinutes !== 'number' || durationMinutes <= 0) {
|
|
||||||
throw Object.assign(new Error('durationMinutes must be a positive number'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
|
||||||
}
|
|
||||||
if (typeof amount !== 'number' || amount < 0) {
|
if (typeof amount !== 'number' || amount < 0) {
|
||||||
throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
throw Object.assign(new Error('amount must be a non-negative number'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
||||||
}
|
}
|
||||||
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
|
if (productType === 'chat_session') {
|
||||||
throw Object.assign(new Error('mode must be chat or call'), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
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`
|
const [row] = await sql`
|
||||||
INSERT INTO payment_sessions (
|
INSERT INTO payment_requests (
|
||||||
customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
||||||
status, targeted_mitra_id, mode, expires_at
|
status, targeted_mitra_id, mode, expires_at, product_type, product_metadata
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
${customerId}, ${amount}, ${durationMinutes}, ${isFirstSessionDiscount}, ${isExtension},
|
${customerId}, ${amount}, ${durationMinutes ?? 0}, ${isFirstSessionDiscount}, ${isExtension},
|
||||||
${PaymentSessionStatus.PENDING}, ${targetedMitraId}, ${mode},
|
${PaymentRequestStatus.PENDING}, ${targetedMitraId}, ${mode},
|
||||||
NOW() + (${ttlMinutes} || ' minutes')::interval
|
NOW() + (${ttl} || ' minutes')::interval,
|
||||||
|
${productType}, ${sql.json(meta)}
|
||||||
)
|
)
|
||||||
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
RETURNING *
|
||||||
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// If Xendit is on, create the invoice + stamp the row. If creation fails, mark
|
||||||
|
// the row as `failed` (NOT `expired` — distinct: "we never got an invoice at all"
|
||||||
|
// vs. "TTL elapsed unpaid") and surface a 502 to the caller.
|
||||||
|
const xc = getXenditConfig()
|
||||||
|
if (xc.enabled) {
|
||||||
|
try {
|
||||||
|
const { invoiceId, invoiceUrl } = await createXenditInvoice({
|
||||||
|
paymentRequestId: row.id,
|
||||||
|
amount: row.amount,
|
||||||
|
ttlMinutes: ttl,
|
||||||
|
description: buildInvoiceDescription(row),
|
||||||
|
})
|
||||||
|
await sql`
|
||||||
|
UPDATE payment_requests
|
||||||
|
SET xendit_invoice_id = ${invoiceId}, xendit_invoice_url = ${invoiceUrl}
|
||||||
|
WHERE id = ${row.id}
|
||||||
|
`
|
||||||
|
row.xendit_invoice_id = invoiceId
|
||||||
|
row.xendit_invoice_url = invoiceUrl
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[xendit] createInvoice failed; marking payment_request failed', { paymentRequestId: row.id, err })
|
||||||
|
const [failed] = await sql`
|
||||||
|
UPDATE payment_requests
|
||||||
|
SET status = ${PaymentRequestStatus.FAILED}
|
||||||
|
WHERE id = ${row.id} AND status = ${PaymentRequestStatus.PENDING}
|
||||||
|
RETURNING *
|
||||||
|
`
|
||||||
|
if (failed) emit('payment_request.failed', buildEventPayload(failed))
|
||||||
|
throw Object.assign(new Error('Payment provider error'), {
|
||||||
|
code: 'PAYMENT_PROVIDER_ERROR',
|
||||||
|
statusCode: 502,
|
||||||
|
cause: err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- confirmPayment ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transition pending → confirmed. Throws on ownership/status/expiry mismatch.
|
* Transition pending → confirmed. Customer-facing callers (the legacy
|
||||||
|
* /payment-requests/:id/confirm route) verify customer ownership themselves before
|
||||||
|
* calling. Webhook caller does not check ownership (Xendit's authority).
|
||||||
|
*
|
||||||
|
* Optional xenditMeta stamps payment-time data from the webhook body:
|
||||||
|
* { invoiceId, paymentMethod, amount }
|
||||||
|
*
|
||||||
|
* Throws on missing row / wrong status / expiry. Webhook handler swallows
|
||||||
|
* INVALID_STATE (already confirmed) and EXPIRED (raced sweeper) and ACKs.
|
||||||
|
*
|
||||||
|
* Emits 'payment_request.confirmed' after commit.
|
||||||
*/
|
*/
|
||||||
export const confirmPaymentSession = async (paymentSessionId, customerId) => {
|
export const confirmPayment = async (paymentRequestId, xenditMeta = {}) => {
|
||||||
const [existing] = await sql`
|
const [existing] = await sql`
|
||||||
SELECT id, customer_id, status, expires_at
|
SELECT id, customer_id, status, expires_at
|
||||||
FROM payment_sessions
|
FROM payment_requests
|
||||||
WHERE id = ${paymentSessionId}
|
WHERE id = ${paymentRequestId}
|
||||||
`
|
`
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
||||||
}
|
}
|
||||||
if (existing.customer_id !== customerId) {
|
if (existing.status !== PaymentRequestStatus.PENDING) {
|
||||||
throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
|
throw Object.assign(new Error(`Payment request is ${existing.status}, cannot confirm`), {
|
||||||
}
|
|
||||||
if (existing.status !== PaymentSessionStatus.PENDING) {
|
|
||||||
throw Object.assign(new Error(`Payment session is ${existing.status}, cannot confirm`), {
|
|
||||||
code: 'INVALID_STATE', statusCode: 409,
|
code: 'INVALID_STATE', statusCode: 409,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (new Date(existing.expires_at) <= new Date()) {
|
if (new Date(existing.expires_at) <= new Date()) {
|
||||||
// Inline expiry check in addition to the background sweeper, since the customer can
|
// Inline expiry check (sweeper hasn't run yet)
|
||||||
// attempt to confirm a row that's already past expires_at before the sweep runs.
|
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE payment_sessions SET status = ${PaymentSessionStatus.EXPIRED}
|
UPDATE payment_requests SET status = ${PaymentRequestStatus.EXPIRED}
|
||||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
|
||||||
`
|
`
|
||||||
throw Object.assign(new Error('Payment session has expired'), { code: 'EXPIRED', statusCode: 409 })
|
throw Object.assign(new Error('Payment request has expired'), { code: 'EXPIRED', statusCode: 409 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await sql`
|
const [updated] = await sql`
|
||||||
UPDATE payment_sessions
|
UPDATE payment_requests
|
||||||
SET status = ${PaymentSessionStatus.CONFIRMED}, confirmed_at = NOW()
|
SET status = ${PaymentRequestStatus.CONFIRMED},
|
||||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
|
confirmed_at = NOW(),
|
||||||
RETURNING id, customer_id, amount, duration_minutes, is_first_session_discount, is_extension,
|
xendit_invoice_id = COALESCE(${xenditMeta.invoiceId ?? null}, xendit_invoice_id),
|
||||||
mode, status, targeted_mitra_id, created_at, confirmed_at, consumed_at, expires_at
|
xendit_payment_method = COALESCE(${xenditMeta.paymentMethod ?? null}, xendit_payment_method),
|
||||||
|
xendit_paid_amount = COALESCE(${xenditMeta.amount ?? null}, xendit_paid_amount)
|
||||||
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
|
||||||
|
RETURNING *
|
||||||
`
|
`
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
throw Object.assign(new Error('Payment session state changed during confirm'), { code: 'CONFLICT', statusCode: 409 })
|
throw Object.assign(new Error('Payment request state changed during confirm'), { code: 'CONFLICT', statusCode: 409 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit('payment_request.confirmed', buildEventPayload(updated))
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Customer-facing wrapper used by the legacy /payment-requests/:id/confirm route
|
||||||
* Transition confirmed → consumed. Called from pairing service when a chat starts.
|
// (kept only for dev/Maestro — production gates the route on XENDIT_ENABLED).
|
||||||
* Idempotent at higher level (caller should check status first if it matters).
|
// Verifies customer ownership before delegating to the internal confirmPayment.
|
||||||
*/
|
export const confirmPaymentForCustomer = async (paymentRequestId, customerId) => {
|
||||||
export const consumePaymentSession = async (paymentSessionId) => {
|
const [existing] = await sql`SELECT customer_id FROM payment_requests WHERE id = ${paymentRequestId}`
|
||||||
const [updated] = await sql`
|
if (!existing) {
|
||||||
UPDATE payment_sessions
|
throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
||||||
SET status = ${PaymentSessionStatus.CONSUMED}, consumed_at = NOW()
|
}
|
||||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED}
|
if (existing.customer_id !== customerId) {
|
||||||
RETURNING id, status, consumed_at
|
throw Object.assign(new Error('Payment request does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
|
||||||
`
|
}
|
||||||
return updated || null
|
return confirmPayment(paymentRequestId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- expirePayment (webhook-callable; no ownership check) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TERMINAL: mark a confirmed payment session as failed_pairing AND write a pairing_failures row.
|
* Transition pending → expired. Called by the Xendit EXPIRED webhook handler
|
||||||
* Idempotent: no-op if already terminal (consumed/failed_pairing/expired/abandoned).
|
* and by the background sweeper. Idempotent — if already terminal, no-op.
|
||||||
*
|
* Emits 'payment_request.expired' if a transition occurred.
|
||||||
* Use only for true terminal failures (no fallback path possible):
|
|
||||||
* - general blast exhausted, no acceptance
|
|
||||||
* - all blasted mitras explicitly rejected
|
|
||||||
* - customer cancels mid-search
|
|
||||||
* - payment session expires before consumption
|
|
||||||
*
|
|
||||||
* For intermediate failures that have a fallback CTA available (targeted-mitra reject/timeout
|
|
||||||
* during a returning-chat flow), use `recordIntermediateFailure` instead — that writes the
|
|
||||||
* audit row WITHOUT terminating the payment session. Termination is the caller's decision
|
|
||||||
* (cancel CTA = terminal, fallback-to-blast CTA = stays confirmed).
|
|
||||||
*/
|
*/
|
||||||
export const failPaymentSession = async (paymentSessionId, causeTag) => {
|
export const expirePayment = async (paymentRequestId) => {
|
||||||
|
const [updated] = await sql`
|
||||||
|
UPDATE payment_requests
|
||||||
|
SET status = ${PaymentRequestStatus.EXPIRED}
|
||||||
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
|
||||||
|
RETURNING *
|
||||||
|
`
|
||||||
|
if (updated) emit('payment_request.expired', buildEventPayload(updated))
|
||||||
|
return updated ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- consumePayment ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transition confirmed → consumed. Called by product code (pairing service for chat)
|
||||||
|
* after successful delivery. No event — terminal success.
|
||||||
|
*/
|
||||||
|
export const consumePayment = async (paymentRequestId) => {
|
||||||
|
const [updated] = await sql`
|
||||||
|
UPDATE payment_requests
|
||||||
|
SET status = ${PaymentRequestStatus.CONSUMED}, consumed_at = NOW()
|
||||||
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.CONFIRMED}
|
||||||
|
RETURNING id, status, consumed_at
|
||||||
|
`
|
||||||
|
return updated ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- markDeliveryFailed ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TERMINAL: mark a confirmed payment as failed_delivery + write a pairing_failures
|
||||||
|
* audit row. Idempotent: no-op if not currently confirmed.
|
||||||
|
*
|
||||||
|
* Use only when no fallback is possible (no mitra available, all rejected, etc.).
|
||||||
|
* For intermediate failures with a fallback (targeted reject during returning-chat),
|
||||||
|
* use recordIntermediateFailure() which keeps the payment confirmed.
|
||||||
|
*
|
||||||
|
* Emits 'payment_request.delivery_failed' on transition.
|
||||||
|
*/
|
||||||
|
export const markDeliveryFailed = async (paymentRequestId, causeTag) => {
|
||||||
if (!Object.values(PairingFailureCause).includes(causeTag)) {
|
if (!Object.values(PairingFailureCause).includes(causeTag)) {
|
||||||
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const [existing] = await sql`
|
const [existing] = await sql`
|
||||||
SELECT id, customer_id, targeted_mitra_id, amount, status
|
SELECT id, customer_id, targeted_mitra_id, amount, status, product_type, product_metadata, xendit_invoice_id, xendit_payment_method
|
||||||
FROM payment_sessions
|
FROM payment_requests
|
||||||
WHERE id = ${paymentSessionId}
|
WHERE id = ${paymentRequestId}
|
||||||
`
|
`
|
||||||
if (!existing) {
|
if (!existing) return null
|
||||||
return null
|
if (existing.status !== PaymentRequestStatus.CONFIRMED) return existing // idempotent
|
||||||
}
|
|
||||||
// Idempotent: only confirmed sessions transition to failed_pairing here.
|
|
||||||
// Pending sessions become expired/abandoned via their own paths.
|
|
||||||
if (existing.status !== PaymentSessionStatus.CONFIRMED) {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await sql`
|
const [updated] = await sql`
|
||||||
UPDATE payment_sessions
|
UPDATE payment_requests
|
||||||
SET status = ${PaymentSessionStatus.FAILED_PAIRING}
|
SET status = ${PaymentRequestStatus.FAILED_DELIVERY}
|
||||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.CONFIRMED}
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.CONFIRMED}
|
||||||
RETURNING id, customer_id, targeted_mitra_id, amount, status
|
RETURNING *
|
||||||
`
|
`
|
||||||
if (!updated) {
|
if (!updated) return existing
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
await recordFailure({
|
await recordFailure({
|
||||||
paymentSessionId,
|
paymentRequestId,
|
||||||
customerId: existing.customer_id,
|
customerId: existing.customer_id,
|
||||||
targetedMitraId: existing.targeted_mitra_id,
|
targetedMitraId: existing.targeted_mitra_id,
|
||||||
causeTag,
|
causeTag,
|
||||||
amount: existing.amount,
|
amount: existing.amount,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
emit('payment_request.delivery_failed', { ...buildEventPayload(updated), causeTag })
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backward-compat alias — pairing.service still calls failPaymentSession by name.
|
||||||
|
// TODO follow-up phase: rename call sites and drop this alias.
|
||||||
|
export const failPaymentSession = markDeliveryFailed
|
||||||
|
|
||||||
|
// --- recordIntermediateFailure (audit only; doesn't terminate) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* INTERMEDIATE: write a pairing_failures audit row WITHOUT terminating the payment session.
|
* INTERMEDIATE: write a pairing_failures audit row WITHOUT terminating the payment.
|
||||||
|
* Used inside flows with a fallback path (targeted "Curhat lagi" reject can fall back
|
||||||
|
* to general blast on the same payment). One payment_request may have many audit rows.
|
||||||
*
|
*
|
||||||
* Used for failures inside a flow that still has a fallback path: targeted "Curhat lagi"
|
* Returns the inserted pairing_failures row, or null if the payment is missing.
|
||||||
* reject/timeout (customer can fall back to general blast on the same payment), or any
|
|
||||||
* other intermediate attempt where the payment_session must remain `confirmed` so it can be
|
|
||||||
* reused.
|
|
||||||
*
|
|
||||||
* One payment_session may FK from multiple pairing_failures rows — that's the desired CC
|
|
||||||
* UX (each attempt lists as its own row in the Failed Pairings table).
|
|
||||||
*
|
|
||||||
* Returns the inserted pairing_failures row, or null if the payment session was missing.
|
|
||||||
*/
|
*/
|
||||||
export const recordIntermediateFailure = async ({ paymentSessionId, customerId, targetedMitraId = null, causeTag, amount }) => {
|
export const recordIntermediateFailure = async ({
|
||||||
|
paymentRequestId,
|
||||||
|
customerId,
|
||||||
|
targetedMitraId = null,
|
||||||
|
causeTag,
|
||||||
|
amount,
|
||||||
|
}) => {
|
||||||
if (!Object.values(PairingFailureCause).includes(causeTag)) {
|
if (!Object.values(PairingFailureCause).includes(causeTag)) {
|
||||||
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
throw Object.assign(new Error(`Unknown cause_tag: ${causeTag}`), { code: 'VALIDATION_ERROR', statusCode: 422 })
|
||||||
}
|
}
|
||||||
|
const [existing] = await sql`SELECT id FROM payment_requests WHERE id = ${paymentRequestId}`
|
||||||
// Loose sanity: the row should exist. If not, fall through with null — caller likely
|
|
||||||
// already moved on; we'd rather skip the audit than throw mid-callback.
|
|
||||||
const [existing] = await sql`SELECT id FROM payment_sessions WHERE id = ${paymentSessionId}`
|
|
||||||
if (!existing) return null
|
if (!existing) return null
|
||||||
|
return recordFailure({ paymentRequestId, customerId, targetedMitraId, causeTag, amount })
|
||||||
return recordFailure({
|
|
||||||
paymentSessionId,
|
|
||||||
customerId,
|
|
||||||
targetedMitraId,
|
|
||||||
causeTag,
|
|
||||||
amount,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- cancelPayment (customer-initiated abandonment) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Customer-initiated abandonment of a `pending` payment session (e.g. closed payment screen).
|
* Customer-initiated abandonment of a pending payment (closed payment screen).
|
||||||
* No pairing_failures row is written — only confirmed-but-no-chat counts as a pairing failure.
|
* No pairing_failures row (no money moved). Idempotent.
|
||||||
|
*
|
||||||
|
* Emits 'payment_request.cancelled' on transition.
|
||||||
*/
|
*/
|
||||||
export const abandonPaymentSession = async (paymentSessionId, customerId) => {
|
export const cancelPayment = async (paymentRequestId, customerId) => {
|
||||||
const [existing] = await sql`
|
const [existing] = await sql`
|
||||||
SELECT id, customer_id, status FROM payment_sessions WHERE id = ${paymentSessionId}
|
SELECT id, customer_id, status FROM payment_requests WHERE id = ${paymentRequestId}
|
||||||
`
|
`
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw Object.assign(new Error('Payment session not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
throw Object.assign(new Error('Payment request not found'), { code: 'NOT_FOUND', statusCode: 404 })
|
||||||
}
|
}
|
||||||
if (existing.customer_id !== customerId) {
|
if (existing.customer_id !== customerId) {
|
||||||
throw Object.assign(new Error('Payment session does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
|
throw Object.assign(new Error('Payment request does not belong to this customer'), { code: 'FORBIDDEN', statusCode: 403 })
|
||||||
}
|
}
|
||||||
if (existing.status !== PaymentSessionStatus.PENDING) {
|
if (existing.status !== PaymentRequestStatus.PENDING) {
|
||||||
// Idempotent — already terminal.
|
return existing // idempotent
|
||||||
return existing
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await sql`
|
const [updated] = await sql`
|
||||||
UPDATE payment_sessions SET status = ${PaymentSessionStatus.ABANDONED}
|
UPDATE payment_requests SET status = ${PaymentRequestStatus.ABANDONED}
|
||||||
WHERE id = ${paymentSessionId} AND status = ${PaymentSessionStatus.PENDING}
|
WHERE id = ${paymentRequestId} AND status = ${PaymentRequestStatus.PENDING}
|
||||||
RETURNING id, customer_id, status
|
RETURNING *
|
||||||
`
|
`
|
||||||
return updated || existing
|
if (updated) emit('payment_request.cancelled', buildEventPayload(updated))
|
||||||
|
return updated ?? existing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Background sweeper + reconciliation ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Background sweeper:
|
* Runs every 60s from server.js + once at startup.
|
||||||
* - pending rows past expires_at → expired (no failure row; never confirmed)
|
*
|
||||||
* - confirmed rows past expires_at AND not consumed → failed_pairing with cause = payment_session_expired
|
* Three jobs:
|
||||||
|
* 1. Pending past expires_at → expired (no failure row; emits .expired)
|
||||||
|
* 2. Confirmed past expires_at AND not consumed → failed_delivery (writes pairing_failures, emits .delivery_failed)
|
||||||
|
* 3. Confirmed-with-no-chat-session-yet → re-invoke pairing subscriber (lost-event recovery)
|
||||||
|
*
|
||||||
|
* Job 3 is the durability backstop — if a process restart lost the in-process
|
||||||
|
* EventEmitter notification before pairing started, the sweeper re-triggers it
|
||||||
|
* on the next tick. Subscribers MUST be idempotent (the pairing subscriber checks
|
||||||
|
* "chat_sessions WHERE payment_request_id = ?" before doing work).
|
||||||
*/
|
*/
|
||||||
export const expireStalePaymentSessions = async () => {
|
export const expireStalePaymentRequests = async () => {
|
||||||
// 1) pending → expired
|
// 1) pending → expired (emit event for each)
|
||||||
const expired = await sql`
|
const expired = await sql`
|
||||||
UPDATE payment_sessions
|
UPDATE payment_requests
|
||||||
SET status = ${PaymentSessionStatus.EXPIRED}
|
SET status = ${PaymentRequestStatus.EXPIRED}
|
||||||
WHERE status = ${PaymentSessionStatus.PENDING}
|
WHERE status = ${PaymentRequestStatus.PENDING}
|
||||||
AND expires_at <= NOW()
|
AND expires_at <= NOW()
|
||||||
RETURNING id
|
RETURNING *
|
||||||
`
|
`
|
||||||
|
for (const row of expired) emit('payment_request.expired', buildEventPayload(row))
|
||||||
|
|
||||||
// 2) confirmed-but-stale → failed_pairing. Single atomic UPDATE returns the rows we
|
// 2) confirmed-but-stale → failed_delivery
|
||||||
// actually flipped (vs. the old SELECT + per-row UPDATE which leaked a TOCTOU window
|
|
||||||
// with concurrent confirmPaymentSession/consumePaymentSession). Audit-row writes and
|
|
||||||
// customer notifications then fan out in parallel.
|
|
||||||
const flipped = await sql`
|
const flipped = await sql`
|
||||||
UPDATE payment_sessions
|
UPDATE payment_requests
|
||||||
SET status = ${PaymentSessionStatus.FAILED_PAIRING}
|
SET status = ${PaymentRequestStatus.FAILED_DELIVERY}
|
||||||
WHERE status = ${PaymentSessionStatus.CONFIRMED}
|
WHERE status = ${PaymentRequestStatus.CONFIRMED}
|
||||||
AND expires_at <= NOW()
|
AND expires_at <= NOW()
|
||||||
RETURNING id, customer_id, targeted_mitra_id, amount
|
RETURNING *
|
||||||
`
|
`
|
||||||
|
|
||||||
await Promise.all(flipped.map(async (row) => {
|
await Promise.all(flipped.map(async (row) => {
|
||||||
await recordFailure({
|
await recordFailure({
|
||||||
paymentSessionId: row.id,
|
paymentRequestId: row.id,
|
||||||
customerId: row.customer_id,
|
customerId: row.customer_id,
|
||||||
targetedMitraId: row.targeted_mitra_id,
|
targetedMitraId: row.targeted_mitra_id,
|
||||||
causeTag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
|
causeTag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
|
||||||
amount: row.amount,
|
amount: row.amount,
|
||||||
})
|
})
|
||||||
// Customer may be on searching/waiting; push terminal PAIRING_FAILED in real time.
|
// Customer-facing: push terminal PAIRING_FAILED via WS; FCM fallback if not connected.
|
||||||
// FCM fallback when not WS-connected so they're notified at the OS level.
|
|
||||||
try {
|
try {
|
||||||
const wsSent = sendToUser(UserType.CUSTOMER, row.customer_id, {
|
const wsSent = sendToUser(UserType.CUSTOMER, row.customer_id, {
|
||||||
type: WsMessage.PAIRING_FAILED,
|
type: WsMessage.PAIRING_FAILED,
|
||||||
payment_session_id: row.id,
|
payment_request_id: row.id,
|
||||||
cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
|
cause_tag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
|
||||||
is_terminal: true,
|
is_terminal: true,
|
||||||
})
|
})
|
||||||
if (!wsSent) {
|
if (!wsSent) {
|
||||||
@@ -281,43 +521,73 @@ export const expireStalePaymentSessions = async () => {
|
|||||||
body: 'Sesi pembayaranmu telah berakhir. Silakan mulai ulang.',
|
body: 'Sesi pembayaranmu telah berakhir. Silakan mulai ulang.',
|
||||||
data: {
|
data: {
|
||||||
type: WsMessage.PAIRING_FAILED,
|
type: WsMessage.PAIRING_FAILED,
|
||||||
payment_session_id: row.id,
|
payment_request_id: row.id,
|
||||||
cause_tag: PairingFailureCause.PAYMENT_SESSION_EXPIRED,
|
cause_tag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('expireStalePaymentSessions: failed to notify customer', {
|
console.error('expireStalePaymentRequests: failed to notify customer', {
|
||||||
paymentSessionId: row.id, customerId: row.customer_id, err,
|
paymentRequestId: row.id, customerId: row.customer_id, err,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
emit('payment_request.delivery_failed', {
|
||||||
|
...buildEventPayload(row),
|
||||||
|
causeTag: PairingFailureCause.PAYMENT_REQUEST_EXPIRED,
|
||||||
|
})
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return { expired: expired.length, failed: flipped.length }
|
// 3) Reconciliation — confirmed payments that should have started product work but didn't.
|
||||||
|
// For chat_session: no chat_sessions row exists AND no pairing_failures row exists.
|
||||||
|
// The 30-second buffer avoids racing with happy-path subscribers mid-flight.
|
||||||
|
const orphans = await sql`
|
||||||
|
SELECT *
|
||||||
|
FROM payment_requests pr
|
||||||
|
WHERE pr.status = ${PaymentRequestStatus.CONFIRMED}
|
||||||
|
AND pr.confirmed_at < NOW() - INTERVAL '30 seconds'
|
||||||
|
AND pr.product_type = 'chat_session'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM chat_sessions cs WHERE cs.payment_request_id = pr.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM pairing_failures pf WHERE pf.payment_request_id = pr.id)
|
||||||
|
LIMIT 100
|
||||||
|
`
|
||||||
|
let reconciled = 0
|
||||||
|
for (const row of orphans) {
|
||||||
|
try {
|
||||||
|
// Re-emit the event the subscriber missed. Subscriber's idempotency check
|
||||||
|
// makes this safe even if the subscriber did actually run (just slow).
|
||||||
|
emit('payment_request.confirmed', buildEventPayload(row))
|
||||||
|
reconciled++
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[reconciler] re-emit failed', { paymentRequestId: row.id, err })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { expired: expired.length, failed: flipped.length, reconciled }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getPaymentSession = async (id) => {
|
// Backward-compat alias — server.js previously called expireStalePaymentSessions
|
||||||
const [row] = await sql`
|
export const expireStalePaymentSessions = expireStalePaymentRequests
|
||||||
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
|
// --- getPayment ---
|
||||||
FROM payment_sessions
|
|
||||||
WHERE id = ${id}
|
export const getPayment = async (id) => {
|
||||||
`
|
const [row] = await sql`SELECT * FROM payment_requests WHERE id = ${id}`
|
||||||
return row || null
|
return row ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backward-compat aliases (legacy callers — to be migrated in a follow-up pass)
|
||||||
|
export const createPaymentSession = (args) => requestPayment(args)
|
||||||
|
export const confirmPaymentSession = confirmPaymentForCustomer
|
||||||
|
export const consumePaymentSession = consumePayment
|
||||||
|
export const abandonPaymentSession = cancelPayment
|
||||||
|
export const getPaymentSession = getPayment
|
||||||
|
|
||||||
|
// --- getCustomerPendingPayments (unchanged shape) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 4 Stage 10 — Chat Tab Pembayaran feed.
|
* Phase 4 Stage 10 — Chat Tab Pembayaran feed.
|
||||||
*
|
* Returns the customer's pending payment requests (initial + extension) that
|
||||||
* Returns the customer's pending payment sessions (initial + extension) that
|
* haven't paid AND haven't expired.
|
||||||
* haven't paid AND haven't expired. The `expires_at > NOW()` filter is
|
|
||||||
* defensive: the background sweeper flips stale pending rows to `expired`,
|
|
||||||
* but rows can be stale between sweeps, so we filter inline too.
|
|
||||||
*
|
|
||||||
* Extension rows resolve mitra info via session_extensions → chat_sessions →
|
|
||||||
* mitras. Initial rows fall back to `payment_sessions.targeted_mitra_id`
|
|
||||||
* (set for targeted "Curhat lagi" flows); for general-blast initial rows
|
|
||||||
* the mitra is unknown until pairing succeeds, so mitra fields are null.
|
|
||||||
*/
|
*/
|
||||||
export const getCustomerPendingPayments = async (customerId) => {
|
export const getCustomerPendingPayments = async (customerId) => {
|
||||||
const items = await sql`
|
const items = await sql`
|
||||||
@@ -331,15 +601,37 @@ export const getCustomerPendingPayments = async (customerId) => {
|
|||||||
ps.expires_at,
|
ps.expires_at,
|
||||||
COALESCE(ext_m.id, tgt_m.id) AS mitra_id,
|
COALESCE(ext_m.id, tgt_m.id) AS mitra_id,
|
||||||
COALESCE(ext_m.display_name, tgt_m.display_name) AS mitra_display_name
|
COALESCE(ext_m.display_name, tgt_m.display_name) AS mitra_display_name
|
||||||
FROM payment_sessions ps
|
FROM payment_requests ps
|
||||||
LEFT JOIN session_extensions se ON se.payment_session_id = ps.id
|
LEFT JOIN session_extensions se ON se.payment_request_id = ps.id
|
||||||
LEFT JOIN chat_sessions cs ON cs.id = se.session_id
|
LEFT JOIN chat_sessions cs ON cs.id = se.session_id
|
||||||
LEFT JOIN mitras ext_m ON ext_m.id = cs.mitra_id
|
LEFT JOIN mitras ext_m ON ext_m.id = cs.mitra_id
|
||||||
LEFT JOIN mitras tgt_m ON tgt_m.id = ps.targeted_mitra_id
|
LEFT JOIN mitras tgt_m ON tgt_m.id = ps.targeted_mitra_id
|
||||||
WHERE ps.customer_id = ${customerId}
|
WHERE ps.customer_id = ${customerId}
|
||||||
AND ps.status = ${PaymentSessionStatus.PENDING}
|
AND ps.status = ${PaymentRequestStatus.PENDING}
|
||||||
AND ps.expires_at > NOW()
|
AND ps.expires_at > NOW()
|
||||||
ORDER BY ps.created_at DESC
|
ORDER BY ps.created_at DESC
|
||||||
`
|
`
|
||||||
return { items, total: items.length }
|
return { items, total: items.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Pairing subscriber wiring (called from server.js at startup) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires pairing.service as a subscriber to payment_request.confirmed.
|
||||||
|
* Filters on productType so future product subscribers can coexist.
|
||||||
|
* Subscriber implements its own idempotency (skip if chat_sessions row exists).
|
||||||
|
*
|
||||||
|
* Imported lazily to avoid a circular import: pairing.service imports payment.service
|
||||||
|
* for failPaymentSession + consumePayment + getPayment.
|
||||||
|
*/
|
||||||
|
export const registerPairingSubscriber = () => {
|
||||||
|
on('payment_request.confirmed', async (evt) => {
|
||||||
|
if (evt.productType !== 'chat_session') return
|
||||||
|
const { startPairingFromPaymentRequest } = await import('./pairing.service.js')
|
||||||
|
await startPairingFromPaymentRequest({
|
||||||
|
paymentRequestId: evt.paymentRequestId,
|
||||||
|
productMetadata: evt.productMetadata,
|
||||||
|
customerId: evt.customerId,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,9 +149,9 @@ export const listSessions = async ({ page = 1, limit = 20, status, topic_sensiti
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getSessionById = async (sessionId) => {
|
export const getSessionById = async (sessionId) => {
|
||||||
// `mode` lives on payment_sessions (chat | call), introduced in Phase 4.1.
|
// `mode` lives on payment_requests (chat | call), introduced in Phase 4.1.
|
||||||
// The chat header pill needs it, so surface it on every session.info read.
|
// The chat header pill needs it, so surface it on every session.info read.
|
||||||
// Falls back to 'chat' for pre-3.7 rows where payment_session_id is null.
|
// Falls back to 'chat' for pre-3.7 rows where payment_request_id is null.
|
||||||
const [session] = await sql`
|
const [session] = await sql`
|
||||||
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics,
|
SELECT cs.id, cs.customer_id, cs.mitra_id, cs.status, cs.topic_sensitivity, cs.topics,
|
||||||
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
|
cs.created_at, cs.paired_at, cs.ended_at, cs.ended_by,
|
||||||
@@ -162,7 +162,7 @@ export const getSessionById = async (sessionId) => {
|
|||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
INNER JOIN customers c ON c.id = cs.customer_id
|
INNER JOIN customers c ON c.id = cs.customer_id
|
||||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||||
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
|
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
|
||||||
WHERE cs.id = ${sessionId}
|
WHERE cs.id = ${sessionId}
|
||||||
`
|
`
|
||||||
return session
|
return session
|
||||||
@@ -251,7 +251,7 @@ export const getCustomerHistory = async (customerId, { cursor = null, limit = 20
|
|||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||||
LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id
|
LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id
|
||||||
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
|
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
|
||||||
WHERE cs.customer_id = ${customerId}
|
WHERE cs.customer_id = ${customerId}
|
||||||
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||||
AND (
|
AND (
|
||||||
@@ -275,7 +275,7 @@ export const getCustomerHistory = async (customerId, { cursor = null, limit = 20
|
|||||||
FROM chat_sessions cs
|
FROM chat_sessions cs
|
||||||
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
LEFT JOIN mitras m ON m.id = cs.mitra_id
|
||||||
LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id
|
LEFT JOIN mitra_online_status mos ON mos.mitra_id = cs.mitra_id
|
||||||
LEFT JOIN payment_sessions ps ON ps.id = cs.payment_session_id
|
LEFT JOIN payment_requests ps ON ps.id = cs.payment_request_id
|
||||||
WHERE cs.customer_id = ${customerId}
|
WHERE cs.customer_id = ${customerId}
|
||||||
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
AND cs.status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
||||||
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC, cs.id DESC
|
ORDER BY COALESCE(cs.ended_at, cs.created_at) DESC, cs.id DESC
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const db = () => getDb()
|
|||||||
/**
|
/**
|
||||||
* Truncate Phase 3.7-relevant tables between tests.
|
* Truncate Phase 3.7-relevant tables between tests.
|
||||||
*
|
*
|
||||||
* Order matters: pairing_failures FK → payment_sessions; chat_request_notifications
|
* Order matters: pairing_failures FK → payment_requests; chat_request_notifications
|
||||||
* FK → chat_sessions; customer_transactions FK → chat_sessions; etc. Use CASCADE so
|
* FK → chat_sessions; customer_transactions FK → chat_sessions; etc. Use CASCADE so
|
||||||
* we don't have to maintain the topological order when tables get added.
|
* we don't have to maintain the topological order when tables get added.
|
||||||
*
|
*
|
||||||
@@ -19,7 +19,7 @@ export const db = () => getDb()
|
|||||||
*/
|
*/
|
||||||
const TRUNCATE_TABLES = [
|
const TRUNCATE_TABLES = [
|
||||||
'pairing_failures',
|
'pairing_failures',
|
||||||
'payment_sessions',
|
'payment_requests',
|
||||||
'chat_request_notifications',
|
'chat_request_notifications',
|
||||||
'session_extensions',
|
'session_extensions',
|
||||||
'session_closures',
|
'session_closures',
|
||||||
@@ -70,7 +70,7 @@ export const resetAppConfig = async () => {
|
|||||||
['extension_timeout_seconds', { value: 60 }],
|
['extension_timeout_seconds', { value: 60 }],
|
||||||
['early_end_mitra_enabled', { value: false }],
|
['early_end_mitra_enabled', { value: false }],
|
||||||
['early_end_customer_enabled', { value: false }],
|
['early_end_customer_enabled', { value: false }],
|
||||||
['payment_session_timeout_minutes', { value: 20 }],
|
['payment_request_timeout_minutes', { value: 20 }],
|
||||||
['returning_chat_confirmation_timeout_seconds', { value: 20 }],
|
['returning_chat_confirmation_timeout_seconds', { value: 20 }],
|
||||||
['extension_default_action_on_timeout', { value: 'auto_approve' }],
|
['extension_default_action_on_timeout', { value: 'auto_approve' }],
|
||||||
['pairing_blast_timeout_seconds', { value: 60 }],
|
['pairing_blast_timeout_seconds', { value: 60 }],
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ const { buildPublic } = await import('../helpers/server.js')
|
|||||||
const { resetDb, resetAppConfig, db } = await import('../helpers/db.js')
|
const { resetDb, resetAppConfig, db } = await import('../helpers/db.js')
|
||||||
const { createCustomer } = await import('../helpers/fixtures.js')
|
const { createCustomer } = await import('../helpers/fixtures.js')
|
||||||
const { customerJwt, authHeader } = await import('../helpers/jwt.js')
|
const { customerJwt, authHeader } = await import('../helpers/jwt.js')
|
||||||
const { PaymentSessionStatus } = await import('../../src/constants.js')
|
const { PaymentRequestStatus } = await import('../../src/constants.js')
|
||||||
|
|
||||||
describe('POST /api/client/payment-sessions', () => {
|
describe('POST /api/client/payment-requests', () => {
|
||||||
let app
|
let app
|
||||||
let customer
|
let customer
|
||||||
let token
|
let token
|
||||||
@@ -49,7 +49,7 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
it('happy path returns 201 + a pending payment-session row at the discounted price for an eligible customer', async () => {
|
it('happy path returns 201 + a pending payment-session row at the discounted price for an eligible customer', async () => {
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/client/payment-sessions',
|
url: '/api/client/payment-requests',
|
||||||
headers: authHeader(token),
|
headers: authHeader(token),
|
||||||
// Discount duration default is 12 minutes (config seed). Eligible customer →
|
// Discount duration default is 12 minutes (config seed). Eligible customer →
|
||||||
// amount forced to actual_price_idr (2000), is_first_session_discount=true.
|
// amount forced to actual_price_idr (2000), is_first_session_discount=true.
|
||||||
@@ -59,7 +59,7 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
expect(res.statusCode).toBe(201)
|
expect(res.statusCode).toBe(201)
|
||||||
const body = res.json()
|
const body = res.json()
|
||||||
expect(body.success).toBe(true)
|
expect(body.success).toBe(true)
|
||||||
expect(body.data.status).toBe(PaymentSessionStatus.PENDING)
|
expect(body.data.status).toBe(PaymentRequestStatus.PENDING)
|
||||||
expect(body.data.duration_minutes).toBe(12)
|
expect(body.data.duration_minutes).toBe(12)
|
||||||
expect(body.data.is_first_session_discount).toBe(true)
|
expect(body.data.is_first_session_discount).toBe(true)
|
||||||
expect(body.data.amount).toBe(2000)
|
expect(body.data.amount).toBe(2000)
|
||||||
@@ -68,7 +68,7 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
|
|
||||||
// Verify persistence
|
// Verify persistence
|
||||||
const sql = db()
|
const sql = db()
|
||||||
const [row] = await sql`SELECT * FROM payment_sessions WHERE id = ${body.data.id}`
|
const [row] = await sql`SELECT * FROM payment_requests WHERE id = ${body.data.id}`
|
||||||
expect(row).toBeDefined()
|
expect(row).toBeDefined()
|
||||||
expect(row.customer_id).toBe(customer.id)
|
expect(row.customer_id).toBe(customer.id)
|
||||||
})
|
})
|
||||||
@@ -83,7 +83,7 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
|
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/client/payment-sessions',
|
url: '/api/client/payment-requests',
|
||||||
headers: authHeader(token),
|
headers: authHeader(token),
|
||||||
payload: { duration_minutes: 12 },
|
payload: { duration_minutes: 12 },
|
||||||
})
|
})
|
||||||
@@ -99,19 +99,19 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
// Use a non-discount tier (5 min @ 5000 IDR) so we exercise the regular confirm path.
|
// Use a non-discount tier (5 min @ 5000 IDR) so we exercise the regular confirm path.
|
||||||
const createRes = await app.inject({
|
const createRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/client/payment-sessions',
|
url: '/api/client/payment-requests',
|
||||||
headers: authHeader(token),
|
headers: authHeader(token),
|
||||||
payload: { duration_minutes: 5 },
|
payload: { duration_minutes: 5 },
|
||||||
})
|
})
|
||||||
expect(createRes.statusCode).toBe(201)
|
expect(createRes.statusCode).toBe(201)
|
||||||
const created = createRes.json().data
|
const created = createRes.json().data
|
||||||
expect(created.status).toBe(PaymentSessionStatus.PENDING)
|
expect(created.status).toBe(PaymentRequestStatus.PENDING)
|
||||||
expect(created.is_first_session_discount).toBe(false)
|
expect(created.is_first_session_discount).toBe(false)
|
||||||
expect(created.amount).toBe(5000)
|
expect(created.amount).toBe(5000)
|
||||||
|
|
||||||
const confirmRes = await app.inject({
|
const confirmRes = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: `/api/client/payment-sessions/${created.id}/confirm`,
|
url: `/api/client/payment-requests/${created.id}/confirm`,
|
||||||
headers: authHeader(token),
|
headers: authHeader(token),
|
||||||
payload: {},
|
payload: {},
|
||||||
})
|
})
|
||||||
@@ -119,7 +119,7 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
expect(confirmRes.statusCode).toBe(200)
|
expect(confirmRes.statusCode).toBe(200)
|
||||||
const confirmed = confirmRes.json().data
|
const confirmed = confirmRes.json().data
|
||||||
expect(confirmed.id).toBe(created.id)
|
expect(confirmed.id).toBe(created.id)
|
||||||
expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED)
|
expect(confirmed.status).toBe(PaymentRequestStatus.CONFIRMED)
|
||||||
expect(confirmed.confirmed_at).toBeTruthy()
|
expect(confirmed.confirmed_at).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ describe('POST /api/client/payment-sessions', () => {
|
|||||||
// 20-minute call tier in Phase 4 = 17000 IDR.
|
// 20-minute call tier in Phase 4 = 17000 IDR.
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/client/payment-sessions',
|
url: '/api/client/payment-requests',
|
||||||
headers: authHeader(token),
|
headers: authHeader(token),
|
||||||
payload: { duration_minutes: 20, mode: 'call' },
|
payload: { duration_minutes: 20, mode: 'call' },
|
||||||
})
|
})
|
||||||
|
|||||||
190
backend/test/routes/shared.payment-webhooks.routes.test.js
Normal file
190
backend/test/routes/shared.payment-webhooks.routes.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -70,7 +70,7 @@ describe('extension.service — EXTENSION_RESPONSE payload', () => {
|
|||||||
// Pending extension row tied to that payment.
|
// Pending extension row tied to that payment.
|
||||||
const [extension] = await sql`
|
const [extension] = await sql`
|
||||||
INSERT INTO session_extensions (
|
INSERT INTO session_extensions (
|
||||||
session_id, requested_duration_minutes, requested_price, status, payment_session_id
|
session_id, requested_duration_minutes, requested_price, status, payment_request_id
|
||||||
)
|
)
|
||||||
VALUES (${session.id}, 10, 9000, ${ExtensionStatus.PENDING}, ${extPay.id})
|
VALUES (${session.id}, 10, 9000, ${ExtensionStatus.PENDING}, ${extPay.id})
|
||||||
RETURNING id
|
RETURNING id
|
||||||
@@ -119,7 +119,7 @@ describe('extension.service — EXTENSION_RESPONSE payload', () => {
|
|||||||
await confirmPaymentSession(extPay.id, customer.id)
|
await confirmPaymentSession(extPay.id, customer.id)
|
||||||
const [extension] = await sql`
|
const [extension] = await sql`
|
||||||
INSERT INTO session_extensions (
|
INSERT INTO session_extensions (
|
||||||
session_id, requested_duration_minutes, requested_price, status, payment_session_id
|
session_id, requested_duration_minutes, requested_price, status, payment_request_id
|
||||||
)
|
)
|
||||||
VALUES (${session.id}, 10, 9000, ${ExtensionStatus.PENDING}, ${extPay.id})
|
VALUES (${session.id}, 10, 9000, ${ExtensionStatus.PENDING}, ${extPay.id})
|
||||||
RETURNING id
|
RETURNING id
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const { createPaymentSession, confirmPaymentSession } = await import('../../src/
|
|||||||
const {
|
const {
|
||||||
WsMessage,
|
WsMessage,
|
||||||
PairingFailureCause,
|
PairingFailureCause,
|
||||||
PaymentSessionStatus,
|
PaymentRequestStatus,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
} = await import('../../src/constants.js')
|
} = await import('../../src/constants.js')
|
||||||
const { db, resetDb, resetAppConfig } = await import('../helpers/db.js')
|
const { db, resetDb, resetAppConfig } = await import('../helpers/db.js')
|
||||||
@@ -72,7 +72,7 @@ describe('pairing.service', () => {
|
|||||||
|
|
||||||
// Act: customer fires the general blast — only one mitra is online.
|
// Act: customer fires the general blast — only one mitra is online.
|
||||||
const session = await createPairingRequest(customer.id, {
|
const session = await createPairingRequest(customer.id, {
|
||||||
paymentSessionId: pay.id,
|
paymentRequestId: pay.id,
|
||||||
})
|
})
|
||||||
expect(session.status).toBe(SessionStatus.PENDING_ACCEPTANCE)
|
expect(session.status).toBe(SessionStatus.PENDING_ACCEPTANCE)
|
||||||
|
|
||||||
@@ -83,15 +83,15 @@ describe('pairing.service', () => {
|
|||||||
// Assert: pairing_failures audit row carries ALL_MITRAS_REJECTED.
|
// Assert: pairing_failures audit row carries ALL_MITRAS_REJECTED.
|
||||||
const sql = db()
|
const sql = db()
|
||||||
const failures = await sql`
|
const failures = await sql`
|
||||||
SELECT cause_tag FROM pairing_failures WHERE payment_session_id = ${pay.id}
|
SELECT cause_tag FROM pairing_failures WHERE payment_request_id = ${pay.id}
|
||||||
`
|
`
|
||||||
expect(failures).toHaveLength(1)
|
expect(failures).toHaveLength(1)
|
||||||
expect(failures[0].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED)
|
expect(failures[0].cause_tag).toBe(PairingFailureCause.ALL_MITRAS_REJECTED)
|
||||||
|
|
||||||
// Payment session stays CONFIRMED — the customer can re-blast on the same
|
// Payment session stays CONFIRMED — the customer can re-blast on the same
|
||||||
// payment via the S7 Timeout "coba cari lagi" CTA.
|
// payment via the S7 Timeout "coba cari lagi" CTA.
|
||||||
const [paySession] = await sql`SELECT status FROM payment_sessions WHERE id = ${pay.id}`
|
const [payRequest] = await sql`SELECT status FROM payment_requests WHERE id = ${pay.id}`
|
||||||
expect(paySession.status).toBe(PaymentSessionStatus.CONFIRMED)
|
expect(payRequest.status).toBe(PaymentRequestStatus.CONFIRMED)
|
||||||
|
|
||||||
// Customer was notified with PAIRING_FAILED carrying is_terminal=false so
|
// Customer was notified with PAIRING_FAILED carrying is_terminal=false so
|
||||||
// the client renders the retryable variant of the S7 timeout screen.
|
// the client renders the retryable variant of the S7 timeout screen.
|
||||||
@@ -112,7 +112,7 @@ describe('pairing.service', () => {
|
|||||||
})
|
})
|
||||||
await confirmPaymentSession(pay.id, customer.id)
|
await confirmPaymentSession(pay.id, customer.id)
|
||||||
const session = await createPairingRequest(customer.id, {
|
const session = await createPairingRequest(customer.id, {
|
||||||
paymentSessionId: pay.id,
|
paymentRequestId: pay.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Act: customer cancels.
|
// Act: customer cancels.
|
||||||
@@ -131,7 +131,7 @@ describe('pairing.service', () => {
|
|||||||
// Payment session is still terminated (CUSTOMER_CANCELLED) — the failure row exists
|
// Payment session is still terminated (CUSTOMER_CANCELLED) — the failure row exists
|
||||||
// for ops accounting, just no real-time push to the customer who initiated the cancel.
|
// for ops accounting, just no real-time push to the customer who initiated the cancel.
|
||||||
const sql = db()
|
const sql = db()
|
||||||
const failures = await sql`SELECT cause_tag FROM pairing_failures WHERE payment_session_id = ${pay.id}`
|
const failures = await sql`SELECT cause_tag FROM pairing_failures WHERE payment_request_id = ${pay.id}`
|
||||||
expect(failures).toHaveLength(1)
|
expect(failures).toHaveLength(1)
|
||||||
expect(failures[0].cause_tag).toBe(PairingFailureCause.CUSTOMER_CANCELLED)
|
expect(failures[0].cause_tag).toBe(PairingFailureCause.CUSTOMER_CANCELLED)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
getPaymentSession,
|
getPaymentSession,
|
||||||
getCustomerPendingPayments,
|
getCustomerPendingPayments,
|
||||||
} from '../../src/services/payment.service.js'
|
} from '../../src/services/payment.service.js'
|
||||||
import { PaymentSessionStatus, SessionStatus } from '../../src/constants.js'
|
import { PaymentRequestStatus, SessionStatus } from '../../src/constants.js'
|
||||||
import { resetDb, resetAppConfig, db } from '../helpers/db.js'
|
import { resetDb, resetAppConfig, db } from '../helpers/db.js'
|
||||||
import { createCustomer, createMitra } from '../helpers/fixtures.js'
|
import { createCustomer, createMitra } from '../helpers/fixtures.js'
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ describe('payment.service', () => {
|
|||||||
amount: 30000,
|
amount: 30000,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(session.status).toBe(PaymentSessionStatus.PENDING)
|
expect(session.status).toBe(PaymentRequestStatus.PENDING)
|
||||||
expect(session.customer_id).toBe(customer.id)
|
expect(session.customer_id).toBe(customer.id)
|
||||||
expect(session.duration_minutes).toBe(15)
|
expect(session.duration_minutes).toBe(15)
|
||||||
expect(session.amount).toBe(30000)
|
expect(session.amount).toBe(30000)
|
||||||
@@ -47,7 +47,7 @@ describe('payment.service', () => {
|
|||||||
// Verify it's actually persisted (not just returned from the INSERT)
|
// Verify it's actually persisted (not just returned from the INSERT)
|
||||||
const reloaded = await getPaymentSession(session.id)
|
const reloaded = await getPaymentSession(session.id)
|
||||||
expect(reloaded.id).toBe(session.id)
|
expect(reloaded.id).toBe(session.id)
|
||||||
expect(reloaded.status).toBe(PaymentSessionStatus.PENDING)
|
expect(reloaded.status).toBe(PaymentRequestStatus.PENDING)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('confirmPaymentSession transitions pending → confirmed', async () => {
|
it('confirmPaymentSession transitions pending → confirmed', async () => {
|
||||||
@@ -56,11 +56,11 @@ describe('payment.service', () => {
|
|||||||
durationMinutes: 30,
|
durationMinutes: 30,
|
||||||
amount: 60000,
|
amount: 60000,
|
||||||
})
|
})
|
||||||
expect(session.status).toBe(PaymentSessionStatus.PENDING)
|
expect(session.status).toBe(PaymentRequestStatus.PENDING)
|
||||||
|
|
||||||
const confirmed = await confirmPaymentSession(session.id, customer.id)
|
const confirmed = await confirmPaymentSession(session.id, customer.id)
|
||||||
|
|
||||||
expect(confirmed.status).toBe(PaymentSessionStatus.CONFIRMED)
|
expect(confirmed.status).toBe(PaymentRequestStatus.CONFIRMED)
|
||||||
expect(confirmed.confirmed_at).toBeTruthy()
|
expect(confirmed.confirmed_at).toBeTruthy()
|
||||||
expect(new Date(confirmed.confirmed_at).getTime()).toBeGreaterThan(0)
|
expect(new Date(confirmed.confirmed_at).getTime()).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
@@ -81,7 +81,7 @@ describe('payment.service', () => {
|
|||||||
|
|
||||||
// Row should still be pending — the failed confirm must not have side effects.
|
// Row should still be pending — the failed confirm must not have side effects.
|
||||||
const reloaded = await getPaymentSession(session.id)
|
const reloaded = await getPaymentSession(session.id)
|
||||||
expect(reloaded.status).toBe(PaymentSessionStatus.PENDING)
|
expect(reloaded.status).toBe(PaymentRequestStatus.PENDING)
|
||||||
expect(reloaded.confirmed_at).toBeNull()
|
expect(reloaded.confirmed_at).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ describe('payment.service', () => {
|
|||||||
|
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO session_extensions (
|
INSERT INTO session_extensions (
|
||||||
session_id, requested_duration_minutes, requested_price, status, payment_session_id
|
session_id, requested_duration_minutes, requested_price, status, payment_request_id
|
||||||
)
|
)
|
||||||
VALUES (${chatSession.id}, 10, 2500, 'pending', ${extPay.id})
|
VALUES (${chatSession.id}, 10, 2500, 'pending', ${extPay.id})
|
||||||
`
|
`
|
||||||
@@ -188,7 +188,7 @@ describe('payment.service', () => {
|
|||||||
isExtension: true,
|
isExtension: true,
|
||||||
})
|
})
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_session_id)
|
INSERT INTO session_extensions (session_id, requested_duration_minutes, requested_price, status, payment_request_id)
|
||||||
VALUES (${chatSession.id}, 10, 2500, 'pending', ${extension.id})
|
VALUES (${chatSession.id}, 10, 2500, 'pending', ${extension.id})
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ describe('payment.service', () => {
|
|||||||
// Manually move expires_at into the past — leaves status pending so this
|
// Manually move expires_at into the past — leaves status pending so this
|
||||||
// simulates the gap between TTL expiry and the next sweep tick.
|
// simulates the gap between TTL expiry and the next sweep tick.
|
||||||
await sql`
|
await sql`
|
||||||
UPDATE payment_sessions
|
UPDATE payment_requests
|
||||||
SET expires_at = NOW() - INTERVAL '1 second'
|
SET expires_at = NOW() - INTERVAL '1 second'
|
||||||
WHERE id = ${pay.id}
|
WHERE id = ${pay.id}
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -48,14 +48,14 @@ class SessionClosure extends _$SessionClosure {
|
|||||||
SessionClosureData build() => const ClosureInitialData();
|
SessionClosureData build() => const ClosureInitialData();
|
||||||
|
|
||||||
/// Extension request is a 3-step flow with the extension cost held in its
|
/// Extension request is a 3-step flow with the extension cost held in its
|
||||||
/// own `payment_session` (never combined with a free trial). Server-side,
|
/// own `payment_request` (never combined with a free trial). Server-side,
|
||||||
/// the extension service refuses requests without an
|
/// the extension service refuses requests without an
|
||||||
/// `extension_payment_session_id` on a confirmed, is_extension payment session.
|
/// `extension_payment_request_id` on a confirmed, is_extension payment session.
|
||||||
///
|
///
|
||||||
/// 1. POST `/api/client/payment-sessions` with `is_extension: true`
|
/// 1. POST `/api/client/payment-requests` with `is_extension: true`
|
||||||
/// 2. POST `/api/client/payment-sessions/:id/confirm`
|
/// 2. POST `/api/client/payment-requests/:id/confirm`
|
||||||
/// 3. POST `/api/client/chat/session/:sessionId/extend` with the
|
/// 3. POST `/api/client/chat/session/:sessionId/extend` with the
|
||||||
/// extension_payment_session_id from step 2.
|
/// extension_payment_request_id from step 2.
|
||||||
///
|
///
|
||||||
/// Charge timing is server-side: only on actual approve / auto-approve.
|
/// Charge timing is server-side: only on actual approve / auto-approve.
|
||||||
/// If the mitra explicitly rejects within 10s the payment is failed back, no charge.
|
/// If the mitra explicitly rejects within 10s the payment is failed back, no charge.
|
||||||
@@ -64,15 +64,15 @@ class SessionClosure extends _$SessionClosure {
|
|||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
|
|
||||||
final createResp = await api.post('/api/client/payment-sessions/', data: {
|
final createResp = await api.post('/api/client/payment-requests/', data: {
|
||||||
'duration_minutes': durationMinutes,
|
'duration_minutes': durationMinutes,
|
||||||
'is_extension': true,
|
'is_extension': true,
|
||||||
});
|
});
|
||||||
final paymentSessionId = (createResp['data'] as Map<String, dynamic>)['id'] as String;
|
final paymentRequestId = (createResp['data'] as Map<String, dynamic>)['id'] as String;
|
||||||
|
|
||||||
// Backend rejects truly empty bodies on confirm, so always send `{}`.
|
// Backend rejects truly empty bodies on confirm, so always send `{}`.
|
||||||
await api.post(
|
await api.post(
|
||||||
'/api/client/payment-sessions/$paymentSessionId/confirm',
|
'/api/client/payment-requests/$paymentRequestId/confirm',
|
||||||
data: const <String, dynamic>{},
|
data: const <String, dynamic>{},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ class SessionClosure extends _$SessionClosure {
|
|||||||
await api.post('/api/client/chat/session/$sessionId/extend', data: {
|
await api.post('/api/client/chat/session/$sessionId/extend', data: {
|
||||||
'duration_minutes': durationMinutes,
|
'duration_minutes': durationMinutes,
|
||||||
'price': price,
|
'price': price,
|
||||||
'extension_payment_session_id': paymentSessionId,
|
'extension_payment_request_id': paymentRequestId,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = const ClosureErrorData('Gagal meminta perpanjangan.');
|
state = const ClosureErrorData('Gagal meminta perpanjangan.');
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class ExtensionStatus {
|
|||||||
ExtensionStatus._();
|
ExtensionStatus._();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session mode — chat or voice call. Mirrors backend `payment_sessions.mode`
|
/// Session mode — chat or voice call. Mirrors backend `payment_requests.mode`
|
||||||
/// (added in Phase 4 stage 1). A `call` session is functionally a chat with a
|
/// (added in Phase 4 stage 1). A `call` session is functionally a chat with a
|
||||||
/// "voice call" badge and (eventually) a Meet link the mitra pastes manually;
|
/// "voice call" badge and (eventually) a Meet link the mitra pastes manually;
|
||||||
/// no real audio transport is built yet.
|
/// no real audio transport is built yet.
|
||||||
@@ -153,7 +153,7 @@ enum PairingFailureCause {
|
|||||||
targetedMitraOffline('targeted_mitra_offline'),
|
targetedMitraOffline('targeted_mitra_offline'),
|
||||||
targetedMitraRejected('targeted_mitra_rejected'),
|
targetedMitraRejected('targeted_mitra_rejected'),
|
||||||
targetedMitraTimeout('targeted_mitra_timeout'),
|
targetedMitraTimeout('targeted_mitra_timeout'),
|
||||||
paymentSessionExpired('payment_session_expired'),
|
paymentSessionExpired('payment_request_expired'),
|
||||||
customerCancelled('customer_cancelled'),
|
customerCancelled('customer_cancelled'),
|
||||||
unknown('unknown');
|
unknown('unknown');
|
||||||
|
|
||||||
@@ -165,13 +165,13 @@ enum PairingFailureCause {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Payment session lifecycle. Mirror of backend
|
/// Payment session lifecycle. Mirror of backend
|
||||||
/// `PaymentSessionStatus`.
|
/// `PaymentRequestStatus`.
|
||||||
class PaymentSessionStatus {
|
class PaymentRequestStatus {
|
||||||
static const pending = 'pending';
|
static const pending = 'pending';
|
||||||
static const confirmed = 'confirmed';
|
static const confirmed = 'confirmed';
|
||||||
static const consumed = 'consumed';
|
static const consumed = 'consumed';
|
||||||
static const failedPairing = 'failed_pairing';
|
static const failedPairing = 'failed_delivery';
|
||||||
static const abandoned = 'abandoned';
|
static const abandoned = 'abandoned';
|
||||||
static const expired = 'expired';
|
static const expired = 'expired';
|
||||||
PaymentSessionStatus._();
|
PaymentRequestStatus._();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ class PairingInitialData extends PairingData {
|
|||||||
/// General-blast in flight. The chat_session row exists; backend has already
|
/// General-blast in flight. The chat_session row exists; backend has already
|
||||||
/// notified all available mitras and is waiting for the first to accept.
|
/// notified all available mitras and is waiting for the first to accept.
|
||||||
class PairingSearchingData extends PairingData {
|
class PairingSearchingData extends PairingData {
|
||||||
/// chat_session id (NOT payment_session id).
|
/// chat_session id (NOT payment_request id).
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
|
|
||||||
/// payment_session id — we keep it on the state so cancelSearch can call
|
/// payment_request id — we keep it on the state so cancelSearch can call
|
||||||
/// the payment-session-scoped cancel endpoint without re-prompting.
|
/// the payment-session-scoped cancel endpoint without re-prompting.
|
||||||
final String paymentSessionId;
|
final String paymentRequestId;
|
||||||
|
|
||||||
/// Carried so a retryable PAIRING_FAILED can preserve the customer's original
|
/// Carried so a retryable PAIRING_FAILED can preserve the customer's original
|
||||||
/// topic choice when looping back into Blast via retryBlast().
|
/// topic choice when looping back into Blast via retryBlast().
|
||||||
@@ -36,7 +36,7 @@ class PairingSearchingData extends PairingData {
|
|||||||
|
|
||||||
const PairingSearchingData({
|
const PairingSearchingData({
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
required this.paymentSessionId,
|
required this.paymentRequestId,
|
||||||
required this.topicSensitivity,
|
required this.topicSensitivity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ class PairingSearchingData extends PairingData {
|
|||||||
/// server is the source of truth for the actual auto-reject; the local timer
|
/// server is the source of truth for the actual auto-reject; the local timer
|
||||||
/// is purely cosmetic.
|
/// is purely cosmetic.
|
||||||
class PairingTargetedWaitingData extends PairingData {
|
class PairingTargetedWaitingData extends PairingData {
|
||||||
final String paymentSessionId;
|
final String paymentRequestId;
|
||||||
final String mitraId;
|
final String mitraId;
|
||||||
final String mitraName;
|
final String mitraName;
|
||||||
final int secondsRemaining;
|
final int secondsRemaining;
|
||||||
@@ -58,7 +58,7 @@ class PairingTargetedWaitingData extends PairingData {
|
|||||||
final TopicSensitivity topicSensitivity;
|
final TopicSensitivity topicSensitivity;
|
||||||
|
|
||||||
const PairingTargetedWaitingData({
|
const PairingTargetedWaitingData({
|
||||||
required this.paymentSessionId,
|
required this.paymentRequestId,
|
||||||
required this.mitraId,
|
required this.mitraId,
|
||||||
required this.mitraName,
|
required this.mitraName,
|
||||||
required this.secondsRemaining,
|
required this.secondsRemaining,
|
||||||
@@ -67,7 +67,7 @@ class PairingTargetedWaitingData extends PairingData {
|
|||||||
|
|
||||||
PairingTargetedWaitingData copyWith({int? secondsRemaining}) {
|
PairingTargetedWaitingData copyWith({int? secondsRemaining}) {
|
||||||
return PairingTargetedWaitingData(
|
return PairingTargetedWaitingData(
|
||||||
paymentSessionId: paymentSessionId,
|
paymentRequestId: paymentRequestId,
|
||||||
mitraId: mitraId,
|
mitraId: mitraId,
|
||||||
mitraName: mitraName,
|
mitraName: mitraName,
|
||||||
secondsRemaining: secondsRemaining ?? this.secondsRemaining,
|
secondsRemaining: secondsRemaining ?? this.secondsRemaining,
|
||||||
@@ -96,14 +96,14 @@ class PairingActiveData extends PairingData {
|
|||||||
///
|
///
|
||||||
/// The UI surfaces this via the bestie-unavailable dialog.
|
/// The UI surfaces this via the bestie-unavailable dialog.
|
||||||
class PairingTargetedUnavailableData extends PairingData {
|
class PairingTargetedUnavailableData extends PairingData {
|
||||||
final String paymentSessionId;
|
final String paymentRequestId;
|
||||||
final String mitraName;
|
final String mitraName;
|
||||||
final PairingFailureCause cause;
|
final PairingFailureCause cause;
|
||||||
// Carried so the fallback-to-blast call preserves the customer's original choice.
|
// Carried so the fallback-to-blast call preserves the customer's original choice.
|
||||||
final TopicSensitivity topicSensitivity;
|
final TopicSensitivity topicSensitivity;
|
||||||
|
|
||||||
const PairingTargetedUnavailableData({
|
const PairingTargetedUnavailableData({
|
||||||
required this.paymentSessionId,
|
required this.paymentRequestId,
|
||||||
required this.mitraName,
|
required this.mitraName,
|
||||||
required this.cause,
|
required this.cause,
|
||||||
required this.topicSensitivity,
|
required this.topicSensitivity,
|
||||||
@@ -114,11 +114,11 @@ class PairingTargetedUnavailableData extends PairingData {
|
|||||||
///
|
///
|
||||||
/// `isRetryable=true` means the backend kept the payment session `confirmed`
|
/// `isRetryable=true` means the backend kept the payment session `confirmed`
|
||||||
/// (audit-only failure) so the customer can re-blast on the same payment via
|
/// (audit-only failure) so the customer can re-blast on the same payment via
|
||||||
/// `retryBlast()`. `isRetryable=false` means the payment is in `failed_pairing`
|
/// `retryBlast()`. `isRetryable=false` means the payment is in `failed_delivery`
|
||||||
/// and any retry must start from a fresh payment session.
|
/// and any retry must start from a fresh payment session.
|
||||||
class PairingFailedData extends PairingData {
|
class PairingFailedData extends PairingData {
|
||||||
final PairingFailureCause cause;
|
final PairingFailureCause cause;
|
||||||
final String? paymentSessionId;
|
final String? paymentRequestId;
|
||||||
final bool isRetryable;
|
final bool isRetryable;
|
||||||
// Carried so retryBlast() can re-issue the blast with the customer's original
|
// Carried so retryBlast() can re-issue the blast with the customer's original
|
||||||
// topic choice. Null when the failure originated before any topic was known.
|
// topic choice. Null when the failure originated before any topic was known.
|
||||||
@@ -126,7 +126,7 @@ class PairingFailedData extends PairingData {
|
|||||||
|
|
||||||
const PairingFailedData({
|
const PairingFailedData({
|
||||||
required this.cause,
|
required this.cause,
|
||||||
this.paymentSessionId,
|
this.paymentRequestId,
|
||||||
this.isRetryable = false,
|
this.isRetryable = false,
|
||||||
this.topicSensitivity,
|
this.topicSensitivity,
|
||||||
});
|
});
|
||||||
@@ -156,7 +156,7 @@ class Pairing extends _$Pairing {
|
|||||||
/// Returns once the chat_session row is created server-side; subsequent
|
/// Returns once the chat_session row is created server-side; subsequent
|
||||||
/// transitions (paired / pairing_failed) arrive via WS.
|
/// transitions (paired / pairing_failed) arrive via WS.
|
||||||
Future<void> startSearch({
|
Future<void> startSearch({
|
||||||
required String paymentSessionId,
|
required String paymentRequestId,
|
||||||
required TopicSensitivity topicSensitivity,
|
required TopicSensitivity topicSensitivity,
|
||||||
}) async {
|
}) async {
|
||||||
state = const PairingInitialData();
|
state = const PairingInitialData();
|
||||||
@@ -165,7 +165,7 @@ class Pairing extends _$Pairing {
|
|||||||
final response = await _apiClient.post(
|
final response = await _apiClient.post(
|
||||||
'/api/client/chat/request',
|
'/api/client/chat/request',
|
||||||
data: {
|
data: {
|
||||||
'payment_session_id': paymentSessionId,
|
'payment_request_id': paymentRequestId,
|
||||||
'topic_sensitivity': topicSensitivity.value,
|
'topic_sensitivity': topicSensitivity.value,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -173,7 +173,7 @@ class Pairing extends _$Pairing {
|
|||||||
final sessionId = data['id'] as String;
|
final sessionId = data['id'] as String;
|
||||||
state = PairingSearchingData(
|
state = PairingSearchingData(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
paymentSessionId: paymentSessionId,
|
paymentRequestId: paymentRequestId,
|
||||||
topicSensitivity: topicSensitivity,
|
topicSensitivity: topicSensitivity,
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
@@ -183,7 +183,7 @@ class Pairing extends _$Pairing {
|
|||||||
// Backend already failed the payment in this case — terminal.
|
// Backend already failed the payment in this case — terminal.
|
||||||
state = PairingFailedData(
|
state = PairingFailedData(
|
||||||
cause: PairingFailureCause.noMitraAvailable,
|
cause: PairingFailureCause.noMitraAvailable,
|
||||||
paymentSessionId: paymentSessionId,
|
paymentRequestId: paymentRequestId,
|
||||||
);
|
);
|
||||||
} else if (code == 'ALREADY_ACTIVE') {
|
} else if (code == 'ALREADY_ACTIVE') {
|
||||||
state = const PairingErrorData('Kamu sudah memiliki sesi aktif.');
|
state = const PairingErrorData('Kamu sudah memiliki sesi aktif.');
|
||||||
@@ -201,7 +201,7 @@ class Pairing extends _$Pairing {
|
|||||||
/// row (payment stays confirmed) — we transition to TargetedUnavailable so
|
/// row (payment stays confirmed) — we transition to TargetedUnavailable so
|
||||||
/// the UI can offer the fallback dialog.
|
/// the UI can offer the fallback dialog.
|
||||||
Future<void> startTargetedSearch({
|
Future<void> startTargetedSearch({
|
||||||
required String paymentSessionId,
|
required String paymentRequestId,
|
||||||
required String mitraId,
|
required String mitraId,
|
||||||
required String mitraName,
|
required String mitraName,
|
||||||
required TopicSensitivity topicSensitivity,
|
required TopicSensitivity topicSensitivity,
|
||||||
@@ -212,7 +212,7 @@ class Pairing extends _$Pairing {
|
|||||||
final response = await _apiClient.post(
|
final response = await _apiClient.post(
|
||||||
'/api/client/chat/chat-requests/returning',
|
'/api/client/chat/chat-requests/returning',
|
||||||
data: {
|
data: {
|
||||||
'payment_session_id': paymentSessionId,
|
'payment_request_id': paymentRequestId,
|
||||||
'mitra_id': mitraId,
|
'mitra_id': mitraId,
|
||||||
'topic_sensitivity': topicSensitivity.value,
|
'topic_sensitivity': topicSensitivity.value,
|
||||||
},
|
},
|
||||||
@@ -222,7 +222,7 @@ class Pairing extends _$Pairing {
|
|||||||
final sessionData = response['data'] as Map<String, dynamic>?;
|
final sessionData = response['data'] as Map<String, dynamic>?;
|
||||||
final seconds = (sessionData?['confirmation_timeout_seconds'] as num?)?.toInt() ?? 20;
|
final seconds = (sessionData?['confirmation_timeout_seconds'] as num?)?.toInt() ?? 20;
|
||||||
state = PairingTargetedWaitingData(
|
state = PairingTargetedWaitingData(
|
||||||
paymentSessionId: paymentSessionId,
|
paymentRequestId: paymentRequestId,
|
||||||
mitraId: mitraId,
|
mitraId: mitraId,
|
||||||
mitraName: mitraName,
|
mitraName: mitraName,
|
||||||
secondsRemaining: seconds,
|
secondsRemaining: seconds,
|
||||||
@@ -237,7 +237,7 @@ class Pairing extends _$Pairing {
|
|||||||
// Intermediate — payment session is still confirmed; show the
|
// Intermediate — payment session is still confirmed; show the
|
||||||
// bestie-unavailable popup with a "Chat dengan bestie lain" option.
|
// bestie-unavailable popup with a "Chat dengan bestie lain" option.
|
||||||
state = PairingTargetedUnavailableData(
|
state = PairingTargetedUnavailableData(
|
||||||
paymentSessionId: paymentSessionId,
|
paymentRequestId: paymentRequestId,
|
||||||
mitraName: mitraName,
|
mitraName: mitraName,
|
||||||
cause: PairingFailureCause.targetedMitraOffline,
|
cause: PairingFailureCause.targetedMitraOffline,
|
||||||
topicSensitivity: topicSensitivity,
|
topicSensitivity: topicSensitivity,
|
||||||
@@ -251,17 +251,17 @@ class Pairing extends _$Pairing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Customer-initiated cancel during a search/wait. Terminal — payment
|
/// Customer-initiated cancel during a search/wait. Terminal — payment
|
||||||
/// session moves to `failed_pairing` server-side. We route the UI to home
|
/// session moves to `failed_delivery` server-side. We route the UI to home
|
||||||
/// (NOT to the failed-pairing screen) since the customer chose this.
|
/// (NOT to the failed-pairing screen) since the customer chose this.
|
||||||
Future<void> cancelSearch() async {
|
Future<void> cancelSearch() async {
|
||||||
String? paymentSessionId;
|
String? paymentRequestId;
|
||||||
final current = state;
|
final current = state;
|
||||||
if (current is PairingSearchingData) {
|
if (current is PairingSearchingData) {
|
||||||
paymentSessionId = current.paymentSessionId;
|
paymentRequestId = current.paymentRequestId;
|
||||||
} else if (current is PairingTargetedWaitingData) {
|
} else if (current is PairingTargetedWaitingData) {
|
||||||
paymentSessionId = current.paymentSessionId;
|
paymentRequestId = current.paymentRequestId;
|
||||||
}
|
}
|
||||||
if (paymentSessionId == null) {
|
if (paymentRequestId == null) {
|
||||||
_cleanup();
|
_cleanup();
|
||||||
state = const PairingCancelledData();
|
state = const PairingCancelledData();
|
||||||
return;
|
return;
|
||||||
@@ -269,7 +269,7 @@ class Pairing extends _$Pairing {
|
|||||||
try {
|
try {
|
||||||
await _apiClient.post(
|
await _apiClient.post(
|
||||||
'/api/client/chat/chat-requests/cancel',
|
'/api/client/chat/chat-requests/cancel',
|
||||||
data: {'payment_session_id': paymentSessionId},
|
data: {'payment_request_id': paymentRequestId},
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Best-effort. Backend will still fail the payment if/when it
|
// Best-effort. Backend will still fail the payment if/when it
|
||||||
@@ -283,21 +283,21 @@ class Pairing extends _$Pairing {
|
|||||||
/// Reuses the same payment session — backend transitions back into the
|
/// Reuses the same payment session — backend transitions back into the
|
||||||
/// general-blast path.
|
/// general-blast path.
|
||||||
Future<void> fallbackToBlast({
|
Future<void> fallbackToBlast({
|
||||||
required String paymentSessionId,
|
required String paymentRequestId,
|
||||||
required TopicSensitivity topicSensitivity,
|
required TopicSensitivity topicSensitivity,
|
||||||
}) async {
|
}) async {
|
||||||
state = const PairingInitialData();
|
state = const PairingInitialData();
|
||||||
try {
|
try {
|
||||||
await _connectWebSocket();
|
await _connectWebSocket();
|
||||||
final response = await _apiClient.post(
|
final response = await _apiClient.post(
|
||||||
'/api/client/chat/chat-requests/$paymentSessionId/fallback-to-blast',
|
'/api/client/chat/chat-requests/$paymentRequestId/fallback-to-blast',
|
||||||
data: {'topic_sensitivity': topicSensitivity.value},
|
data: {'topic_sensitivity': topicSensitivity.value},
|
||||||
);
|
);
|
||||||
final data = response['data'] as Map<String, dynamic>;
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
final sessionId = data['id'] as String;
|
final sessionId = data['id'] as String;
|
||||||
state = PairingSearchingData(
|
state = PairingSearchingData(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
paymentSessionId: paymentSessionId,
|
paymentRequestId: paymentRequestId,
|
||||||
topicSensitivity: topicSensitivity,
|
topicSensitivity: topicSensitivity,
|
||||||
);
|
);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
@@ -306,7 +306,7 @@ class Pairing extends _$Pairing {
|
|||||||
if (code == 'NO_MITRA_AVAILABLE') {
|
if (code == 'NO_MITRA_AVAILABLE') {
|
||||||
state = PairingFailedData(
|
state = PairingFailedData(
|
||||||
cause: PairingFailureCause.noMitraAvailable,
|
cause: PairingFailureCause.noMitraAvailable,
|
||||||
paymentSessionId: paymentSessionId,
|
paymentRequestId: paymentRequestId,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
state = const PairingErrorData('Gagal memulai. Coba lagi.');
|
state = const PairingErrorData('Gagal memulai. Coba lagi.');
|
||||||
@@ -326,17 +326,17 @@ class Pairing extends _$Pairing {
|
|||||||
/// `confirmed` (retryable failure). Re-blasts on the same payment session.
|
/// `confirmed` (retryable failure). Re-blasts on the same payment session.
|
||||||
///
|
///
|
||||||
/// Caller should only invoke this when `state is PairingFailedData &&
|
/// Caller should only invoke this when `state is PairingFailedData &&
|
||||||
/// state.isRetryable && paymentSessionId != null && topicSensitivity != null`.
|
/// state.isRetryable && paymentRequestId != null && topicSensitivity != null`.
|
||||||
Future<void> retryBlast() async {
|
Future<void> retryBlast() async {
|
||||||
final current = state;
|
final current = state;
|
||||||
if (current is! PairingFailedData
|
if (current is! PairingFailedData
|
||||||
|| !current.isRetryable
|
|| !current.isRetryable
|
||||||
|| current.paymentSessionId == null
|
|| current.paymentRequestId == null
|
||||||
|| current.topicSensitivity == null) {
|
|| current.topicSensitivity == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await startSearch(
|
await startSearch(
|
||||||
paymentSessionId: current.paymentSessionId!,
|
paymentRequestId: current.paymentRequestId!,
|
||||||
topicSensitivity: current.topicSensitivity!,
|
topicSensitivity: current.topicSensitivity!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -388,7 +388,7 @@ class Pairing extends _$Pairing {
|
|||||||
|
|
||||||
if (type == WsMessage.pairingFailed) {
|
if (type == WsMessage.pairingFailed) {
|
||||||
final causeTag = data['cause_tag'] as String?;
|
final causeTag = data['cause_tag'] as String?;
|
||||||
final paymentSessionId = data['payment_session_id'] as String?;
|
final paymentRequestId = data['payment_request_id'] as String?;
|
||||||
// Missing flag = terminal (backward-compat with older emit sites). When
|
// Missing flag = terminal (backward-compat with older emit sites). When
|
||||||
// false, the backend kept the payment confirmed and we can re-blast.
|
// false, the backend kept the payment confirmed and we can re-blast.
|
||||||
final isRetryable = data['is_terminal'] == false;
|
final isRetryable = data['is_terminal'] == false;
|
||||||
@@ -398,7 +398,7 @@ class Pairing extends _$Pairing {
|
|||||||
_cleanup();
|
_cleanup();
|
||||||
state = PairingFailedData(
|
state = PairingFailedData(
|
||||||
cause: PairingFailureCause.fromString(causeTag),
|
cause: PairingFailureCause.fromString(causeTag),
|
||||||
paymentSessionId: paymentSessionId,
|
paymentRequestId: paymentRequestId,
|
||||||
isRetryable: isRetryable,
|
isRetryable: isRetryable,
|
||||||
topicSensitivity: carriedTopic,
|
topicSensitivity: carriedTopic,
|
||||||
);
|
);
|
||||||
@@ -409,7 +409,7 @@ class Pairing extends _$Pairing {
|
|||||||
// Intermediate — payment still confirmed. Show the bestie-unavailable
|
// Intermediate — payment still confirmed. Show the bestie-unavailable
|
||||||
// dialog (UI surfaces via state listener).
|
// dialog (UI surfaces via state listener).
|
||||||
_stopLocalCountdown();
|
_stopLocalCountdown();
|
||||||
final paymentSessionId = data['payment_session_id'] as String?;
|
final paymentRequestId = data['payment_request_id'] as String?;
|
||||||
// Pull mitra name + topic from the prior targeted-waiting state (we know it from
|
// Pull mitra name + topic from the prior targeted-waiting state (we know it from
|
||||||
// the request payload). If we somehow lost it, fall back to safe defaults.
|
// the request payload). If we somehow lost it, fall back to safe defaults.
|
||||||
String mitraName = 'Bestie';
|
String mitraName = 'Bestie';
|
||||||
@@ -419,7 +419,7 @@ class Pairing extends _$Pairing {
|
|||||||
carriedTopic = current.topicSensitivity;
|
carriedTopic = current.topicSensitivity;
|
||||||
}
|
}
|
||||||
state = PairingTargetedUnavailableData(
|
state = PairingTargetedUnavailableData(
|
||||||
paymentSessionId: paymentSessionId ?? (current is PairingTargetedWaitingData ? current.paymentSessionId : ''),
|
paymentRequestId: paymentRequestId ?? (current is PairingTargetedWaitingData ? current.paymentRequestId : ''),
|
||||||
mitraName: mitraName,
|
mitraName: mitraName,
|
||||||
topicSensitivity: carriedTopic,
|
topicSensitivity: carriedTopic,
|
||||||
cause: type == WsMessage.returningChatTimeout
|
cause: type == WsMessage.returningChatTimeout
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import '../../../core/pairing/pairing_notifier.dart';
|
|||||||
/// Terminal failed-pairing screen.
|
/// Terminal failed-pairing screen.
|
||||||
///
|
///
|
||||||
/// Reached when the pairing notifier transitions to [PairingFailedData]
|
/// Reached when the pairing notifier transitions to [PairingFailedData]
|
||||||
/// (terminal — payment session is `failed_pairing` server-side, audit row
|
/// (terminal — payment session is `failed_delivery` server-side, audit row
|
||||||
/// recorded). Copy is intentionally identical regardless of `cause_tag` for
|
/// recorded). Copy is intentionally identical regardless of `cause_tag` for
|
||||||
/// now (the design pass will revise this later).
|
/// now (the design pass will revise this later).
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
|||||||
if (draft.targetedMitraId != null) {
|
if (draft.targetedMitraId != null) {
|
||||||
// ignore: discarded_futures
|
// ignore: discarded_futures
|
||||||
ref.read(pairingProvider.notifier).startTargetedSearch(
|
ref.read(pairingProvider.notifier).startTargetedSearch(
|
||||||
paymentSessionId: draft.paymentId!,
|
paymentRequestId: draft.paymentId!,
|
||||||
mitraId: draft.targetedMitraId!,
|
mitraId: draft.targetedMitraId!,
|
||||||
mitraName: draft.targetedMitraName ?? 'Bestie',
|
mitraName: draft.targetedMitraName ?? 'Bestie',
|
||||||
topicSensitivity: draft.topicSensitivity,
|
topicSensitivity: draft.topicSensitivity,
|
||||||
@@ -80,7 +80,7 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
|||||||
}
|
}
|
||||||
// ignore: discarded_futures
|
// ignore: discarded_futures
|
||||||
ref.read(pairingProvider.notifier).startSearch(
|
ref.read(pairingProvider.notifier).startSearch(
|
||||||
paymentSessionId: draft.paymentId!,
|
paymentRequestId: draft.paymentId!,
|
||||||
topicSensitivity: draft.topicSensitivity,
|
topicSensitivity: draft.topicSensitivity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ class _SearchingScreenState extends ConsumerState<SearchingScreen> {
|
|||||||
context,
|
context,
|
||||||
variant: BestieOfflineVariant.returning,
|
variant: BestieOfflineVariant.returning,
|
||||||
mitraName: next.mitraName,
|
mitraName: next.mitraName,
|
||||||
paymentSessionId: next.paymentSessionId,
|
paymentRequestId: next.paymentRequestId,
|
||||||
topicSensitivity: next.topicSensitivity,
|
topicSensitivity: next.topicSensitivity,
|
||||||
).then((_) {
|
).then((_) {
|
||||||
if (mounted) _unavailableDialogShown = false;
|
if (mounted) _unavailableDialogShown = false;
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class _TargetedWaitingScreenState extends ConsumerState<TargetedWaitingScreen> {
|
|||||||
context,
|
context,
|
||||||
variant: BestieOfflineVariant.returning,
|
variant: BestieOfflineVariant.returning,
|
||||||
mitraName: next.mitraName,
|
mitraName: next.mitraName,
|
||||||
paymentSessionId: next.paymentSessionId,
|
paymentRequestId: next.paymentRequestId,
|
||||||
topicSensitivity: next.topicSensitivity,
|
topicSensitivity: next.topicSensitivity,
|
||||||
).then((_) {
|
).then((_) {
|
||||||
if (mounted) _popupShown = false;
|
if (mounted) _popupShown = false;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import '../../support/widgets/tanya_admin_sheet.dart';
|
|||||||
/// payment session exists yet, so the "cari bestie lain" CTA resets the
|
/// payment session exists yet, so the "cari bestie lain" CTA resets the
|
||||||
/// payment draft and pushes `/payment/entry` for a fresh blast-payment
|
/// payment draft and pushes `/payment/entry` for a fresh blast-payment
|
||||||
/// flow. This branch never calls [Pairing.fallbackToBlast] because there's
|
/// flow. This branch never calls [Pairing.fallbackToBlast] because there's
|
||||||
/// no `paymentSessionId` to attach to.
|
/// no `paymentRequestId` to attach to.
|
||||||
/// - [BestieOfflineVariant.new_] — the customer triggered a general blast
|
/// - [BestieOfflineVariant.new_] — the customer triggered a general blast
|
||||||
/// that bottomed out (no online besties). No fallback button; just a
|
/// that bottomed out (no online besties). No fallback button; just a
|
||||||
/// ghost `tanya admin` and a `kembali ke home` exit.
|
/// ghost `tanya admin` and a `kembali ke home` exit.
|
||||||
@@ -35,14 +35,14 @@ enum BestieOfflineVariant { returning, prePayReturning, new_ }
|
|||||||
class BestieOfflinePopup extends ConsumerWidget {
|
class BestieOfflinePopup extends ConsumerWidget {
|
||||||
final BestieOfflineVariant variant;
|
final BestieOfflineVariant variant;
|
||||||
final String mitraName;
|
final String mitraName;
|
||||||
final String? paymentSessionId;
|
final String? paymentRequestId;
|
||||||
final TopicSensitivity? topicSensitivity;
|
final TopicSensitivity? topicSensitivity;
|
||||||
|
|
||||||
const BestieOfflinePopup({
|
const BestieOfflinePopup({
|
||||||
super.key,
|
super.key,
|
||||||
required this.variant,
|
required this.variant,
|
||||||
required this.mitraName,
|
required this.mitraName,
|
||||||
this.paymentSessionId,
|
this.paymentRequestId,
|
||||||
this.topicSensitivity,
|
this.topicSensitivity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ class BestieOfflinePopup extends ConsumerWidget {
|
|||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required BestieOfflineVariant variant,
|
required BestieOfflineVariant variant,
|
||||||
required String mitraName,
|
required String mitraName,
|
||||||
String? paymentSessionId,
|
String? paymentRequestId,
|
||||||
TopicSensitivity? topicSensitivity,
|
TopicSensitivity? topicSensitivity,
|
||||||
}) {
|
}) {
|
||||||
return showDialog<void>(
|
return showDialog<void>(
|
||||||
@@ -60,7 +60,7 @@ class BestieOfflinePopup extends ConsumerWidget {
|
|||||||
builder: (_) => BestieOfflinePopup(
|
builder: (_) => BestieOfflinePopup(
|
||||||
variant: variant,
|
variant: variant,
|
||||||
mitraName: mitraName,
|
mitraName: mitraName,
|
||||||
paymentSessionId: paymentSessionId,
|
paymentRequestId: paymentRequestId,
|
||||||
topicSensitivity: topicSensitivity,
|
topicSensitivity: topicSensitivity,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -83,7 +83,7 @@ class BestieOfflinePopup extends ConsumerWidget {
|
|||||||
|
|
||||||
final canFallbackToBlast = isReturning &&
|
final canFallbackToBlast = isReturning &&
|
||||||
hasOtherAvailable &&
|
hasOtherAvailable &&
|
||||||
paymentSessionId != null &&
|
paymentRequestId != null &&
|
||||||
topicSensitivity != null;
|
topicSensitivity != null;
|
||||||
|
|
||||||
return Dialog(
|
return Dialog(
|
||||||
@@ -145,7 +145,7 @@ class BestieOfflinePopup extends ConsumerWidget {
|
|||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
// ignore: discarded_futures
|
// ignore: discarded_futures
|
||||||
ref.read(pairingProvider.notifier).fallbackToBlast(
|
ref.read(pairingProvider.notifier).fallbackToBlast(
|
||||||
paymentSessionId: paymentSessionId!,
|
paymentRequestId: paymentRequestId!,
|
||||||
topicSensitivity: topicSensitivity!,
|
topicSensitivity: topicSensitivity!,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import '../../../core/auth/auth_notifier.dart';
|
|||||||
|
|
||||||
/// One row in the Chat Tab > Pembayaran sub-tab.
|
/// One row in the Chat Tab > Pembayaran sub-tab.
|
||||||
///
|
///
|
||||||
/// Mirrors the response of `GET /api/client/payment-sessions/pending`. A row
|
/// Mirrors the response of `GET /api/client/payment-requests/pending`. A row
|
||||||
/// is either an initial-session payment (`isExtension == false`) — for which
|
/// is either an initial-session payment (`isExtension == false`) — for which
|
||||||
/// mitra info is only present in the targeted "Curhat lagi" flow — or an
|
/// mitra info is only present in the targeted "Curhat lagi" flow — or an
|
||||||
/// extension payment (`isExtension == true`) — mitra info resolved by the
|
/// extension payment (`isExtension == true`) — mitra info resolved by the
|
||||||
@@ -80,7 +80,7 @@ final pendingPaymentsProvider =
|
|||||||
if (customerId == null) return PendingPaymentsData.empty;
|
if (customerId == null) return PendingPaymentsData.empty;
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final response =
|
final response =
|
||||||
await api.get('/api/client/payment-sessions/pending');
|
await api.get('/api/client/payment-requests/pending');
|
||||||
final data = response['data'] as Map<String, dynamic>? ?? const {};
|
final data = response['data'] as Map<String, dynamic>? ?? const {};
|
||||||
final items = (data['items'] as List<dynamic>? ?? [])
|
final items = (data['items'] as List<dynamic>? ?? [])
|
||||||
.cast<Map<String, dynamic>>()
|
.cast<Map<String, dynamic>>()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import '../../../core/theme/widgets/halo_button.dart';
|
|||||||
import '../state/payment_draft_provider.dart';
|
import '../state/payment_draft_provider.dart';
|
||||||
|
|
||||||
/// "Cara bayar" — QRIS-first list of payment methods. On tap of `bayar`:
|
/// "Cara bayar" — QRIS-first list of payment methods. On tap of `bayar`:
|
||||||
/// 1. POST `/api/client/payment-sessions` with the draft + chosen method.
|
/// 1. POST `/api/client/payment-requests` with the draft + chosen method.
|
||||||
/// 2. Push `/payment/waiting/:paymentId`.
|
/// 2. Push `/payment/waiting/:paymentId`.
|
||||||
class PaymentMethodScreen extends ConsumerStatefulWidget {
|
class PaymentMethodScreen extends ConsumerStatefulWidget {
|
||||||
const PaymentMethodScreen({super.key});
|
const PaymentMethodScreen({super.key});
|
||||||
@@ -72,7 +72,7 @@ class _PaymentMethodScreenState extends ConsumerState<PaymentMethodScreen> {
|
|||||||
};
|
};
|
||||||
// Trailing slash matches the existing payment_notifier path — Fastify
|
// Trailing slash matches the existing payment_notifier path — Fastify
|
||||||
// is not configured with `ignoreTrailingSlash`.
|
// is not configured with `ignoreTrailingSlash`.
|
||||||
final response = await api.post('/api/client/payment-sessions/', data: body);
|
final response = await api.post('/api/client/payment-requests/', data: body);
|
||||||
final data = response['data'] as Map<String, dynamic>;
|
final data = response['data'] as Map<String, dynamic>;
|
||||||
final paymentId = data['id'] as String;
|
final paymentId = data['id'] as String;
|
||||||
ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId);
|
ref.read(paymentDraftNotifierProvider.notifier).setPaymentId(paymentId);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../../../core/api/api_client_provider.dart';
|
import '../../../core/api/api_client_provider.dart';
|
||||||
import '../../../core/constants.dart';
|
import '../../../core/constants.dart';
|
||||||
import '../../../core/theme/halo_tokens.dart';
|
import '../../../core/theme/halo_tokens.dart';
|
||||||
@@ -36,6 +37,7 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
|
|||||||
bool _initialLoading = true;
|
bool _initialLoading = true;
|
||||||
bool _terminal = false;
|
bool _terminal = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
bool _invoiceUrlLaunched = false; // Phase 5: only auto-launch the Custom Tab once
|
||||||
|
|
||||||
Duration get _remaining {
|
Duration get _remaining {
|
||||||
final exp = _expiresAt;
|
final exp = _expiresAt;
|
||||||
@@ -80,11 +82,34 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
|
|||||||
_qrPayload = (session['qr_string'] as String?) ?? widget.paymentId;
|
_qrPayload = (session['qr_string'] as String?) ?? widget.paymentId;
|
||||||
_initialLoading = false;
|
_initialLoading = false;
|
||||||
});
|
});
|
||||||
|
// Phase 5: when Xendit is on, the backend returns an `xendit_invoice_url`
|
||||||
|
// (Xendit's hosted checkout). Open it in a Custom Tab (Android) /
|
||||||
|
// SFSafariViewController (iOS) so the customer stays inside the app's
|
||||||
|
// browser context. Fire-and-forget — polling continues regardless.
|
||||||
|
// When Xendit is off (dev/Maestro), invoice_url is null and the QR fallback below is used.
|
||||||
|
await _maybeLaunchInvoiceUrl(session);
|
||||||
_maybeHandleStatus(session);
|
_maybeHandleStatus(session);
|
||||||
_startTicker();
|
_startTicker();
|
||||||
_resumePolling();
|
_resumePolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _maybeLaunchInvoiceUrl(Map<String, dynamic> session) async {
|
||||||
|
if (_invoiceUrlLaunched) return;
|
||||||
|
final url = (session['xendit_invoice_url'] as String?) ?? (session['invoice_url'] as String?);
|
||||||
|
if (url == null || url.isEmpty) return;
|
||||||
|
_invoiceUrlLaunched = true;
|
||||||
|
try {
|
||||||
|
await launchUrl(
|
||||||
|
Uri.parse(url),
|
||||||
|
mode: LaunchMode.inAppBrowserView, // Custom Tab on Android, SFVC on iOS
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Silent — polling will eventually resolve to expired if the customer can't pay.
|
||||||
|
// Don't surface an error toast; the user might have a non-Custom-Tab-capable env
|
||||||
|
// and url_launcher falls back to the system browser automatically.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _startTicker() {
|
void _startTicker() {
|
||||||
_ticker?.cancel();
|
_ticker?.cancel();
|
||||||
_ticker = Timer.periodic(_tickInterval, (_) {
|
_ticker = Timer.periodic(_tickInterval, (_) {
|
||||||
@@ -111,7 +136,7 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
|
|||||||
Future<Map<String, dynamic>?> _fetchSession() async {
|
Future<Map<String, dynamic>?> _fetchSession() async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final response = await api.get('/api/client/payment-sessions/${widget.paymentId}');
|
final response = await api.get('/api/client/payment-requests/${widget.paymentId}');
|
||||||
return response['data'] as Map<String, dynamic>?;
|
return response['data'] as Map<String, dynamic>?;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
@@ -122,12 +147,12 @@ class _WaitingPaymentScreenState extends ConsumerState<WaitingPaymentScreen>
|
|||||||
|
|
||||||
void _maybeHandleStatus(Map<String, dynamic> session) {
|
void _maybeHandleStatus(Map<String, dynamic> session) {
|
||||||
final status = session['status'] as String?;
|
final status = session['status'] as String?;
|
||||||
if (status == PaymentSessionStatus.confirmed ||
|
if (status == PaymentRequestStatus.confirmed ||
|
||||||
status == PaymentSessionStatus.consumed) {
|
status == PaymentRequestStatus.consumed) {
|
||||||
_markTerminal();
|
_markTerminal();
|
||||||
_navigateTerminal('/onboarding/notif-gate');
|
_navigateTerminal('/onboarding/notif-gate');
|
||||||
} else if (status == PaymentSessionStatus.expired ||
|
} else if (status == PaymentRequestStatus.expired ||
|
||||||
status == PaymentSessionStatus.abandoned) {
|
status == PaymentRequestStatus.abandoned) {
|
||||||
_markTerminal();
|
_markTerminal();
|
||||||
_navigateTerminal('/payment/expired/${widget.paymentId}');
|
_navigateTerminal('/payment/expired/${widget.paymentId}');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const PairingFailureCause = Object.freeze({
|
|||||||
TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline',
|
TARGETED_MITRA_OFFLINE: 'targeted_mitra_offline',
|
||||||
TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected',
|
TARGETED_MITRA_REJECTED: 'targeted_mitra_rejected',
|
||||||
TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout',
|
TARGETED_MITRA_TIMEOUT: 'targeted_mitra_timeout',
|
||||||
PAYMENT_SESSION_EXPIRED: 'payment_session_expired',
|
PAYMENT_REQUEST_EXPIRED: 'payment_request_expired',
|
||||||
CUSTOMER_CANCELLED: 'customer_cancelled',
|
CUSTOMER_CANCELLED: 'customer_cancelled',
|
||||||
EXTENSION_REJECTED: 'extension_rejected',
|
EXTENSION_REJECTED: 'extension_rejected',
|
||||||
EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped',
|
EXTENSION_SAFEGUARD_TRIPPED: 'extension_safeguard_tripped',
|
||||||
@@ -20,7 +20,7 @@ export const PairingFailureCauseLabel = Object.freeze({
|
|||||||
[PairingFailureCause.TARGETED_MITRA_OFFLINE]: 'Targeted mitra offline',
|
[PairingFailureCause.TARGETED_MITRA_OFFLINE]: 'Targeted mitra offline',
|
||||||
[PairingFailureCause.TARGETED_MITRA_REJECTED]: 'Targeted mitra rejected',
|
[PairingFailureCause.TARGETED_MITRA_REJECTED]: 'Targeted mitra rejected',
|
||||||
[PairingFailureCause.TARGETED_MITRA_TIMEOUT]: 'Targeted mitra timeout',
|
[PairingFailureCause.TARGETED_MITRA_TIMEOUT]: 'Targeted mitra timeout',
|
||||||
[PairingFailureCause.PAYMENT_SESSION_EXPIRED]: 'Payment session expired',
|
[PairingFailureCause.PAYMENT_REQUEST_EXPIRED]: 'Payment session expired',
|
||||||
[PairingFailureCause.CUSTOMER_CANCELLED]: 'Customer cancelled',
|
[PairingFailureCause.CUSTOMER_CANCELLED]: 'Customer cancelled',
|
||||||
[PairingFailureCause.EXTENSION_REJECTED]: 'Extension rejected',
|
[PairingFailureCause.EXTENSION_REJECTED]: 'Extension rejected',
|
||||||
[PairingFailureCause.EXTENSION_SAFEGUARD_TRIPPED]: 'Extension safeguard tripped',
|
[PairingFailureCause.EXTENSION_SAFEGUARD_TRIPPED]: 'Extension safeguard tripped',
|
||||||
|
|||||||
1326
requirement/phase5-xendit-plan.md
Normal file
1326
requirement/phase5-xendit-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user