import { Pool, type PoolClient } from "pg"; import { env } from "../../config/env"; type PoolConfigWithConnectionString = { connectionString: string; }; type PoolConfigWithCredentials = { host: string; port: number; user: string; password: string; database: string; }; let pool: Pool | null = null; function buildPoolConfig(): PoolConfigWithConnectionString | PoolConfigWithCredentials { 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(): Pool { if (!pool) { const config = buildPoolConfig(); pool = new Pool(config); } return pool; } export async function closePool() { if (pool) { await pool.end(); pool = null; } } export async function withClient(work: (client: PoolClient) => Promise): Promise { 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 merchant_users ( id TEXT PRIMARY KEY, merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role_name TEXT NOT NULL DEFAULT 'owner' CHECK (role_name IN ('owner', 'finance', 'ops', 'viewer')), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_merchant_users_merchant ON merchant_users (merchant_id, status); CREATE TABLE IF NOT EXISTS export_jobs ( id TEXT PRIMARY KEY, job_type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')), requested_by TEXT, request_json JSONB NOT NULL DEFAULT '{}'::jsonb, result_content_type TEXT, result_filename TEXT, result_body TEXT, result_storage_path TEXT, result_size_bytes INTEGER, expires_at TIMESTAMPTZ, error_message TEXT, created_at TIMESTAMPTZ NOT NULL, started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_export_jobs_status ON export_jobs (status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_export_jobs_expires_at ON export_jobs (expires_at); 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, mqtt_username TEXT, credential_secret_fingerprint TEXT, credential_status TEXT NOT NULL DEFAULT 'not_issued' CHECK (credential_status IN ('not_issued', 'active', 'rotation_required', 'revoked')), credential_issued_at TIMESTAMPTZ, credential_rotated_at TIMESTAMPTZ, credential_revoked_at TIMESTAMPTZ, 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 INDEX IF NOT EXISTS idx_devices_serial_number ON devices (serial_number); ALTER TABLE devices ADD COLUMN IF NOT EXISTS mqtt_username TEXT; ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_secret_fingerprint TEXT; ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_status TEXT NOT NULL DEFAULT 'not_issued'; ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_issued_at TIMESTAMPTZ; ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_rotated_at TIMESTAMPTZ; ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_revoked_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_devices_credential_status ON devices (credential_status); 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 mqtt_messages ( id TEXT PRIMARY KEY, direction TEXT NOT NULL CHECK (direction IN ('uplink', 'downlink')), device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, topic TEXT NOT NULL, message_type TEXT NOT NULL, correlation_id TEXT, payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, publish_status TEXT NOT NULL DEFAULT 'recorded' CHECK (publish_status IN ('recorded', 'sent', 'failed')), reason TEXT, created_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_mqtt_messages_device_created ON mqtt_messages (device_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_mqtt_messages_correlation ON mqtt_messages (correlation_id); CREATE TABLE IF NOT EXISTS device_configs ( device_id TEXT PRIMARY KEY REFERENCES devices (id) ON DELETE CASCADE, config_version INT NOT NULL, settings_json JSONB NOT NULL DEFAULT '{}'::jsonb, updated_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS device_config_acks ( id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, config_version INT NOT NULL, status TEXT NOT NULL CHECK (status IN ('applied', 'failed')), reason TEXT, payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, acked_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_device_config_acks_device ON device_config_acks (device_id, acked_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); CREATE TABLE IF NOT EXISTS ledger_entries ( id TEXT PRIMARY KEY, transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, entry_type TEXT NOT NULL CHECK (entry_type IN ('gross_income', 'platform_fee', 'merchant_payable')), amount NUMERIC(20,2) NOT NULL, currency TEXT NOT NULL DEFAULT 'IDR', direction TEXT NOT NULL CHECK (direction IN ('credit', 'debit')), status TEXT NOT NULL DEFAULT 'posted' CHECK (status IN ('posted', 'voided')), metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT ledger_entries_unique_tx_type UNIQUE (transaction_id, entry_type) ); CREATE INDEX IF NOT EXISTS idx_ledger_entries_merchant_created ON ledger_entries (merchant_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_ledger_entries_tx ON ledger_entries (transaction_id); CREATE TABLE IF NOT EXISTS settlement_batches ( id TEXT PRIMARY KEY, batch_code TEXT NOT NULL UNIQUE, merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, currency TEXT NOT NULL DEFAULT 'IDR', gross_amount NUMERIC(20,2) NOT NULL DEFAULT 0, platform_fee_amount NUMERIC(20,2) NOT NULL DEFAULT 0, net_payable_amount NUMERIC(20,2) NOT NULL DEFAULT 0, entry_count INT NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'created' CHECK (status IN ('created', 'paid', 'failed', 'cancelled')), cutoff_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL, paid_at TIMESTAMPTZ, failure_reason TEXT, metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb ); CREATE INDEX IF NOT EXISTS idx_settlement_batches_merchant_created ON settlement_batches (merchant_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_settlement_batches_status_created ON settlement_batches (status, created_at DESC); CREATE TABLE IF NOT EXISTS settlement_batch_entries ( id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE, ledger_entry_id TEXT NOT NULL REFERENCES ledger_entries (id) ON DELETE CASCADE, transaction_id TEXT NOT NULL REFERENCES transactions (id) ON DELETE CASCADE, merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, amount NUMERIC(20,2) NOT NULL, currency TEXT NOT NULL DEFAULT 'IDR', created_at TIMESTAMPTZ NOT NULL, CONSTRAINT settlement_batch_entries_unique_ledger UNIQUE (ledger_entry_id) ); CREATE INDEX IF NOT EXISTS idx_settlement_batch_entries_batch ON settlement_batch_entries (batch_id); CREATE INDEX IF NOT EXISTS idx_settlement_batch_entries_merchant ON settlement_batch_entries (merchant_id, created_at DESC); CREATE TABLE IF NOT EXISTS settlement_batch_events ( id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE, merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, event_type TEXT NOT NULL, actor_type TEXT NOT NULL, actor_id TEXT, payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_settlement_batch_events_batch ON settlement_batch_events (batch_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_settlement_batch_events_merchant ON settlement_batch_events (merchant_id, created_at DESC); CREATE TABLE IF NOT EXISTS settlement_batch_adjustments ( id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES settlement_batches (id) ON DELETE CASCADE, merchant_id TEXT NOT NULL REFERENCES merchants (id) ON DELETE CASCADE, adjustment_type TEXT NOT NULL CHECK (adjustment_type IN ('credit', 'debit')), amount NUMERIC(20,2) NOT NULL, signed_amount NUMERIC(20,2) NOT NULL, currency TEXT NOT NULL DEFAULT 'IDR', reason TEXT NOT NULL, note TEXT, approval_status TEXT NOT NULL DEFAULT 'approved' CHECK (approval_status IN ('pending', 'approved', 'rejected')), approved_by TEXT, approved_at TIMESTAMPTZ, rejected_by TEXT, rejected_at TIMESTAMPTZ, actor_type TEXT NOT NULL DEFAULT 'admin', actor_id TEXT, metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL ); ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approval_status TEXT NOT NULL DEFAULT 'approved'; ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approved_by TEXT; ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS rejected_by TEXT; ALTER TABLE settlement_batch_adjustments ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_batch ON settlement_batch_adjustments (batch_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_merchant ON settlement_batch_adjustments (merchant_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_settlement_batch_adjustments_approval ON settlement_batch_adjustments (approval_status, created_at DESC); INSERT INTO settlement_batch_adjustments ( id, batch_id, merchant_id, adjustment_type, amount, signed_amount, currency, reason, note, approval_status, approved_by, approved_at, actor_type, actor_id, metadata_json, created_at ) SELECT COALESCE(adjustment.item->>'id', 'adj_backfill_' || sb.id || '_' || adjustment.ordinality::text) AS id, sb.id AS batch_id, sb.merchant_id, CASE WHEN adjustment.item->>'adjustment_type' IN ('credit', 'debit') THEN adjustment.item->>'adjustment_type' WHEN COALESCE(NULLIF(adjustment.item->>'signed_amount', '')::numeric, 0) >= 0 THEN 'credit' ELSE 'debit' END AS adjustment_type, ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, NULLIF(adjustment.item->>'signed_amount', '')::numeric, 0)) AS amount, COALESCE( NULLIF(adjustment.item->>'signed_amount', '')::numeric, CASE WHEN adjustment.item->>'adjustment_type' = 'debit' THEN -ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, 0)) ELSE ABS(COALESCE(NULLIF(adjustment.item->>'amount', '')::numeric, 0)) END ) AS signed_amount, sb.currency, COALESCE(NULLIF(adjustment.item->>'reason', ''), 'Backfilled settlement adjustment') AS reason, NULLIF(adjustment.item->>'note', '') AS note, 'approved' AS approval_status, COALESCE(NULLIF(adjustment.item->>'actor_id', ''), 'metadata_backfill') AS approved_by, COALESCE(NULLIF(adjustment.item->>'created_at', '')::timestamptz, sb.created_at) AS approved_at, COALESCE(NULLIF(adjustment.item->>'actor_type', ''), 'admin') AS actor_type, NULLIF(adjustment.item->>'actor_id', '') AS actor_id, jsonb_build_object('source', 'metadata_backfill', 'original', adjustment.item) AS metadata_json, COALESCE(NULLIF(adjustment.item->>'created_at', '')::timestamptz, sb.created_at) AS created_at FROM settlement_batches sb CROSS JOIN LATERAL jsonb_array_elements(sb.metadata_json->'adjustments') WITH ORDINALITY AS adjustment(item, ordinality) WHERE jsonb_typeof(sb.metadata_json->'adjustments') = 'array' ON CONFLICT (id) DO NOTHING; WITH adjustment_totals AS ( SELECT batch_id, COALESCE(SUM(signed_amount), 0) AS total_adjustment_amount FROM settlement_batch_adjustments WHERE approval_status = 'approved' GROUP BY batch_id ) UPDATE settlement_batches sb SET metadata_json = sb.metadata_json || jsonb_build_object( 'total_adjustment_amount', adjustment_totals.total_adjustment_amount, 'adjustment_source', 'settlement_batch_adjustments', 'adjustment_backfilled_at', NOW() ) FROM adjustment_totals WHERE adjustment_totals.batch_id = sb.id; CREATE TABLE IF NOT EXISTS roles ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, permissions_json JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role_id TEXT NOT NULL REFERENCES roles (id), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), created_at TIMESTAMPTZ NOT NULL ); CREATE TABLE IF NOT EXISTS audit_logs ( id TEXT PRIMARY KEY, actor_type TEXT NOT NULL, actor_id TEXT, action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, before_json JSONB, after_json JSONB, source_ip TEXT, request_id TEXT, trace_id TEXT, created_at TIMESTAMPTZ NOT NULL ); CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs (entity_type, entity_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs (action, created_at DESC); INSERT INTO roles (id, name, permissions_json, created_at) VALUES ('role_admin', 'admin', '{"admin":"*"}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING; INSERT INTO roles (id, name, permissions_json, created_at) VALUES ('role_finance', 'finance', '{"admin":["read"],"merchant":["read"],"device":["read"],"transaction":["read"],"settlement":"*","reconciliation":"*","audit":["read"]}'::jsonb, NOW()), ('role_ops', 'ops', '{"admin":["read"],"merchant":"*","outlet":"*","terminal":"*","device":"*","transaction":"*","notification":"*","settlement":["read"],"reconciliation":["read"],"audit":["read"]}'::jsonb, NOW()), ('role_support', 'support', '{"admin":["read"],"merchant":["read"],"outlet":["read"],"terminal":["read"],"device":["read"],"transaction":["read"],"notification":["read"],"settlement":["read"],"audit":["read"]}'::jsonb, NOW()), ('role_viewer', 'viewer', '{"admin":["read"],"merchant":["read"],"outlet":["read"],"terminal":["read"],"device":["read"],"transaction":["read"],"settlement":["read"],"reconciliation":["read"]}'::jsonb, NOW()) ON CONFLICT (id) DO NOTHING; INSERT INTO users (id, name, email, password_hash, role_id, status, created_at) VALUES ('user_admin_seed', 'Admin Seed', 'admin@example.local', 'dev-only-admin-password', 'role_admin', 'active', NOW()) ON CONFLICT (id) DO NOTHING; COMMIT; `;