summaryrefslogtreecommitdiff
path: root/internal/controlplane/schema.sql
diff options
context:
space:
mode:
authorChia <Chia@93.nz>2026-08-05 22:01:29 +1200
committerChia <Chia@93.nz>2026-08-05 22:07:50 +1200
commiteadb2ffe85c43cf6fc741c9823cd28eedb4a844c (patch)
tree1aba2536d57360da403aa35c9ced58b615c7064e /internal/controlplane/schema.sql
parentcd0dd91ab93653631904f2ea0e574ccde6d60339 (diff)
feat: harden prepaid billing and commercial operations
Diffstat (limited to 'internal/controlplane/schema.sql')
-rw-r--r--internal/controlplane/schema.sql265
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(),