Production readiness hardening and ops tooling

This commit is contained in:
2026-05-29 10:10:12 +07:00
parent e0b8f9af9a
commit 648e77cee9
68 changed files with 12222 additions and 848 deletions

View File

@ -41,6 +41,13 @@ export function getPool(): Pool {
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 {
@ -73,6 +80,41 @@ CREATE TABLE IF NOT EXISTS merchants (
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,
@ -104,6 +146,12 @@ CREATE TABLE IF NOT EXISTS devices (
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,
@ -111,6 +159,13 @@ CREATE TABLE IF NOT EXISTS devices (
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_devices_status_last_seen ON devices (status, last_seen_at DESC);
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,
@ -263,6 +318,151 @@ CREATE TABLE IF NOT EXISTS ledger_entries (
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,
@ -302,6 +502,14 @@ 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;