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