import { Pool } from "pg"; import { env } from "../../config/env"; let pool = null; function buildPoolConfig() { if (env.DATABASE_URL) { return { connectionString: env.DATABASE_URL }; } return { host: env.PGHOST, port: env.PGPORT, user: env.PGUSER, password: env.PGPASSWORD, database: env.PGDATABASE }; } export function getPool() { if (!pool) { const config = buildPoolConfig(); pool = new Pool(config); } return pool; } export async function withClient(work) { const client = await getPool().connect(); try { return await work(client); } finally { client.release(); } } export async function ensureSchema() { const pool = getPool(); await pool.query(MIGRATIONS_SQL); } const MIGRATIONS_SQL = ` BEGIN; CREATE TABLE IF NOT EXISTS merchants ( id TEXT PRIMARY KEY, merchant_code TEXT NOT NULL UNIQUE, legal_name TEXT NOT NULL, brand_name TEXT, settlement_account_reference TEXT, settlement_account_type TEXT, payout_mode TEXT NOT NULL DEFAULT 'merchant_direct' CHECK (payout_mode IN ('merchant_direct', 'manual')), fee_profile_id TEXT, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), onboarding_status TEXT NOT NULL DEFAULT 'pending' CHECK (onboarding_status IN ('pending', 'approved', 'rejected')), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS outlets ( id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, outlet_code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, address TEXT, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS terminals ( id TEXT PRIMARY KEY, outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE, terminal_code TEXT NOT NULL UNIQUE, qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic_mqtt', 'dynamic_api')), partner_reference TEXT, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS devices ( id TEXT PRIMARY KEY, device_code TEXT NOT NULL UNIQUE, serial_number TEXT, vendor TEXT, model TEXT, communication_mode TEXT NOT NULL DEFAULT 'static' CHECK (communication_mode IN ('static', 'mqtt', 'api')), capability_profile_json JSONB NOT NULL DEFAULT '{}'::jsonb, auth_method TEXT, status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), last_seen_at TIMESTAMPTZ, firmware_version TEXT, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_devices_status_last_seen ON devices (status, last_seen_at DESC); CREATE TABLE IF NOT EXISTS device_bindings ( id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE, terminal_id TEXT NOT NULL REFERENCES terminals (id) ON DELETE CASCADE, active_flag BOOLEAN NOT NULL DEFAULT false, bound_at TIMESTAMPTZ NOT NULL, unbound_at TIMESTAMPTZ ); CREATE UNIQUE INDEX IF NOT EXISTS uq_device_active_binding ON device_bindings (device_id) WHERE active_flag = TRUE; CREATE INDEX IF NOT EXISTS idx_device_bindings_terminal_active ON device_bindings (terminal_id, active_flag); CREATE TABLE IF NOT EXISTS device_heartbeats ( id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, timestamp TIMESTAMPTZ NOT NULL, received_at TIMESTAMPTZ NOT NULL, firmware_version TEXT, network_strength INTEGER, battery_level INTEGER, state TEXT, payload_json JSONB NOT NULL DEFAULT '{}'::jsonb ); CREATE INDEX IF NOT EXISTS idx_device_heartbeats_device ON device_heartbeats (device_id, received_at DESC); CREATE TABLE IF NOT EXISTS device_commands ( id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, command TEXT NOT NULL, payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, status TEXT NOT NULL DEFAULT 'accepted' CHECK (status IN ('accepted', 'delivered', 'failed', 'timeout')), requested_at TIMESTAMPTZ NOT NULL, acknowledged_at TIMESTAMPTZ, result_payload_json JSONB, reason TEXT ); CREATE INDEX IF NOT EXISTS idx_device_commands_device_request ON device_commands (device_id, requested_at DESC); CREATE TABLE IF NOT EXISTS transactions ( id TEXT PRIMARY KEY, transaction_code TEXT NOT NULL UNIQUE, merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, outlet_id TEXT NOT NULL REFERENCES outlets (id) ON DELETE CASCADE, terminal_id TEXT NOT NULL REFERENCES terminals (id) ON DELETE CASCADE, device_id TEXT REFERENCES devices (id) ON DELETE SET NULL, qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic')), initiation_mode TEXT NOT NULL DEFAULT 'static' CHECK (initiation_mode IN ('static', 'manual', 'dynamic_api', 'dynamic_mqtt')), partner_reference TEXT NOT NULL UNIQUE, amount NUMERIC(20,2) NOT NULL, currency TEXT NOT NULL DEFAULT 'IDR', status TEXT NOT NULL DEFAULT 'initiated' CHECK (status IN ('initiated', 'awaiting_payment', 'paid', 'failed', 'expired', 'reversed')), created_at TIMESTAMPTZ NOT NULL, paid_at TIMESTAMPTZ, expired_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_transactions_partner_ref ON transactions (partner_reference); CREATE INDEX IF NOT EXISTS idx_transactions_merchant_created ON transactions (merchant_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_transactions_status_created ON transactions (status, created_at DESC); CREATE TABLE IF NOT EXISTS transaction_events ( id TEXT PRIMARY KEY, transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, event_type TEXT NOT NULL, source TEXT NOT NULL, payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_transaction_events_tx ON transaction_events (transaction_id, created_at DESC); CREATE TABLE IF NOT EXISTS notifications ( id TEXT PRIMARY KEY, transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, device_id TEXT REFERENCES devices (id) ON DELETE SET NULL, delivery_channel TEXT NOT NULL DEFAULT 'mqtt' CHECK (delivery_channel IN ('mqtt')), payload_type TEXT NOT NULL DEFAULT 'payment_success' CHECK (payload_type IN ('payment_success')), delivery_status TEXT NOT NULL CHECK (delivery_status IN ('queued', 'sent', 'acknowledged', 'failed', 'retrying')), retry_count INT NOT NULL DEFAULT 0, ack_status TEXT NOT NULL DEFAULT 'not_needed' CHECK (ack_status IN ('pending', 'received', 'not_supported', 'not_needed')), event_id TEXT NOT NULL, reason TEXT, payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, sent_at TIMESTAMPTZ, ack_at TIMESTAMPTZ, next_retry_at TIMESTAMPTZ, CONSTRAINT notifications_unique_tx_event UNIQUE (transaction_id, event_id) ); CREATE INDEX IF NOT EXISTS idx_notifications_device_status ON notifications (device_id, delivery_status); CREATE INDEX IF NOT EXISTS idx_notifications_status_created ON notifications (delivery_status, created_at DESC); COMMIT; `;