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

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

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

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

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

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

View File

@@ -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()
}