520 lines
21 KiB
TypeScript
520 lines
21 KiB
TypeScript
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<T>(work: (client: PoolClient) => Promise<T>): Promise<T> {
|
|
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;
|
|
`;
|