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:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user