diff options
| author | Chia <Chia@93.nz> | 2026-08-05 22:01:29 +1200 |
|---|---|---|
| committer | Chia <Chia@93.nz> | 2026-08-05 22:07:50 +1200 |
| commit | eadb2ffe85c43cf6fc741c9823cd28eedb4a844c (patch) | |
| tree | 1aba2536d57360da403aa35c9ced58b615c7064e /internal/controlplane/schema.sql | |
| parent | cd0dd91ab93653631904f2ea0e574ccde6d60339 (diff) | |
feat: harden prepaid billing and commercial operations
Diffstat (limited to 'internal/controlplane/schema.sql')
| -rw-r--r-- | internal/controlplane/schema.sql | 265 |
1 files changed, 261 insertions, 4 deletions
diff --git a/internal/controlplane/schema.sql b/internal/controlplane/schema.sql index 8a5a605..88db49b 100644 --- a/internal/controlplane/schema.sql +++ b/internal/controlplane/schema.sql @@ -1,5 +1,12 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE TABLE IF NOT EXISTS schema_migrations ( + version BIGINT PRIMARY KEY, + name TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + CREATE TABLE IF NOT EXISTS control_state ( singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), generation BIGINT NOT NULL DEFAULT 0, @@ -71,6 +78,72 @@ ALTER TABLE models ADD COLUMN IF NOT EXISTS input_price_micros_per_million BIGIN ALTER TABLE models ADD COLUMN IF NOT EXISTS output_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (output_price_micros_per_million >= 0); ALTER TABLE models ADD COLUMN IF NOT EXISTS cache_read_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_read_price_micros_per_million >= 0); ALTER TABLE models ADD COLUMN IF NOT EXISTS cache_write_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_write_price_micros_per_million >= 0); +ALTER TABLE models ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE models ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT ''; +ALTER TABLE models ADD COLUMN IF NOT EXISTS input_modalities JSONB NOT NULL DEFAULT '["text"]'::jsonb; +ALTER TABLE models ADD COLUMN IF NOT EXISTS output_modalities JSONB NOT NULL DEFAULT '["text"]'::jsonb; +ALTER TABLE models ADD COLUMN IF NOT EXISTS context_window BIGINT NOT NULL DEFAULT 0 CHECK (context_window >= 0); +ALTER TABLE models ADD COLUMN IF NOT EXISTS max_output_tokens BIGINT NOT NULL DEFAULT 0 CHECK (max_output_tokens >= 0); +ALTER TABLE models ADD COLUMN IF NOT EXISTS capabilities JSONB NOT NULL DEFAULT '["chat","streaming"]'::jsonb; +ALTER TABLE models ADD COLUMN IF NOT EXISTS regions JSONB NOT NULL DEFAULT '[]'::jsonb; +ALTER TABLE models ADD COLUMN IF NOT EXISTS lifecycle TEXT NOT NULL DEFAULT 'active'; +ALTER TABLE models ADD COLUMN IF NOT EXISTS released_at TIMESTAMPTZ; +ALTER TABLE models ADD COLUMN IF NOT EXISTS deprecated_at TIMESTAMPTZ; +ALTER TABLE models ADD COLUMN IF NOT EXISTS retired_at TIMESTAMPTZ; +ALTER TABLE models ADD COLUMN IF NOT EXISTS replacement_model TEXT; +ALTER TABLE models DROP CONSTRAINT IF EXISTS models_lifecycle_check; +ALTER TABLE models ADD CONSTRAINT models_lifecycle_check CHECK (lifecycle IN ('preview','active','deprecated','retired')); + +CREATE TABLE IF NOT EXISTS model_price_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, + version INTEGER NOT NULL CHECK (version > 0), + currency TEXT NOT NULL CHECK (currency = lower(currency) AND length(currency) = 3), + input_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (input_price_micros_per_million >= 0), + output_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (output_price_micros_per_million >= 0), + cache_read_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_read_price_micros_per_million >= 0), + cache_write_price_micros_per_million BIGINT NOT NULL DEFAULT 0 CHECK (cache_write_price_micros_per_million >= 0), + effective_from TIMESTAMPTZ NOT NULL DEFAULT now(), + effective_to TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (model_id, version), + CHECK (effective_to IS NULL OR effective_to > effective_from) +); +CREATE UNIQUE INDEX IF NOT EXISTS model_price_versions_one_open_idx + ON model_price_versions (model_id) WHERE effective_to IS NULL; +CREATE INDEX IF NOT EXISTS model_price_versions_effective_idx + ON model_price_versions (model_id, effective_from DESC); + +INSERT INTO model_price_versions ( + model_id, version, currency, input_price_micros_per_million, + output_price_micros_per_million, cache_read_price_micros_per_million, + cache_write_price_micros_per_million, effective_from) +SELECT id, 1, 'usd', input_price_micros_per_million, output_price_micros_per_million, + cache_read_price_micros_per_million, cache_write_price_micros_per_million, created_at +FROM models +ON CONFLICT (model_id, version) DO NOTHING; + +CREATE TABLE IF NOT EXISTS model_aliases ( + alias TEXT PRIMARY KEY, + model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, + deprecated BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS model_aliases_model_idx ON model_aliases (model_id); + +CREATE TABLE IF NOT EXISTS model_tenant_allowlist ( + model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (model_id, tenant_id) +); + +CREATE TABLE IF NOT EXISTS api_key_model_allowlist ( + api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (api_key_id, model_id) +); CREATE TABLE IF NOT EXISTS model_routes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -106,7 +179,7 @@ CREATE TABLE IF NOT EXISTS billing_reservations ( public_model TEXT NOT NULL, currency TEXT NOT NULL, reserved_micros BIGINT NOT NULL CHECK (reserved_micros >= 0), - status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'settled', 'released')), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'settled', 'released', 'metering_failed')), input_price_micros_per_million BIGINT NOT NULL, output_price_micros_per_million BIGINT NOT NULL, cache_read_price_micros_per_million BIGINT NOT NULL, @@ -118,6 +191,30 @@ CREATE TABLE IF NOT EXISTS billing_reservations ( settled_at TIMESTAMPTZ, FOREIGN KEY (project_id, tenant_id) REFERENCES projects(id, tenant_id) ON DELETE CASCADE ); +ALTER TABLE billing_reservations ADD COLUMN IF NOT EXISTS price_version_id UUID REFERENCES model_price_versions(id) ON DELETE SET NULL; +ALTER TABLE billing_reservations DROP CONSTRAINT IF EXISTS billing_reservations_status_check; +ALTER TABLE billing_reservations ADD CONSTRAINT billing_reservations_status_check + CHECK (status IN ('pending','settled','released','metering_failed')); + +CREATE TABLE IF NOT EXISTS billing_settlement_jobs ( + request_id TEXT PRIMARY KEY REFERENCES billing_reservations(request_id) ON DELETE CASCADE, + event JSONB, + status TEXT NOT NULL DEFAULT 'awaiting_event' + CHECK (status IN ('awaiting_event', 'pending', 'processing', 'retry', 'done')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + available_at TIMESTAMPTZ NOT NULL DEFAULT now(), + locked_at TIMESTAMPTZ, + last_error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS billing_settlement_jobs_ready_idx + ON billing_settlement_jobs (available_at, created_at) + WHERE status IN ('pending', 'retry'); +CREATE INDEX IF NOT EXISTS billing_settlement_jobs_stale_idx + ON billing_settlement_jobs (created_at) + WHERE status IN ('awaiting_event', 'processing', 'retry'); CREATE TABLE IF NOT EXISTS usage_events ( request_id TEXT PRIMARY KEY, @@ -143,9 +240,17 @@ CREATE TABLE IF NOT EXISTS usage_events ( cost_micros BIGINT NOT NULL DEFAULT 0, charged_micros BIGINT NOT NULL DEFAULT 0, uncollected_micros BIGINT NOT NULL DEFAULT 0, + usage_reported BOOLEAN NOT NULL DEFAULT FALSE, + metering_status TEXT NOT NULL DEFAULT 'not_billable' + CHECK (metering_status IN ('not_billable','reported','missing','upstream_failed')), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (project_id, tenant_id) REFERENCES projects(id, tenant_id) ON DELETE RESTRICT ); +ALTER TABLE usage_events ADD COLUMN IF NOT EXISTS usage_reported BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE usage_events ADD COLUMN IF NOT EXISTS metering_status TEXT NOT NULL DEFAULT 'not_billable'; +ALTER TABLE usage_events DROP CONSTRAINT IF EXISTS usage_events_metering_status_check; +ALTER TABLE usage_events ADD CONSTRAINT usage_events_metering_status_check + CHECK (metering_status IN ('not_billable','reported','missing','upstream_failed')); -- Usage persistence is independent from billing. Older installations created this -- foreign key, which prevented recording requests when prepaid billing was disabled. @@ -165,6 +270,9 @@ CREATE TABLE IF NOT EXISTS billing_ledger ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (source_type, source_id) ); +ALTER TABLE billing_ledger DROP CONSTRAINT IF EXISTS billing_ledger_kind_check; +ALTER TABLE billing_ledger ADD CONSTRAINT billing_ledger_kind_check + CHECK (kind IN ('topup','usage','adjustment','refund','release','dispute','dispute_reversal')); CREATE TABLE IF NOT EXISTS topup_orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -178,13 +286,127 @@ CREATE TABLE IF NOT EXISTS topup_orders ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), paid_at TIMESTAMPTZ ); +ALTER TABLE topup_orders DROP CONSTRAINT IF EXISTS topup_orders_status_check; +ALTER TABLE topup_orders ADD CONSTRAINT topup_orders_status_check + CHECK (status IN ('pending','paid','failed','expired','partially_refunded','refunded','disputed','reversed')); +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT; +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS stripe_payment_intent_id TEXT; +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS stripe_charge_id TEXT; +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS stripe_invoice_id TEXT; +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS invoice_url TEXT; +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS invoice_pdf_url TEXT; +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS receipt_url TEXT; +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS refunded_micros BIGINT NOT NULL DEFAULT 0 CHECK (refunded_micros >= 0); +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS disputed_micros BIGINT NOT NULL DEFAULT 0 CHECK (disputed_micros >= 0); +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS reconciliation_status TEXT NOT NULL DEFAULT 'unknown'; +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS reconciled_at TIMESTAMPTZ; +ALTER TABLE topup_orders ADD COLUMN IF NOT EXISTS reconciliation_error TEXT NOT NULL DEFAULT ''; +ALTER TABLE topup_orders DROP CONSTRAINT IF EXISTS topup_orders_reconciliation_status_check; +ALTER TABLE topup_orders ADD CONSTRAINT topup_orders_reconciliation_status_check + CHECK (reconciliation_status IN ('unknown','ok','repaired','missing','mismatch','resolved')); +CREATE INDEX IF NOT EXISTS topup_orders_payment_intent_idx ON topup_orders (stripe_payment_intent_id) WHERE stripe_payment_intent_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS topup_orders_customer_idx ON topup_orders (stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS billing_reconciliation_resolutions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + topup_order_id UUID NOT NULL UNIQUE REFERENCES topup_orders(id) ON DELETE RESTRICT, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT, + actor_id TEXT NOT NULL DEFAULT '', + actor_type TEXT NOT NULL CHECK (actor_type IN ('console_user','bootstrap','maintenance')), + reason TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS stripe_customers ( + tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE, + stripe_customer_id TEXT NOT NULL UNIQUE, + email TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS stripe_refunds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT, + topup_order_id UUID NOT NULL REFERENCES topup_orders(id) ON DELETE RESTRICT, + stripe_refund_id TEXT UNIQUE, + amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), + amount_micros BIGINT NOT NULL CHECK (amount_micros > 0), + held_micros BIGINT NOT NULL DEFAULT 0 CHECK (held_micros >= 0), + uncollected_micros BIGINT NOT NULL DEFAULT 0 CHECK (uncollected_micros >= 0), + currency TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT 'requested_by_customer', + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued','submitting','pending','requires_action','succeeded','failed','canceled')), + attempts INTEGER NOT NULL DEFAULT 0, + available_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_error TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS stripe_refunds_ready_idx ON stripe_refunds (available_at, created_at) + WHERE status IN ('queued','submitting'); + +CREATE TABLE IF NOT EXISTS stripe_disputes ( + stripe_dispute_id TEXT PRIMARY KEY, + tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL, + topup_order_id UUID REFERENCES topup_orders(id) ON DELETE SET NULL, + stripe_payment_intent_id TEXT, + amount_minor BIGINT NOT NULL CHECK (amount_minor >= 0), + amount_micros BIGINT NOT NULL CHECK (amount_micros >= 0), + currency TEXT NOT NULL, + status TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + debited_micros BIGINT NOT NULL DEFAULT 0 CHECK (debited_micros >= 0), + uncollected_micros BIGINT NOT NULL DEFAULT 0 CHECK (uncollected_micros >= 0), + due_by TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + closed_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS stripe_invoices ( + stripe_invoice_id TEXT PRIMARY KEY, + tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL, + topup_order_id UUID REFERENCES topup_orders(id) ON DELETE SET NULL, + stripe_customer_id TEXT, + status TEXT NOT NULL DEFAULT '', + currency TEXT NOT NULL DEFAULT '', + amount_due_minor BIGINT NOT NULL DEFAULT 0, + amount_paid_minor BIGINT NOT NULL DEFAULT 0, + attempt_count INTEGER NOT NULL DEFAULT 0, + next_payment_attempt TIMESTAMPTZ, + hosted_invoice_url TEXT NOT NULL DEFAULT '', + invoice_pdf_url TEXT NOT NULL DEFAULT '', + last_failure TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS billing_reconciliation_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + status TEXT NOT NULL CHECK (status IN ('running','clean','mismatch','failed')), + checked_orders BIGINT NOT NULL DEFAULT 0, + mismatch_count BIGINT NOT NULL DEFAULT 0, + report JSONB NOT NULL DEFAULT '[]'::jsonb, + error TEXT NOT NULL DEFAULT '', + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); CREATE TABLE IF NOT EXISTS stripe_webhook_events ( event_id TEXT PRIMARY KEY, event_type TEXT NOT NULL, processed_at TIMESTAMPTZ, + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt_at TIMESTAMPTZ, + processing_error TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +ALTER TABLE stripe_webhook_events ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0; +ALTER TABLE stripe_webhook_events ADD COLUMN IF NOT EXISTS last_attempt_at TIMESTAMPTZ; +ALTER TABLE stripe_webhook_events ADD COLUMN IF NOT EXISTS processing_error TEXT NOT NULL DEFAULT ''; CREATE TABLE IF NOT EXISTS console_users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -279,7 +501,7 @@ CREATE INDEX IF NOT EXISTS console_action_tokens_active_idx CREATE TABLE IF NOT EXISTS console_mail_outbox ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), recipient TEXT NOT NULL, - template TEXT NOT NULL CHECK (template IN ('verify_email', 'password_reset', 'invite')), + template TEXT NOT NULL CHECK (template IN ('verify_email', 'password_reset', 'invite', 'low_balance', 'spend_anomaly')), subject TEXT NOT NULL, body_ciphertext BYTEA NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sending', 'sent', 'failed')), @@ -290,8 +512,43 @@ CREATE TABLE IF NOT EXISTS console_mail_outbox ( last_error TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -CREATE INDEX IF NOT EXISTS console_mail_outbox_pending_idx - ON console_mail_outbox (available_at, created_at) WHERE status IN ('pending', 'failed'); +ALTER TABLE console_mail_outbox DROP CONSTRAINT IF EXISTS console_mail_outbox_template_check; +ALTER TABLE console_mail_outbox ADD CONSTRAINT console_mail_outbox_template_check + CHECK (template IN ('verify_email','password_reset','invite','low_balance','spend_anomaly')); +ALTER TABLE console_mail_outbox DROP CONSTRAINT IF EXISTS console_mail_outbox_status_check; +UPDATE console_mail_outbox SET status='retry' WHERE status='failed'; +ALTER TABLE console_mail_outbox ADD CONSTRAINT console_mail_outbox_status_check + CHECK (status IN ('pending','sending','sent','retry','dead','suppressed')); +DROP INDEX IF EXISTS console_mail_outbox_pending_idx; +CREATE INDEX console_mail_outbox_pending_idx + ON console_mail_outbox (available_at, created_at) WHERE status IN ('pending', 'retry'); + +CREATE TABLE IF NOT EXISTS mail_suppressions ( + recipient TEXT PRIMARY KEY, + reason TEXT NOT NULL CHECK (reason IN ('bounce','complaint','manual')), + provider TEXT NOT NULL DEFAULT '', + provider_event_id TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS mail_feedback_events ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL CHECK (event_type IN ('delivered','bounce','complaint')), + recipient TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS mail_notification_events ( + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + recipient TEXT NOT NULL, + notification_type TEXT NOT NULL CHECK (notification_type IN ('low_balance','spend_anomaly')), + dedupe_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id,recipient,notification_type,dedupe_key) +); CREATE TABLE IF NOT EXISTS console_auth_challenges ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), |
