Files
halobestie-clone/backend/src/db/migrate.js
Ramadhan Sjamsani 495eb98787 fix(db): widen customer_transactions.type to VARCHAR(128)
TransactionType.FIRST_SESSION_DISCOUNT ('first_session_discount', 22 chars) overflowed the VARCHAR(20) column, throwing in acceptPairingRequest AFTER the session was flipped to ACTIVE but before startSessionTimer/startSessionListener/PAIRED-notify ran. Every first-session-discount pairing thus half-completed: lost transaction row, no server-side timer, and a 500 to the mitra so its app never opened the chat. Widen the column (CREATE TABLE + idempotent ALTER). Deferred hardening (bookkeeping INSERT in the critical path) logged in TECH_DEBT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:27:07 +08:00

1367 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dotenv/config'
import { getDb } from './client.js'
const sql = getDb()
const migrate = async () => {
await sql`
CREATE EXTENSION IF NOT EXISTS "pgcrypto"
`
await sql`
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL UNIQUE,
permissions JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(255) UNIQUE,
phone VARCHAR(20) UNIQUE,
display_name VARCHAR(100) NOT NULL,
is_anonymous BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS mitras (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(255) UNIQUE,
phone VARCHAR(20) NOT NULL UNIQUE,
display_name VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS control_center_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
firebase_uid VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
display_name VARCHAR(100) NOT NULL,
role_id UUID NOT NULL REFERENCES roles(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS app_config (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('anonymity', '{"enabled": false}')
ON CONFLICT (key) DO NOTHING
`
// --- Phase 2: Mitra Online Status & Pairing ---
await sql`
CREATE TABLE IF NOT EXISTS mitra_online_status (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mitra_id UUID NOT NULL UNIQUE REFERENCES mitras(id),
is_online BOOLEAN NOT NULL DEFAULT FALSE,
last_online_at TIMESTAMPTZ,
last_offline_at TIMESTAMPTZ,
last_heartbeat_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS mitra_online_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mitra_id UUID NOT NULL REFERENCES mitras(id),
status VARCHAR(10) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_mitra_online_logs_mitra_id
ON mitra_online_logs (mitra_id)
`
await sql`
CREATE TABLE IF NOT EXISTS chat_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
mitra_id UUID REFERENCES mitras(id),
status VARCHAR(30) NOT NULL DEFAULT 'searching',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
paired_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
ended_by VARCHAR(20)
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_customer_id
ON chat_sessions (customer_id)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_mitra_id
ON chat_sessions (mitra_id)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_status
ON chat_sessions (status)
`
// Composite index for the per-mitra active-session count subquery used by the
// 5s availability poll and the per-blast capacity filter.
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_mitra_status
ON chat_sessions (mitra_id, status)
`
await sql`
CREATE TABLE IF NOT EXISTS chat_request_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES chat_sessions(id),
mitra_id UUID NOT NULL REFERENCES mitras(id),
notified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
response VARCHAR(20),
responded_at TIMESTAMPTZ
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_request_notifications_session_id
ON chat_request_notifications (session_id)
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('max_customers_per_mitra', '{"value": 3}')
ON CONFLICT (key) DO NOTHING
`
// --- Phase 3: Chat Engine ---
// Add session duration/pricing columns to chat_sessions
await sql`
ALTER TABLE chat_sessions
ADD COLUMN IF NOT EXISTS duration_minutes INT,
ADD COLUMN IF NOT EXISTS price INT DEFAULT 0,
ADD COLUMN IF NOT EXISTS is_free_trial BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS extended_minutes INT NOT NULL DEFAULT 0
`
// Add FCM token columns
await sql`
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(255)
`
await sql`
ALTER TABLE mitras
ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(255)
`
await sql`
CREATE TABLE IF NOT EXISTS chat_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES chat_sessions(id),
sender_type VARCHAR(10) NOT NULL,
sender_id UUID NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'text',
content TEXT NOT NULL,
metadata JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'sent',
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created
ON chat_messages (session_id, created_at)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_status
ON chat_messages (session_id, status)
`
await sql`
CREATE TABLE IF NOT EXISTS session_closures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES chat_sessions(id),
user_type VARCHAR(10) NOT NULL,
user_id UUID NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS session_extensions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES chat_sessions(id),
requested_duration_minutes INT NOT NULL,
requested_price INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
responded_at TIMESTAMPTZ
)
`
await sql`
CREATE TABLE IF NOT EXISTS customer_transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
session_id UUID NOT NULL REFERENCES chat_sessions(id),
type VARCHAR(128) NOT NULL,
amount INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
// Idempotent widen for DBs created when this column was VARCHAR(20): the
// TransactionType.FIRST_SESSION_DISCOUNT value 'first_session_discount' is
// 22 chars and overflowed varchar(20), throwing in acceptPairingRequest()
// *after* the session was already marked ACTIVE — losing the transaction row,
// the server-side timer, the PAIRED WS notify, and returning 500 to the mitra.
await sql`ALTER TABLE customer_transactions ALTER COLUMN type TYPE VARCHAR(128)`
await sql`
CREATE INDEX IF NOT EXISTS idx_customer_transactions_customer_id
ON customer_transactions (customer_id)
`
// Phase 3 config keys
await sql`
INSERT INTO app_config (key, value)
VALUES ('free_trial_enabled', '{"value": true}')
ON CONFLICT (key) DO NOTHING
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('free_trial_duration_minutes', '{"value": 5}')
ON CONFLICT (key) DO NOTHING
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('extension_timeout_seconds', '{"value": 60}')
ON CONFLICT (key) DO NOTHING
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('early_end_mitra_enabled', '{"value": false}')
ON CONFLICT (key) DO NOTHING
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('early_end_customer_enabled', '{"value": false}')
ON CONFLICT (key) DO NOTHING
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('price_tiers', ${sql.json({ tiers: [
{ duration_minutes: 1, price: 5000, label: '1 Menit (Test)' },
{ duration_minutes: 15, price: 30000, label: '15 Menit' },
{ duration_minutes: 30, price: 60000, label: '30 Menit' },
{ duration_minutes: 45, price: 100000, label: '45 Menit' },
{ duration_minutes: 60, price: 150000, label: '60 Menit' },
{ duration_minutes: 1440, price: 250000, label: '24 Jam' },
]})})
ON CONFLICT (key) DO NOTHING
`
// --- Phase 3.1: Mitra Ping Config ---
await sql`
INSERT INTO app_config (key, value)
VALUES ('require_mitra_ping', '{"value": true}')
ON CONFLICT (key) DO NOTHING
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('mitra_ping_interval_seconds', '{"value": 15}')
ON CONFLICT (key) DO NOTHING
`
// Mitra reachability — replaces the implicit `ping_interval * 3` grace
// window with an operator-facing "max heartbeat age" knob. The companion
// heartbeat cadence lives in env (MITRA_HEARTBEAT_CADENCE_SECONDS, default
// 30s). Default 45s keeps the same effective grace as the old 15s ping × 3.
// `mitra_ping_interval_seconds` is left in place (vestigial) — no live code
// path reads it anymore; safe to drop after one release.
await sql`
INSERT INTO app_config (key, value)
VALUES ('mitra_stale_after_seconds', '{"value": 45}')
ON CONFLICT (key) DO NOTHING
`
// --- Phase 3.2: Mitra Request Activity Log ---
await sql`
ALTER TABLE chat_request_notifications
ADD COLUMN IF NOT EXISTS active_session_count INT NOT NULL DEFAULT 0
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_request_notifications_mitra_notified
ON chat_request_notifications (mitra_id, notified_at)
`
// --- Phase 3.3: Session Topic Sensitivity ---
await sql`
ALTER TABLE chat_sessions
ADD COLUMN IF NOT EXISTS topic_sensitivity VARCHAR(16) NOT NULL DEFAULT 'regular'
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_topic_sensitivity
ON chat_sessions (topic_sensitivity)
`
await sql`
CREATE TABLE IF NOT EXISTS session_sensitivity_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
changed_by_mitra_id UUID NOT NULL REFERENCES mitras(id),
from_value VARCHAR(16) NOT NULL,
to_value VARCHAR(16) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_session_sensitivity_log_session
ON session_sensitivity_log (session_id)
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('sensitive_flip_confirmation_enabled', '{"value": true}')
ON CONFLICT (key) DO NOTHING
`
await sql`
INSERT INTO app_config (key, value)
VALUES ('sensitive_flag_one_way_latch', '{"value": false}')
ON CONFLICT (key) DO NOTHING
`
// --- Phase 3.4: Self-Managed Auth ---
// Customers: add social identity columns
await sql`
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS email VARCHAR(255),
ADD COLUMN IF NOT EXISTS google_sub VARCHAR(255),
ADD COLUMN IF NOT EXISTS apple_sub VARCHAR(255)
`
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_google_sub ON customers (google_sub) WHERE google_sub IS NOT NULL`
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_apple_sub ON customers (apple_sub) WHERE apple_sub IS NOT NULL`
// display_name is set after sign-in via the set-display-name screen for
// direct phone/Google/Apple sign-ups (no anonymous bootstrap). Allow null.
await sql`ALTER TABLE customers ALTER COLUMN display_name DROP NOT NULL`
// Control center users: password-based auth columns
// firebase_uid stays for backward compat during migration; will be dropped in a later cleanup migration
await sql`ALTER TABLE control_center_users ALTER COLUMN firebase_uid DROP NOT NULL`
await sql`
ALTER TABLE control_center_users
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(60) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS failed_login_count INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS lockout_until TIMESTAMPTZ
`
// Auth sessions (refresh tokens + multi-device)
await sql`
CREATE TABLE IF NOT EXISTS auth_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_type VARCHAR(16) NOT NULL,
user_id UUID NOT NULL,
refresh_token_hash VARCHAR(60) NOT NULL,
device_info JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ
)
`
await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions (user_type, user_id)`
await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at) WHERE revoked_at IS NULL`
// OTP requests (Fazpass reference + rate-limit tracking)
//
// Storage shape rationale:
// - is_bypass : explicit intent flag — true only when a row was created by
// the test-OTP-bypass allowlist (phone-scoped static OTP for
// App Store reviewers). Verify routes on this flag, NOT on
// the mere presence of code_hash.
// - code_hash : bcrypt hash of the OTP code, present whenever the backend
// owns verification (stub-mode rows + bypass rows). NULL when
// Fazpass owns verification (post-cutover, non-bypass rows).
// - CHECK constraint: bypass rows MUST have code_hash and MUST NOT carry a
// Fazpass reference — physically prevents a bypass row from
// ever falling into the Fazpass-verify path.
await sql`
CREATE TABLE IF NOT EXISTS otp_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone VARCHAR(20) NOT NULL,
ip_address VARCHAR(45),
user_type VARCHAR(16) NOT NULL,
fazpass_reference VARCHAR(255),
channel VARCHAR(16),
attempts INT NOT NULL DEFAULT 0,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
is_bypass BOOLEAN NOT NULL DEFAULT FALSE,
code_hash VARCHAR(255)
)
`
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_phone_created ON otp_requests (phone, created_at)`
await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_ip_created ON otp_requests (ip_address, created_at)`
// Idempotent ALTERs for DBs created before is_bypass/code_hash were added.
await sql`
ALTER TABLE otp_requests
ADD COLUMN IF NOT EXISTS is_bypass BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS code_hash VARCHAR(255)
`
// Drop-then-add lets us tighten the invariant later without writing a v2.
// The constraint is defense-in-depth alongside the verifyOtp branching: even
// if app code regressed, the DB refuses to insert a corrupt bypass row.
await sql`ALTER TABLE otp_requests DROP CONSTRAINT IF EXISTS otp_requests_bypass_shape`
await sql`
ALTER TABLE otp_requests
ADD CONSTRAINT otp_requests_bypass_shape CHECK (
is_bypass = FALSE OR (
is_bypass = TRUE
AND code_hash IS NOT NULL
AND fazpass_reference IS NULL
)
)
`
// Auth-related app_config defaults
await sql`
INSERT INTO app_config (key, value) VALUES
('otp_max_per_phone_per_hour', '{"value": 3}'),
('otp_max_per_ip_per_hour', '{"value": 10}'),
('otp_resend_cooldown_seconds', '{"value": 60}'),
('otp_verify_max_attempts', '{"value": 5}'),
('cc_login_max_attempts', '{"value": 5}'),
('cc_login_lockout_minutes', '{"value": 15}')
ON CONFLICT (key) DO NOTHING
`
// --- Phase 3.7: Paid Pairing Flow + Returning-Chat + Extension Flip ---
// 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`
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,
duration_minutes INTEGER NOT NULL,
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_delivery','abandoned','expired','failed')),
targeted_mitra_id UUID REFERENCES mitras(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
confirmed_at TIMESTAMPTZ,
consumed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_payment_requests_customer
ON payment_requests (customer_id)
`
await sql`
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_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
CHECK (cause_tag IN (
'no_mitra_available',
'all_mitras_rejected',
'targeted_mitra_offline',
'targeted_mitra_rejected',
'targeted_mitra_timeout',
'payment_request_expired',
'customer_cancelled'
)),
amount INTEGER NOT NULL,
operator_action TEXT
CHECK (operator_action IS NULL OR operator_action IN ('refunded','credited','no_action')),
actioned_by UUID REFERENCES control_center_users(id),
actioned_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
// Phase 3.7 follow-up: extend the pairing_failures.cause_tag CHECK to include the two
// extension-specific tags. Idempotent: drop the existing check (whatever its exact name) and
// re-add the expanded list. Postgres auto-names CHECK constraints `<table>_<column>_check`
// unless we name them explicitly; the original DDL above relies on that default.
await sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'pairing_failures'::regclass
AND conname = 'pairing_failures_cause_tag_check'
) THEN
ALTER TABLE pairing_failures DROP CONSTRAINT pairing_failures_cause_tag_check;
END IF;
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',
-- Phase 5 rename: payment_session_expired → payment_request_expired
'payment_request_expired',
'customer_cancelled',
'extension_rejected',
'extension_safeguard_tripped'
));
END
$$
`
await sql`
CREATE INDEX IF NOT EXISTS idx_pairing_failures_created_at
ON pairing_failures (created_at DESC)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_pairing_failures_cause
ON pairing_failures (cause_tag)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_pairing_failures_unactioned
ON pairing_failures (created_at DESC) WHERE operator_action IS NULL
`
// 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_request_id UUID REFERENCES payment_requests(id)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment_request
ON chat_sessions (payment_request_id)
`
// 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_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_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}')
ON CONFLICT (key) DO NOTHING
`
// --- Phase 4 — Customer Flow Redesign ---
// 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_requests
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
`
await sql`
ALTER TABLE chat_sessions
ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
`
// Copy values from the legacy column to the new one. We must use dynamic SQL
// (EXECUTE) inside the DO block — a static reference to is_free_trial would fail
// to parse when the column has already been dropped on a previous re-run.
//
// The IF EXISTS check resolves the column against the *current* search_path so
// test schemas don't false-positive on the dev `public` schema's leftover columns.
// We use to_regclass + pg_attribute (which is search_path-aware) instead of
// information_schema.columns (which lists every schema).
await sql`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid = to_regclass('payment_requests')
AND attname = 'is_free_trial'
AND NOT attisdropped
) THEN
EXECUTE 'UPDATE payment_requests
SET is_first_session_discount = is_free_trial
WHERE is_free_trial = true
AND is_first_session_discount = false';
END IF;
IF EXISTS (
SELECT 1 FROM pg_attribute
WHERE attrelid = to_regclass('chat_sessions')
AND attname = 'is_free_trial'
AND NOT attisdropped
) THEN
EXECUTE 'UPDATE chat_sessions
SET is_first_session_discount = is_free_trial
WHERE is_free_trial = true
AND is_first_session_discount = false';
END IF;
END
$$
`
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_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_requests
ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
CHECK (mode IN ('chat', 'call'))
`
// 3. chat_sessions.topics — ESP picks persisted for info-only display to mitra.
// Does NOT affect matching, pricing, or routing.
await sql`
ALTER TABLE chat_sessions
ADD COLUMN IF NOT EXISTS topics TEXT[]
`
// 4. Phase 4 app_config rows. Use ON CONFLICT (key) DO NOTHING so re-runs don't
// clobber operator edits, and the migration is idempotent against partially
// populated DBs.
await sql`
INSERT INTO app_config (key, value) VALUES
('payment_method_qris_first', ${sql.json({ value: true })}),
('searching_timeout_minutes', ${sql.json({ value: 5 })}),
('end_session_two_step_confirm', ${sql.json({ value: true })}),
('three_minute_warning_enabled', ${sql.json({ value: true })}),
('first_session_discount_enabled', ${sql.json({ value: true })}),
('first_session_discount_actual_price_idr', ${sql.json({ value: 2000 })}),
('first_session_discount_gimmick_price_idr', ${sql.json({ value: 12000 })}),
('first_session_discount_duration_minutes', ${sql.json({ value: 12 })}),
('first_session_discount_modes', ${sql.json({ value: ['chat'] })}),
('pricing_chat_tiers_json', ${sql.json({ tiers: [
{ id: '5', minutes: 5, price_idr: 5000, tag: null },
{ id: '12', minutes: 12, price_idr: 12000, tag: 'paling pas' },
{ id: '30', minutes: 30, price_idr: 25000, tag: 'hemat' },
{ id: '60', minutes: 60, price_idr: 45000, tag: null },
{ id: '120', minutes: 120, price_idr: 80000, tag: 'best deal' },
]})}),
('pricing_call_tiers_json', ${sql.json({ tiers: [
{ id: '10', minutes: 10, price_idr: 9000, tag: null },
{ id: '20', minutes: 20, price_idr: 17000, tag: 'paling pas' },
{ id: '45', minutes: 45, price_idr: 35000, tag: null },
{ id: '60', minutes: 60, price_idr: 45000, tag: 'hemat' },
]})}),
('support_handles_json', ${sql.json({
wa: { label: 'WhatsApp', deeplink: 'https://wa.me/6285173310010' },
telegram: { label: 'Telegram', deeplink: 'https://t.me/halobestie' },
})})
ON CONFLICT (key) DO NOTHING
`
// 5. Phase 4 USP one-time gate. Customers see S5b USP at most once; this flag
// is the cross-device source of truth, OR-merged with a local
// SharedPreferences flag on the client. Existing customers come out as
// false and will see USP one more time on next "aku mau curhat" — business
// accepted this backfill cost.
await sql`
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS usp_seen BOOLEAN NOT NULL DEFAULT FALSE
`
// --- Phase 4 §2.1: Anonymous → existing-user merge breadcrumb ---
//
// When an anonymous customer verifies a phone that already belongs to a
// different (existing) customer row, we don't 409 the OTP and we don't
// delete the anon row (which would orphan its chat_sessions /
// customer_transactions). Instead we stamp account_belongs_to on the anon
// row pointing at the existing customer's id, then log the app in as the
// existing user. Actual data reconciliation (moving FKs onto the existing
// row) is deferred to a later phase — this column is the breadcrumb that
// makes the merge replayable.
await sql`
ALTER TABLE customers
ADD COLUMN IF NOT EXISTS account_belongs_to UUID REFERENCES customers(id) ON DELETE SET NULL
`
// --- Pricing relational migration — Stage 1 (schema + backfill only) ---
//
// Moves the pricing_chat_tiers_json / pricing_call_tiers_json /
// first_session_discount_* rows from app_config into dedicated relational
// tables. Stage 1 is schema-only: the live read paths in
// pricing.service.js continue to read app_config until Stage 3 cuts them
// over. The seven legacy app_config rows are NOT deleted here — that's
// Stage 5.
//
// Design notes:
// - PK is UUID (gen_random_uuid()) instead of the doc's TEXT prefix
// scheme ("chat-60"). Backend lookups go through (mode, minutes),
// not the id, so the id is purely internal — there is no benefit to
// a human-readable surrogate key, and UUIDs match the convention
// used by every other table in this schema.
// - UNIQUE (mode, minutes) on pricing_tiers and UNIQUE (eligibility)
// on pricing_promotions enforce the natural keys at the DB level.
// - History tables reference the live row via tier_id / promotion_id
// (UUID) — column rename from the doc's `id` to avoid shadowing the
// history row's own pk.
// - original_price_idr is shipped as schema-only — not exposed in
// GET /api/client/pricing in this Stage. Surfacing it to clients is
// a separate out-of-scope follow-up.
// - sort_order: operator-controlled ordering distinct from minutes.
// Backfill seeds it as the array index from the existing JSON so any
// curated order is preserved.
await sql`
CREATE TABLE IF NOT EXISTS pricing_tiers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mode TEXT NOT NULL CHECK (mode IN ('chat', 'call')),
minutes INTEGER NOT NULL CHECK (minutes > 0),
price_idr INTEGER NOT NULL CHECK (price_idr >= 0),
original_price_idr INTEGER CHECK (original_price_idr IS NULL OR original_price_idr >= price_idr),
tag TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (mode, minutes)
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_pricing_tiers_mode_active_sort
ON pricing_tiers (mode, is_active, sort_order)
`
await sql`
CREATE TABLE IF NOT EXISTS pricing_promotions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
enabled BOOLEAN NOT NULL DEFAULT true,
eligibility TEXT NOT NULL UNIQUE
CHECK (eligibility IN ('first_session')),
actual_price_idr INTEGER NOT NULL CHECK (actual_price_idr >= 0),
gimmick_price_idr INTEGER CHECK (gimmick_price_idr IS NULL OR gimmick_price_idr >= actual_price_idr),
duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0),
modes TEXT[] NOT NULL CHECK (
array_length(modes, 1) >= 1
AND modes <@ ARRAY['chat', 'call']::TEXT[]
),
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE TABLE IF NOT EXISTS pricing_tiers_history (
history_id BIGSERIAL PRIMARY KEY,
tier_id UUID NOT NULL,
mode TEXT NOT NULL,
minutes INTEGER NOT NULL,
price_idr INTEGER NOT NULL,
original_price_idr INTEGER,
tag TEXT,
sort_order INTEGER NOT NULL,
is_active BOOLEAN NOT NULL,
changed_by UUID,
change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_pricing_tiers_history_tier_time
ON pricing_tiers_history (tier_id, changed_at DESC)
`
await sql`
CREATE TABLE IF NOT EXISTS pricing_promotions_history (
history_id BIGSERIAL PRIMARY KEY,
promotion_id UUID NOT NULL,
enabled BOOLEAN NOT NULL,
eligibility TEXT NOT NULL,
actual_price_idr INTEGER NOT NULL,
gimmick_price_idr INTEGER,
duration_minutes INTEGER NOT NULL,
modes TEXT[] NOT NULL,
starts_at TIMESTAMPTZ,
ends_at TIMESTAMPTZ,
changed_by UUID,
change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_pricing_promotions_history_promo_time
ON pricing_promotions_history (promotion_id, changed_at DESC)
`
// --- Backfill: pricing_tiers ---
//
// Only seeds when the table is empty so re-runs don't clobber operator
// edits or insert duplicates. The unique(mode, minutes) constraint is a
// belt-and-suspenders backstop in case a half-finished run is retried.
//
// Source preference order per mode:
// 1. app_config.pricing_{chat,call}_tiers_json (if the row exists)
// 2. Hardcoded defaults that match DEFAULT_{CHAT,CALL}_TIERS in
// pricing.service.js. We duplicate them here rather than importing
// because migrate.js is a standalone script.
const DEFAULT_CHAT_TIERS_BACKFILL = [
{ minutes: 5, price_idr: 5000, tag: null },
{ minutes: 12, price_idr: 12000, tag: 'paling pas' },
{ minutes: 30, price_idr: 25000, tag: 'hemat' },
{ minutes: 60, price_idr: 45000, tag: null },
{ minutes: 120, price_idr: 80000, tag: 'best deal' },
]
const DEFAULT_CALL_TIERS_BACKFILL = [
{ minutes: 10, price_idr: 9000, tag: null },
{ minutes: 20, price_idr: 17000, tag: 'paling pas' },
{ minutes: 45, price_idr: 35000, tag: null },
{ minutes: 60, price_idr: 45000, tag: 'hemat' },
]
const [{ n: tierCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_tiers`
if (tierCount === 0) {
const [chatRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
const [callRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`
const chatTiers = Array.isArray(chatRow?.value?.tiers) ? chatRow.value.tiers : DEFAULT_CHAT_TIERS_BACKFILL
const callTiers = Array.isArray(callRow?.value?.tiers) ? callRow.value.tiers : DEFAULT_CALL_TIERS_BACKFILL
for (const [mode, tiers] of [['chat', chatTiers], ['call', callTiers]]) {
let order = 0
for (const t of tiers) {
await sql`
INSERT INTO pricing_tiers (mode, minutes, price_idr, tag, sort_order, is_active)
VALUES (
${mode},
${t.minutes},
${t.price_idr},
${t.tag ?? null},
${order++},
true
)
ON CONFLICT (mode, minutes) DO NOTHING
`
}
}
}
// --- Backfill: pricing_promotions (single 'first_session' row) ---
//
// Defaults below match DEFAULT_DISCOUNT in pricing.service.js. The five
// legacy app_config keys override these if present.
const [{ n: promoCount }] = await sql`
SELECT COUNT(*)::int AS n FROM pricing_promotions WHERE eligibility = 'first_session'
`
if (promoCount === 0) {
const keys = [
'first_session_discount_enabled',
'first_session_discount_actual_price_idr',
'first_session_discount_gimmick_price_idr',
'first_session_discount_duration_minutes',
'first_session_discount_modes',
]
const rows = await sql`SELECT key, value FROM app_config WHERE key IN ${sql(keys)}`
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
const enabled = byKey.first_session_discount_enabled ?? true
const actual = byKey.first_session_discount_actual_price_idr ?? 2000
const gimmick = byKey.first_session_discount_gimmick_price_idr ?? 12000
const duration = byKey.first_session_discount_duration_minutes ?? 12
const modes = Array.isArray(byKey.first_session_discount_modes)
? byKey.first_session_discount_modes
: ['chat']
await sql`
INSERT INTO pricing_promotions (
enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes
)
VALUES (
${enabled},
'first_session',
${actual},
${gimmick},
${duration},
${modes}
)
ON CONFLICT (eligibility) DO NOTHING
`
}
// --- Pricing relational migration — Stage 5 cleanup ---
//
// The seven legacy `app_config` rows below were the JSON-on-app_config
// source of truth for pricing tiers and the first-session discount, copied
// into `pricing_tiers` / `pricing_promotions` by the Stage 1 backfill above.
// Stage 3 cut every read path over to the relational tables; this delete
// removes the now-orphaned rows so operator edits in CC can't get out of
// sync with the live source.
//
// Idempotent: a fresh dev DB just deletes zero rows. A previously-migrated
// DB on this revision is a no-op. The seed of these keys in the Phase 4
// app_config INSERT block above (~line 627) uses ON CONFLICT (key) DO
// NOTHING — so even if the seed runs *after* this delete during a future
// refactor, we don't accidentally resurrect them on the next pass.
await sql`
DELETE FROM app_config
WHERE key IN (
'first_session_discount_enabled',
'first_session_discount_actual_price_idr',
'first_session_discount_gimmick_price_idr',
'first_session_discount_duration_minutes',
'first_session_discount_modes',
'pricing_chat_tiers_json',
'pricing_call_tiers_json'
)
`
// --- 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'
`
// 10. webhook_logs — survival/audit table for every inbound payment-provider
// webhook. The route handler inserts a row BEFORE auth checks or business
// logic so a forensic record exists even when the request is rejected,
// the body is malformed, or processing throws.
//
// Primary fields are extracted as columns (queryable in CC, indexed where
// useful); the full body is kept in `raw_body` JSONB so we can replay or
// diff later. `provider` is a string column (not enum) so adding a new
// payment provider doesn't require a migration.
//
// No FK to payment_requests — logs must survive even if the matching
// payment row was wiped, never existed, or arrives for a product/event
// type we don't yet model.
await sql`
CREATE TABLE IF NOT EXISTS webhook_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider TEXT NOT NULL DEFAULT 'xendit',
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Parsed primary fields (Xendit Invoice callback shape — NULL for other event types)
xendit_event_id TEXT,
external_id TEXT,
payment_request_id UUID,
status TEXT,
amount BIGINT,
currency TEXT,
payment_method TEXT,
paid_at TIMESTAMPTZ,
metadata_app TEXT,
-- Integrity + verbatim record
callback_token_valid BOOLEAN NOT NULL,
headers JSONB NOT NULL,
raw_body JSONB NOT NULL,
-- Outcome (filled by the handler after processing). Leaving these NULL
-- is itself a useful signal — it means the handler crashed before the
-- finally block could stamp the result.
http_status SMALLINT,
processing_result TEXT,
processing_error TEXT,
processed_at TIMESTAMPTZ
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_webhook_logs_received_at
ON webhook_logs (received_at DESC)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_webhook_logs_external_id
ON webhook_logs (external_id)
WHERE external_id IS NOT NULL
`
await sql`
CREATE INDEX IF NOT EXISTS idx_webhook_logs_payment_request
ON webhook_logs (payment_request_id)
WHERE payment_request_id IS NOT NULL
`
// --- Phase 5.x: Payment catalog (groups + methods) ----------------------
//
// DB-backed payment method catalog edited from control center, cached in
// Valkey by payment-catalog.service.js. Replaces the hardcoded _PayMethod
// enum in client_app/lib/features/payment/screens/payment_method_screen.dart.
// See requirement/phase5-payment-catalog-plan.md.
//
// `payment_code` stores the Xendit channel code (OVO, DANA, QRIS, BCA_VA,
// …) verbatim — no mapping layer. UPPER-CASE by convention; the service
// layer normalises incoming app-submitted codes to upper before lookup.
await sql`
CREATE TABLE IF NOT EXISTS payment_method_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
display_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_payment_method_groups_order
ON payment_method_groups (display_order)
`
await sql`
CREATE TABLE IF NOT EXISTS payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES payment_method_groups(id) ON DELETE RESTRICT,
display_name TEXT NOT NULL,
payment_code TEXT NOT NULL UNIQUE,
display_order INTEGER NOT NULL DEFAULT 0,
icon TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`
await sql`
CREATE INDEX IF NOT EXISTS idx_payment_methods_group_order
ON payment_methods (group_id, display_order)
`
// Per-method amount bounds (Phase 5.x). Both inclusive, both nullable
// (null = no bound). The customer app greys out methods whose bounds the
// current bill misses; the backend rejects with INVALID_PAYMENT_AMOUNT.
// BIGINT so we can store Xendit's published per-channel ceilings verbatim
// (some banks document up to Rp 50 billion, well past INT range).
await sql`
ALTER TABLE payment_methods
ADD COLUMN IF NOT EXISTS min_amount BIGINT,
ADD COLUMN IF NOT EXISTS max_amount BIGINT
`
// Unique on group name so we can re-run the seed below with ON CONFLICT.
await sql`
CREATE UNIQUE INDEX IF NOT EXISTS payment_method_groups_name_uq
ON payment_method_groups (name)
`
// --- Catalog seed -----------------------------------------------------------
//
// Re-runnable: groups upsert via ON CONFLICT on name; methods via ON CONFLICT
// on payment_code (already UNIQUE). Operator edits in CC are NOT clobbered
// because DO NOTHING leaves existing rows alone. New methods you add to this
// list later land on the next migration; existing methods don't get bumped.
//
// Limits are pulled from https://docs.xendit.co/docs/available-payment-channels
// (verified 2026-05-27). Amounts are inclusive Rp. Where Xendit's documented
// max exceeds 50B, we keep the literal number — `payment_requests.amount` is
// INTEGER-capped at 2.1B so we never get that high in practice; the bound is
// recorded faithfully for documentation / future raises.
const PAYMENT_CATALOG_SEED = [
{
name: 'Paling Cepat',
order: 0,
methods: [
{ code: 'QRIS', display: 'QRIS', icon: 'qris',
min: 1500, max: 20000000, active: true },
],
},
{
name: 'E-Wallet',
order: 1,
methods: [
{ code: 'OVO', display: 'OVO', icon: 'ovo-new', min: 100, max: 20000000, active: true },
{ code: 'DANA', display: 'DANA', icon: 'dana', min: 100, max: 20000000, active: true },
{ code: 'SHOPEEPAY', display: 'ShopeePay', icon: 'shopee-pay', min: 1, max: 20000000, active: true },
{ code: 'LINKAJA', display: 'LinkAja', icon: 'linkaja', min: 1, max: 20000000, active: true },
{ code: 'ASTRAPAY', display: 'AstraPay', icon: 'astra-pay', min: 100, max: 20000000, active: true },
],
},
{
name: 'Virtual Account',
order: 2,
methods: [
{ code: 'BCA_VIRTUAL_ACCOUNT', display: 'BCA Virtual Account', icon: 'bca', min: 10000, max: 50000000, active: true },
{ code: 'MANDIRI_VIRTUAL_ACCOUNT', display: 'Mandiri Virtual Account', icon: 'mandiri', min: 1, max: 50000000000n, active: true },
{ code: 'BNI_VIRTUAL_ACCOUNT', display: 'BNI Virtual Account', icon: 'bni', min: 1, max: 50000000, active: true },
{ code: 'BRI_VIRTUAL_ACCOUNT', display: 'BRI Virtual Account', icon: 'bri', min: 1, max: 50000000000n, active: true },
{ code: 'BSI_VIRTUAL_ACCOUNT', display: 'BSI Virtual Account', icon: 'bsi', min: 1, max: 50000000000n, active: true },
{ code: 'PERMATA_VIRTUAL_ACCOUNT', display: 'Permata Virtual Account', icon: 'permata', min: 1, max: 9999999999n, active: true },
{ code: 'CIMB_VIRTUAL_ACCOUNT', display: 'CIMB Virtual Account', icon: 'cimb-niaga', min: 1, max: 50000000, active: true },
{ code: 'BJB_VIRTUAL_ACCOUNT', display: 'BJB Virtual Account', icon: 'bank-bjb', min: 1, max: 2000000000, active: true },
{ code: 'BSS_VIRTUAL_ACCOUNT', display: 'BSS Virtual Account', icon: 'bank-sahabat-sampoerna', min: 1, max: 50000000000n, active: true },
],
},
{
name: 'Outlet',
order: 3,
methods: [
{ code: 'ALFAMART', display: 'Alfamart', icon: 'alfamart', min: 10000, max: 5000000, active: true },
{ code: 'INDOMARET', display: 'Indomaret', icon: 'indomaret', min: 10000, max: 2500000, active: true },
],
},
{
name: 'Kartu Kredit',
order: 4,
methods: [
// `icon` is comma-separated → backend emits multiple icon_urls; client
// renders them side-by-side on the same tile.
{ code: 'CARDS', display: 'Kartu Kredit', icon: 'visa,mastercard,jcb',
min: 5000, max: 200000000, active: true },
],
},
]
for (const group of PAYMENT_CATALOG_SEED) {
// INSERT new group OR fetch existing's id. ON CONFLICT (name) DO NOTHING
// returns no row, so we RETURNING + fallback SELECT.
const ins = await sql`
INSERT INTO payment_method_groups (name, display_order, is_active)
VALUES (${group.name}, ${group.order}, true)
ON CONFLICT (name) DO NOTHING
RETURNING id
`
let groupId
if (ins.length > 0) {
groupId = ins[0].id
} else {
const [row] = await sql`SELECT id FROM payment_method_groups WHERE name = ${group.name}`
groupId = row.id
}
let methodOrder = 0
for (const m of group.methods) {
await sql`
INSERT INTO payment_methods (
group_id, display_name, payment_code, display_order, icon,
min_amount, max_amount, is_active
)
VALUES (
${groupId},
${m.display},
${m.code},
${methodOrder++},
${m.icon},
${m.min},
${m.max},
${m.active}
)
ON CONFLICT (payment_code) DO NOTHING
`
}
}
console.log('Migration complete.')
await sql.end()
}
migrate().catch((err) => {
console.error('Migration failed:', err)
process.exit(1)
})