Files
member-console/internal/db/migrations/00001_init.sql
T
cgalo5758 b7447bea28 Isolate tests in per-purpose databases
Guard cluster-global CREATE ROLE in all five migration streams with
pg_roles checks so multiple databases can migrate in one cluster, and
tolerate still-referenced roles on Down.

Add test/reset-test-db.sh to drop and recreate member_console_test and
member_console_e2e per run, emit their DSNs from bootstrap, and add a
make test target that resets then runs the suite serialized; parallel
unit packages sharing one database still interfered even after the e2e
split.

Fix customdomain_db_test.go, stale since 0affda7 and previously passing
only through pollution. Bootstrap and the Makefile carry small forward
references to the compose-profile knob introduced next.

Archives the test-db-isolation change.
2026-08-01 04:13:57 -05:00

1254 lines
55 KiB
PL/PgSQL

-- +goose Up
-- Core baseline: schema-consolidation change (see openspec/changes/
-- schema-consolidation). Collapses the five former per-module schemas
-- (identity, organization, billing, entitlements, integration) into a single
-- `core` schema -- see design.md Decision 1/2 for why this is a fresh
-- baseline rather than an ALTER ... SET SCHEMA transition path, and
-- proposal.md for the rationale (no 1:1 module<->model correspondence).
-- Authored clean-slate against the structural reference derived from the
-- pre-consolidation migration chain (git history at bf9a38f); not a
-- transcription of the old file layout.
--
-- `stripe` and `fedwiki` are unchanged, integration-owned schemas with their
-- own migration streams (internal/stripe/migrations, internal/fedwiki/
-- migrations) -- not part of this file.
CREATE SCHEMA core;
-- Required for the GiST exclusion constraint on core.pool_provision_ladders
-- (equality comparison on UUID/VARCHAR columns alongside the range overlap).
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- Shared trigger function for BEFORE UPDATE ... SET updated_at = NOW().
-- Stays in `public` (shared infrastructure, not part of the schema rename
-- map): used by nearly every table below and by the stripe/fedwiki streams.
-- +goose StatementBegin
CREATE FUNCTION public.update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
-- Entitlements-domain trigger functions. Defined here (ahead of the tables
-- that use them) since their bodies reference core.pool_provision_ladders /
-- core.grants, which do not exist yet at this point in the file -- safe
-- because CREATE FUNCTION only parses the body, it does not resolve table
-- references until the function is invoked.
-- Propagates pool_id/status/activated_at/ended_at from a pool_provisions row
-- to every pool_provision_ladders row attached to it, so the ladder table's
-- GiST exclusion predicate always reflects the provision's current state.
-- +goose StatementBegin
CREATE FUNCTION core.sync_pool_provision_ladders()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.pool_id IS DISTINCT FROM OLD.pool_id
OR NEW.status IS DISTINCT FROM OLD.status
OR NEW.activated_at IS DISTINCT FROM OLD.activated_at
OR NEW.ended_at IS DISTINCT FROM OLD.ended_at THEN
UPDATE core.pool_provision_ladders
SET pool_id = NEW.pool_id,
status = NEW.status,
activated_at = NEW.activated_at,
ended_at = NEW.ended_at
WHERE provision_id = NEW.provision_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
-- Grant lineage same-org enforcement: a grant with extends_grant_id set must
-- share its parent grant's granted_to_org_id, and both must be non-NULL.
-- Restricted to org-scoped grants; billing-account/person grant lineage is
-- out of scope (entitlements/00010 in the old chain).
-- +goose StatementBegin
CREATE FUNCTION core.check_grant_lineage_same_org()
RETURNS TRIGGER AS $$
DECLARE
parent_org_id UUID;
BEGIN
IF NEW.extends_grant_id IS NULL THEN
RETURN NEW;
END IF;
-- Self-reference is rejected by chk_grants_no_self_extend; defer to it
-- so the CHECK-violation path fires instead of this trigger (BEFORE
-- triggers run before CHECK constraints).
IF NEW.extends_grant_id = NEW.grant_id THEN
RETURN NEW;
END IF;
IF NEW.granted_to_org_id IS NULL THEN
RAISE EXCEPTION 'extends_grant_id requires granted_to_org_id to be non-NULL'
USING ERRCODE = 'check_violation';
END IF;
SELECT granted_to_org_id INTO parent_org_id
FROM core.grants
WHERE grant_id = NEW.extends_grant_id;
IF parent_org_id IS NULL OR parent_org_id != NEW.granted_to_org_id THEN
RAISE EXCEPTION 'extends_grant_id must reference a grant for the same organization'
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
-- Grant lineage immutability: extends_grant_id is fixed at INSERT and may
-- never change, preventing post-hoc cycle creation.
-- +goose StatementBegin
CREATE FUNCTION core.check_grant_lineage_immutable()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.extends_grant_id IS DISTINCT FROM NEW.extends_grant_id THEN
RAISE EXCEPTION 'extends_grant_id is immutable after insert'
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
-- ---------------------------------------------------------------------------
-- Identity
-- ---------------------------------------------------------------------------
CREATE TABLE core.users (
user_id UUID NOT NULL DEFAULT uuidv7(),
oidc_subject TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
last_login_at TIMESTAMPTZ,
last_login_ip TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_users PRIMARY KEY (user_id),
CONSTRAINT uq_users_oidc_subject UNIQUE (oidc_subject)
);
CREATE TRIGGER trigger_users_updated_at
BEFORE UPDATE ON core.users
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- Redundant with uq_users_oidc_subject; the old chain carried both,
-- preserved as-is (candidate for a future documented drop).
CREATE INDEX idx_users_oidc_subject ON core.users(oidc_subject);
CREATE TABLE core.persons (
person_id UUID NOT NULL DEFAULT uuidv7(),
user_id UUID NOT NULL,
display_name VARCHAR(255) NOT NULL,
primary_email VARCHAR(255) NOT NULL,
primary_email_verified BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_persons PRIMARY KEY (person_id),
CONSTRAINT uq_persons_user_id UNIQUE (user_id),
CONSTRAINT fk_persons_user_id FOREIGN KEY (user_id) REFERENCES core.users(user_id) ON DELETE CASCADE
);
CREATE TRIGGER trigger_persons_updated_at
BEFORE UPDATE ON core.persons
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- Redundant with uq_persons_user_id; the old chain carried both,
-- preserved as-is (candidate for a future documented drop).
CREATE INDEX idx_persons_user_id ON core.persons(user_id);
-- ---------------------------------------------------------------------------
-- Catalog: products, prices, plan ladders
-- ---------------------------------------------------------------------------
-- entitlement_set_id has NO FK: entitlements.set_id FK was never actually
-- added anywhere in the old migration chain despite a comment claiming
-- otherwise (billing/00003) -- a genuine pre-existing gap, preserved as-is
-- (see task summary blockers).
CREATE TABLE core.products (
product_id UUID NOT NULL DEFAULT uuidv7(),
name VARCHAR(255) NOT NULL,
description TEXT,
product_type VARCHAR(50),
metadata JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_public BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
entitlement_set_id UUID,
lifecycle_status VARCHAR(20) NOT NULL DEFAULT 'draft',
CONSTRAINT pk_products PRIMARY KEY (product_id),
CONSTRAINT chk_products_product_type_domain CHECK (product_type IS NULL OR product_type IN ('addon', 'usage', 'one_time')),
CONSTRAINT chk_products_lifecycle_status_domain CHECK (lifecycle_status IN ('draft', 'published', 'retired'))
);
CREATE INDEX idx_products_is_active ON core.products(is_active);
CREATE INDEX idx_products_is_public ON core.products(is_public);
CREATE TRIGGER trigger_products_updated_at
BEFORE UPDATE ON core.products
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.prices (
price_id UUID NOT NULL DEFAULT uuidv7(),
product_id UUID NOT NULL,
currency VARCHAR(3) NOT NULL,
unit_amount INTEGER NOT NULL,
recurring_interval VARCHAR(10),
trial_period_days INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT pk_prices PRIMARY KEY (price_id),
CONSTRAINT fk_prices_product_id FOREIGN KEY (product_id) REFERENCES core.products(product_id)
);
CREATE INDEX idx_prices_product_id ON core.prices(product_id);
CREATE INDEX idx_prices_is_active ON core.prices(is_active);
CREATE UNIQUE INDEX uq_prices_one_default_per_product ON core.prices(product_id) WHERE is_default;
CREATE TRIGGER trigger_prices_updated_at
BEFORE UPDATE ON core.prices
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.plan_ladders (
plan_ladder_id UUID NOT NULL DEFAULT uuidv7(),
ladder_key VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sort_order INTEGER NOT NULL DEFAULT 0,
CONSTRAINT pk_plan_ladders PRIMARY KEY (plan_ladder_id),
CONSTRAINT uq_plan_ladders_ladder_key UNIQUE (ladder_key)
);
CREATE TRIGGER trigger_plan_ladders_updated_at
BEFORE UPDATE ON core.plan_ladders
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- ---------------------------------------------------------------------------
-- Organizations & access control
-- ---------------------------------------------------------------------------
-- 'system' org_type is never seeded by any migration (created by
-- app/bootstrap code); is_reserved backfill for it is therefore a
-- historical no-op, not reproduced.
CREATE TABLE core.org_types (
org_type VARCHAR(20) NOT NULL,
display_name VARCHAR(255) NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_reserved BOOLEAN NOT NULL DEFAULT FALSE,
default_plan_ladder_id UUID,
CONSTRAINT pk_org_types PRIMARY KEY (org_type),
CONSTRAINT fk_org_types_default_plan_ladder FOREIGN KEY (default_plan_ladder_id) REFERENCES core.plan_ladders(plan_ladder_id)
);
CREATE TRIGGER trigger_org_types_updated_at
BEFORE UPDATE ON core.org_types
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.organizations (
org_id UUID NOT NULL DEFAULT uuidv7(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
org_type VARCHAR(20) NOT NULL,
owner_person_id UUID NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_organizations PRIMARY KEY (org_id),
CONSTRAINT uq_organizations_slug UNIQUE (slug),
CONSTRAINT fk_organizations_owner_person_id FOREIGN KEY (owner_person_id) REFERENCES core.persons(person_id),
CONSTRAINT fk_organizations_org_type FOREIGN KEY (org_type) REFERENCES core.org_types(org_type)
);
CREATE INDEX idx_organizations_owner ON core.organizations(owner_person_id);
CREATE INDEX idx_organizations_slug ON core.organizations(slug);
CREATE TRIGGER trigger_organizations_updated_at
BEFORE UPDATE ON core.organizations
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- org_id NULL = system-wide role (seeded below:
-- owner/admin/member/billing/viewer/platform_admin).
CREATE TABLE core.roles (
role_id UUID NOT NULL DEFAULT uuidv7(),
org_id UUID,
role_name VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
description TEXT,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
permissions TEXT[] NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_roles PRIMARY KEY (role_id),
CONSTRAINT fk_roles_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id)
);
CREATE INDEX idx_roles_org_id ON core.roles(org_id);
CREATE TRIGGER trigger_roles_updated_at
BEFORE UPDATE ON core.roles
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.org_members (
org_member_id UUID NOT NULL DEFAULT uuidv7(),
org_id UUID NOT NULL,
person_id UUID NOT NULL,
role_id UUID NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
removed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_org_members PRIMARY KEY (org_member_id),
CONSTRAINT uq_org_members_org_id_person_id UNIQUE (org_id, person_id),
CONSTRAINT fk_org_members_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id),
CONSTRAINT fk_org_members_person_id FOREIGN KEY (person_id) REFERENCES core.persons(person_id),
CONSTRAINT fk_org_members_role_id FOREIGN KEY (role_id) REFERENCES core.roles(role_id)
);
CREATE INDEX idx_org_members_org_id ON core.org_members(org_id);
CREATE INDEX idx_org_members_person_id ON core.org_members(person_id);
CREATE TRIGGER trigger_org_members_updated_at
BEFORE UPDATE ON core.org_members
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.workspaces (
workspace_id UUID NOT NULL DEFAULT uuidv7(),
org_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_workspaces PRIMARY KEY (workspace_id),
CONSTRAINT uq_workspaces_org_id_slug UNIQUE (org_id, slug),
CONSTRAINT fk_workspaces_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id)
);
CREATE INDEX idx_workspaces_org_id ON core.workspaces(org_id);
CREATE TRIGGER trigger_workspaces_updated_at
BEFORE UPDATE ON core.workspaces
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.role_assignments (
assignment_id UUID NOT NULL DEFAULT uuidv7(),
role_id UUID NOT NULL,
person_id UUID NOT NULL,
org_id UUID NOT NULL,
scope_type VARCHAR(20) NOT NULL,
scope_id UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_role_assignments PRIMARY KEY (assignment_id),
CONSTRAINT uq_role_assignments_scope UNIQUE (role_id, person_id, org_id, scope_type, scope_id),
CONSTRAINT fk_role_assignments_role_id FOREIGN KEY (role_id) REFERENCES core.roles(role_id),
CONSTRAINT fk_role_assignments_person_id FOREIGN KEY (person_id) REFERENCES core.persons(person_id),
CONSTRAINT fk_role_assignments_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id)
);
CREATE INDEX idx_role_assignments_person_id ON core.role_assignments(person_id);
CREATE INDEX idx_role_assignments_org_id ON core.role_assignments(org_id);
CREATE TRIGGER trigger_role_assignments_updated_at
BEFORE UPDATE ON core.role_assignments
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- ---------------------------------------------------------------------------
-- Billing: accounts, subscriptions, invoicing & payments
-- ---------------------------------------------------------------------------
CREATE TABLE core.accounts (
billing_account_id UUID NOT NULL DEFAULT uuidv7(),
org_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_accounts PRIMARY KEY (billing_account_id),
CONSTRAINT fk_accounts_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id)
);
CREATE INDEX idx_accounts_org_id ON core.accounts(org_id);
CREATE TRIGGER trigger_accounts_updated_at
BEFORE UPDATE ON core.accounts
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.subscriptions (
subscription_id UUID NOT NULL DEFAULT uuidv7(),
billing_account_id UUID NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
canceled_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
commitment_end TIMESTAMPTZ,
commitment_renewal VARCHAR(20),
early_termination_policy VARCHAR(20),
CONSTRAINT pk_subscriptions PRIMARY KEY (subscription_id),
CONSTRAINT fk_subscriptions_billing_account_id FOREIGN KEY (billing_account_id) REFERENCES core.accounts(billing_account_id),
CONSTRAINT chk_subscriptions_commitment_renewal CHECK (commitment_renewal IS NULL OR commitment_renewal IN ('auto_renew', 'expire')),
CONSTRAINT chk_subscriptions_early_termination_policy CHECK (early_termination_policy IS NULL OR early_termination_policy IN ('block', 'fee', 'allow')),
CONSTRAINT chk_subscriptions_commitment_fields_coherent CHECK ((commitment_end IS NULL AND early_termination_policy IS NULL)
OR (commitment_end IS NOT NULL AND early_termination_policy IS NOT NULL))
);
CREATE INDEX idx_subscriptions_billing_account_id ON core.subscriptions(billing_account_id);
CREATE INDEX idx_subscriptions_status ON core.subscriptions(status);
CREATE TRIGGER trigger_subscriptions_updated_at
BEFORE UPDATE ON core.subscriptions
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.subscription_items (
subscription_item_id UUID NOT NULL DEFAULT uuidv7(),
subscription_id UUID NOT NULL,
product_id UUID NOT NULL,
price_id UUID NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_subscription_items PRIMARY KEY (subscription_item_id),
CONSTRAINT fk_subscription_items_subscription_id FOREIGN KEY (subscription_id) REFERENCES core.subscriptions(subscription_id),
CONSTRAINT fk_subscription_items_product_id FOREIGN KEY (product_id) REFERENCES core.products(product_id),
CONSTRAINT fk_subscription_items_price_id FOREIGN KEY (price_id) REFERENCES core.prices(price_id)
);
CREATE INDEX idx_subscription_items_subscription_id ON core.subscription_items(subscription_id);
CREATE TRIGGER trigger_subscription_items_updated_at
BEFORE UPDATE ON core.subscription_items
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- append-only log; no updated_at column, correctly no trigger.
CREATE TABLE core.subscription_changes (
change_id UUID NOT NULL DEFAULT uuidv7(),
subscription_id UUID NOT NULL,
previous_status TEXT,
new_status TEXT NOT NULL,
stripe_event_id TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_subscription_changes PRIMARY KEY (change_id),
CONSTRAINT fk_subscription_changes_subscription_id FOREIGN KEY (subscription_id) REFERENCES core.subscriptions(subscription_id)
);
CREATE INDEX idx_subscription_changes_subscription_id ON core.subscription_changes(subscription_id);
CREATE TABLE core.payment_methods (
payment_method_id UUID NOT NULL DEFAULT uuidv7(),
billing_account_id UUID NOT NULL,
pm_type TEXT NOT NULL,
card_brand TEXT,
card_last4 TEXT,
card_exp_month INTEGER,
card_exp_year INTEGER,
funding TEXT,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
detached_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_payment_methods PRIMARY KEY (payment_method_id),
CONSTRAINT fk_payment_methods_billing_account_id FOREIGN KEY (billing_account_id) REFERENCES core.accounts(billing_account_id)
);
CREATE INDEX idx_payment_methods_billing_account_id ON core.payment_methods(billing_account_id);
CREATE TRIGGER trigger_payment_methods_updated_at
BEFORE UPDATE ON core.payment_methods
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.invoices (
invoice_id UUID NOT NULL DEFAULT uuidv7(),
billing_account_id UUID NOT NULL,
subscription_id UUID,
status TEXT NOT NULL DEFAULT 'open',
amount_due INTEGER NOT NULL DEFAULT 0,
amount_paid INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL,
period_start TIMESTAMPTZ,
period_end TIMESTAMPTZ,
due_date TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
voided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_invoices PRIMARY KEY (invoice_id),
CONSTRAINT fk_invoices_billing_account_id FOREIGN KEY (billing_account_id) REFERENCES core.accounts(billing_account_id),
CONSTRAINT fk_invoices_subscription_id FOREIGN KEY (subscription_id) REFERENCES core.subscriptions(subscription_id)
);
CREATE INDEX idx_invoices_billing_account_id ON core.invoices(billing_account_id);
CREATE INDEX idx_invoices_subscription_id ON core.invoices(subscription_id);
CREATE INDEX idx_invoices_status ON core.invoices(status);
CREATE TRIGGER trigger_invoices_updated_at
BEFORE UPDATE ON core.invoices
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.invoice_line_items (
line_item_id UUID NOT NULL DEFAULT uuidv7(),
invoice_id UUID NOT NULL,
subscription_item_id UUID,
price_id UUID NOT NULL,
amount INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL,
description TEXT,
quantity INTEGER NOT NULL DEFAULT 1,
period_start TIMESTAMPTZ,
period_end TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_invoice_line_items PRIMARY KEY (line_item_id),
CONSTRAINT fk_invoice_line_items_invoice_id FOREIGN KEY (invoice_id) REFERENCES core.invoices(invoice_id),
CONSTRAINT fk_invoice_line_items_subscription_item_id FOREIGN KEY (subscription_item_id) REFERENCES core.subscription_items(subscription_item_id),
CONSTRAINT fk_invoice_line_items_price_id FOREIGN KEY (price_id) REFERENCES core.prices(price_id)
);
CREATE INDEX idx_invoice_line_items_invoice_id ON core.invoice_line_items(invoice_id);
CREATE TRIGGER trigger_invoice_line_items_updated_at
BEFORE UPDATE ON core.invoice_line_items
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- distinct from the dropped legacy public.payments (db/00001/00002 in the
-- old chain) -- this is the real billing ledger row.
CREATE TABLE core.payments (
payment_id UUID NOT NULL DEFAULT uuidv7(),
invoice_id UUID NOT NULL,
billing_account_id UUID NOT NULL,
payment_method_id UUID,
amount INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
failed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_payments PRIMARY KEY (payment_id),
CONSTRAINT fk_payments_invoice_id FOREIGN KEY (invoice_id) REFERENCES core.invoices(invoice_id),
CONSTRAINT fk_payments_billing_account_id FOREIGN KEY (billing_account_id) REFERENCES core.accounts(billing_account_id),
CONSTRAINT fk_payments_payment_method_id FOREIGN KEY (payment_method_id) REFERENCES core.payment_methods(payment_method_id)
);
CREATE INDEX idx_payments_invoice_id ON core.payments(invoice_id);
CREATE INDEX idx_payments_billing_account_id ON core.payments(billing_account_id);
CREATE INDEX idx_payments_status ON core.payments(status);
CREATE TRIGGER trigger_payments_updated_at
BEFORE UPDATE ON core.payments
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- sole and universal representation of product-ladder ('plan') membership
-- -- no label column on products (Doc 31 Amendment #3).
CREATE TABLE core.plan_ladder_tiers (
plan_ladder_id UUID NOT NULL,
product_id UUID NOT NULL,
rank INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_plan_ladder_tiers PRIMARY KEY (plan_ladder_id, product_id),
CONSTRAINT uq_plan_ladder_tiers_plan_ladder_id_rank UNIQUE (plan_ladder_id, rank),
CONSTRAINT fk_plan_ladder_tiers_plan_ladder_id FOREIGN KEY (plan_ladder_id) REFERENCES core.plan_ladders(plan_ladder_id),
CONSTRAINT fk_plan_ladder_tiers_product_id FOREIGN KEY (product_id) REFERENCES core.products(product_id)
);
CREATE INDEX idx_plan_ladder_tiers_product_id ON core.plan_ladder_tiers(product_id);
CREATE TRIGGER trigger_plan_ladder_tiers_updated_at
BEFORE UPDATE ON core.plan_ladder_tiers
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.subscription_scheduled_changes (
scheduled_change_id UUID NOT NULL DEFAULT uuidv7(),
subscription_id UUID NOT NULL,
change_type VARCHAR(30) NOT NULL,
target_price_id UUID,
target_quantity INTEGER,
effective_at TIMESTAMPTZ NOT NULL,
effective_trigger VARCHAR(20) NOT NULL,
credit_disposition VARCHAR(10),
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_subscription_scheduled_changes PRIMARY KEY (scheduled_change_id),
CONSTRAINT fk_subscription_scheduled_changes_subscription_id FOREIGN KEY (subscription_id) REFERENCES core.subscriptions(subscription_id),
CONSTRAINT fk_subscription_scheduled_changes_target_price_id FOREIGN KEY (target_price_id) REFERENCES core.prices(price_id),
CONSTRAINT chk_ssc_change_type CHECK (change_type IN ('plan_switch', 'quantity_change', 'cancellation')),
CONSTRAINT chk_ssc_effective_trigger CHECK (effective_trigger IN ('at_date', 'period_end', 'term_boundary')),
CONSTRAINT chk_ssc_status CHECK (status IN ('scheduled', 'applied', 'superseded', 'canceled')),
CONSTRAINT chk_ssc_credit_disposition CHECK (credit_disposition IS NULL OR credit_disposition IN ('ledger', 'cash'))
);
CREATE INDEX idx_ssc_subscription_id ON core.subscription_scheduled_changes(subscription_id);
CREATE INDEX idx_ssc_due ON core.subscription_scheduled_changes(effective_at) WHERE status = 'scheduled';
CREATE TRIGGER trigger_subscription_scheduled_changes_updated_at
BEFORE UPDATE ON core.subscription_scheduled_changes
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- ---------------------------------------------------------------------------
-- Entitlements: pools, grants & provisions
-- ---------------------------------------------------------------------------
CREATE TABLE core.resource_pools (
pool_id UUID NOT NULL DEFAULT uuidv7(),
org_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
pool_type VARCHAR(20) NOT NULL,
is_auto_managed BOOLEAN NOT NULL DEFAULT FALSE,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active',
suspended_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_resource_pools PRIMARY KEY (pool_id),
CONSTRAINT uq_resource_pools_org_id_slug UNIQUE (org_id, slug),
CONSTRAINT fk_resource_pools_org_id FOREIGN KEY (org_id) REFERENCES core.organizations(org_id)
);
CREATE INDEX idx_resource_pools_org_id ON core.resource_pools(org_id);
CREATE TRIGGER trigger_resource_pools_updated_at
BEFORE UPDATE ON core.resource_pools
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.pool_assignments (
assignment_id UUID NOT NULL DEFAULT uuidv7(),
pool_id UUID NOT NULL,
workspace_id UUID NOT NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(20) NOT NULL DEFAULT 'active',
suspended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_pool_assignments PRIMARY KEY (assignment_id),
CONSTRAINT uq_pool_assignments_pool_id_workspace_id UNIQUE (pool_id, workspace_id),
CONSTRAINT fk_pool_assignments_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id),
CONSTRAINT fk_pool_assignments_workspace_id FOREIGN KEY (workspace_id) REFERENCES core.workspaces(workspace_id)
);
CREATE INDEX idx_pool_assignments_workspace_id ON core.pool_assignments(workspace_id);
CREATE INDEX idx_pool_assignments_pool_id ON core.pool_assignments(pool_id);
CREATE TRIGGER trigger_pool_assignments_updated_at
BEFORE UPDATE ON core.pool_assignments
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.entitlement_sets (
set_id UUID NOT NULL DEFAULT uuidv7(),
name VARCHAR(255) NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_entitlement_sets PRIMARY KEY (set_id),
CONSTRAINT uq_entitlement_sets_name UNIQUE (name)
);
CREATE TRIGGER trigger_entitlement_sets_updated_at
BEFORE UPDATE ON core.entitlement_sets
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- granted_to_billing_account_id has NO FK -- heterogeneous recipient arc,
-- see chk_grants_recipient.
CREATE TABLE core.grants (
grant_id UUID NOT NULL DEFAULT uuidv7(),
product_id UUID,
granted_to_billing_account_id UUID,
granted_to_org_id UUID,
granted_to_person_id UUID,
granted_by_person_id UUID,
grant_reason VARCHAR(50) NOT NULL,
description TEXT,
quantity INTEGER NOT NULL DEFAULT 1,
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_until TIMESTAMPTZ,
status VARCHAR(20) NOT NULL DEFAULT 'active',
revoked_at TIMESTAMPTZ,
revoked_by_person_id UUID,
revocation_reason TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
entitlement_set_id UUID,
extends_grant_id UUID,
CONSTRAINT pk_grants PRIMARY KEY (grant_id),
CONSTRAINT fk_grants_product_id FOREIGN KEY (product_id) REFERENCES core.products(product_id),
CONSTRAINT fk_grants_granted_to_org_id FOREIGN KEY (granted_to_org_id) REFERENCES core.organizations(org_id),
CONSTRAINT fk_grants_granted_to_person_id FOREIGN KEY (granted_to_person_id) REFERENCES core.persons(person_id),
CONSTRAINT fk_grants_granted_by_person_id FOREIGN KEY (granted_by_person_id) REFERENCES core.persons(person_id),
CONSTRAINT fk_grants_revoked_by_person_id FOREIGN KEY (revoked_by_person_id) REFERENCES core.persons(person_id),
CONSTRAINT fk_grants_entitlement_set_id FOREIGN KEY (entitlement_set_id) REFERENCES core.entitlement_sets(set_id),
CONSTRAINT fk_grants_extends_grant_id FOREIGN KEY (extends_grant_id) REFERENCES core.grants(grant_id),
CONSTRAINT chk_grants_recipient CHECK ((granted_to_billing_account_id IS NOT NULL AND granted_to_org_id IS NULL AND granted_to_person_id IS NULL)
OR (granted_to_billing_account_id IS NULL AND granted_to_org_id IS NOT NULL AND granted_to_person_id IS NULL)
OR (granted_to_billing_account_id IS NULL AND granted_to_org_id IS NULL AND granted_to_person_id IS NOT NULL)),
CONSTRAINT chk_grants_system_actor CHECK (granted_by_person_id IS NOT NULL OR grant_reason = 'default'),
CONSTRAINT chk_grants_entitlement_source CHECK (entitlement_set_id IS NOT NULL OR product_id IS NOT NULL),
CONSTRAINT chk_grants_no_self_extend CHECK (extends_grant_id IS NULL OR extends_grant_id != grant_id)
);
CREATE INDEX idx_grants_org_id ON core.grants(granted_to_org_id);
CREATE INDEX idx_grants_product_id ON core.grants(product_id);
CREATE INDEX idx_grants_extends_grant_id ON core.grants(extends_grant_id) WHERE extends_grant_id IS NOT NULL;
CREATE TRIGGER trigger_grants_updated_at
BEFORE UPDATE ON core.grants
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER trigger_grant_lineage_same_org
BEFORE INSERT OR UPDATE ON core.grants
FOR EACH ROW
EXECUTE FUNCTION core.check_grant_lineage_same_org();
CREATE TRIGGER trigger_grant_lineage_immutable
BEFORE UPDATE ON core.grants
FOR EACH ROW
EXECUTE FUNCTION core.check_grant_lineage_immutable();
-- billing_account_id/subscription_id/purchase_id have NO FK --
-- heterogeneous source arc, see chk_pool_provisions_source (purchase_id is
-- a loose slot; no purchases table exists).
CREATE TABLE core.pool_provisions (
provision_id UUID NOT NULL DEFAULT uuidv7(),
pool_id UUID NOT NULL,
billing_account_id UUID,
subscription_id UUID,
purchase_id UUID,
grant_id UUID,
quantity INTEGER NOT NULL DEFAULT 1,
status VARCHAR(20) NOT NULL DEFAULT 'active',
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
suspended_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
entitlement_set_id UUID NOT NULL,
CONSTRAINT pk_pool_provisions PRIMARY KEY (provision_id),
CONSTRAINT fk_pool_provisions_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id),
CONSTRAINT fk_pool_provisions_grant_id FOREIGN KEY (grant_id) REFERENCES core.grants(grant_id),
CONSTRAINT fk_pool_provisions_entitlement_set_id FOREIGN KEY (entitlement_set_id) REFERENCES core.entitlement_sets(set_id),
CONSTRAINT chk_pool_provisions_source CHECK ((subscription_id IS NOT NULL AND purchase_id IS NULL AND grant_id IS NULL)
OR (subscription_id IS NULL AND purchase_id IS NOT NULL AND grant_id IS NULL)
OR (subscription_id IS NULL AND purchase_id IS NULL AND grant_id IS NOT NULL))
);
CREATE INDEX idx_pool_provisions_pool_id ON core.pool_provisions(pool_id);
CREATE INDEX idx_pool_provisions_grant_id ON core.pool_provisions(grant_id);
CREATE INDEX idx_pool_provisions_entitlement_set_id ON core.pool_provisions(entitlement_set_id);
CREATE TRIGGER trigger_pool_provisions_updated_at
BEFORE UPDATE ON core.pool_provisions
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER trigger_sync_pool_provision_ladders
AFTER UPDATE ON core.pool_provisions
FOR EACH ROW
EXECUTE FUNCTION core.sync_pool_provision_ladders();
-- pool_id/status/activated_at/ended_at are denormalized from
-- pool_provisions, kept in sync by the core.sync_pool_provision_ladders()
-- AFTER UPDATE trigger on pool_provisions. The EXCLUDE constraint requires
-- btree_gist (see extension near top of file); WHERE (status = 'active')
-- caps it to at most one active attachment per (pool, ladder) at any
-- instant.
CREATE TABLE core.pool_provision_ladders (
provision_id UUID NOT NULL,
plan_ladder_id UUID NOT NULL,
pool_id UUID NOT NULL,
status VARCHAR(20) NOT NULL,
activated_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
product_id UUID NOT NULL,
CONSTRAINT pk_pool_provision_ladders PRIMARY KEY (provision_id, plan_ladder_id),
CONSTRAINT fk_pool_provision_ladders_provision_id FOREIGN KEY (provision_id) REFERENCES core.pool_provisions(provision_id),
CONSTRAINT fk_pool_provision_ladders_plan_ladder_id FOREIGN KEY (plan_ladder_id) REFERENCES core.plan_ladders(plan_ladder_id),
CONSTRAINT fk_pool_provision_ladders_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id),
CONSTRAINT fk_pool_provision_ladders_product_id FOREIGN KEY (product_id) REFERENCES core.products(product_id),
CONSTRAINT excl_pool_provision_ladders_active_overlap EXCLUDE USING gist (
pool_id WITH =,
plan_ladder_id WITH =,
tstzrange(activated_at, ended_at, '[)') WITH &&
) WHERE (status = 'active')
);
CREATE INDEX idx_pool_provision_ladders_pool_ladder ON core.pool_provision_ladders(pool_id, plan_ladder_id);
CREATE INDEX idx_pool_provision_ladders_plan_ladder ON core.pool_provision_ladders(plan_ladder_id);
CREATE INDEX idx_pool_provision_ladders_product_id ON core.pool_provision_ladders(product_id);
CREATE TRIGGER trigger_pool_provision_ladders_updated_at
BEFORE UPDATE ON core.pool_provision_ladders
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- append-only audit log; no updated_at column, correctly no trigger.
CREATE TABLE core.pool_provision_transitions (
transition_id UUID NOT NULL DEFAULT uuidv7(),
pool_id UUID NOT NULL,
provision_id UUID,
plan_ladder_id UUID NOT NULL,
from_rank INTEGER,
to_rank INTEGER,
transition_type VARCHAR(20) NOT NULL,
actor_type VARCHAR(20) NOT NULL,
actor_id UUID,
reason TEXT,
effective_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_pool_provision_transitions PRIMARY KEY (transition_id),
CONSTRAINT fk_pool_provision_transitions_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id),
CONSTRAINT fk_pool_provision_transitions_provision_id FOREIGN KEY (provision_id) REFERENCES core.pool_provisions(provision_id),
CONSTRAINT fk_pool_provision_transitions_plan_ladder_id FOREIGN KEY (plan_ladder_id) REFERENCES core.plan_ladders(plan_ladder_id),
CONSTRAINT chk_pool_provision_transitions_type CHECK (transition_type IN ('initiate', 'upgrade', 'downgrade', 'end', 'extend')),
CONSTRAINT chk_pool_provision_transitions_actor_type CHECK (actor_type IN ('operator', 'system', 'webhook')),
CONSTRAINT chk_pool_provision_transitions_operator_actor CHECK (actor_type != 'operator' OR actor_id IS NOT NULL)
);
CREATE INDEX idx_pool_provision_transitions_pool_id ON core.pool_provision_transitions(pool_id);
CREATE INDEX idx_pool_provision_transitions_provision_id ON core.pool_provision_transitions(provision_id);
CREATE INDEX idx_pool_provision_transitions_plan_ladder_id ON core.pool_provision_transitions(plan_ladder_id);
CREATE INDEX idx_pool_provision_transitions_effective_at ON core.pool_provision_transitions(effective_at);
-- ---------------------------------------------------------------------------
-- Provider registry & numeric entitlements
-- ---------------------------------------------------------------------------
-- has updated_at but no maintaining trigger anywhere in the old migration
-- chain -- pre-existing gap, not fixed here.
CREATE TABLE core.providers (
slug TEXT NOT NULL,
provider_kind TEXT NOT NULL,
display_name TEXT NOT NULL,
operator_surface_path TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_providers PRIMARY KEY (slug),
CONSTRAINT chk_providers_slug_hygiene CHECK (slug ~ '^[a-z0-9]+$'),
CONSTRAINT chk_providers_kind_valid CHECK (provider_kind IN ('payment', 'provisioning', 'notification', 'tax'))
);
-- no updated_at column, correctly no trigger. provider NULL = platform-
-- owned/pooled.
CREATE TABLE core.resource_keys (
resource_key VARCHAR(100) NOT NULL,
display_name VARCHAR(255) NOT NULL,
description TEXT,
unit VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
provider TEXT,
CONSTRAINT pk_resource_keys PRIMARY KEY (resource_key),
CONSTRAINT fk_resource_keys_provider FOREIGN KEY (provider) REFERENCES core.providers(slug)
);
CREATE TABLE core.numeric_entitlements (
entitlement_id UUID NOT NULL DEFAULT uuidv7(),
pool_id UUID NOT NULL,
resource_key VARCHAR(100) NOT NULL,
entitlement_type VARCHAR(20) NOT NULL,
resource_limit BIGINT NOT NULL DEFAULT 0,
reset_period VARCHAR(20),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_numeric_entitlements PRIMARY KEY (entitlement_id),
CONSTRAINT uq_numeric_entitlements_pool_id_resource_key UNIQUE (pool_id, resource_key),
CONSTRAINT fk_numeric_entitlements_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id),
CONSTRAINT fk_numeric_entitlements_resource_key FOREIGN KEY (resource_key) REFERENCES core.resource_keys(resource_key)
);
CREATE INDEX idx_numeric_entitlements_pool_id ON core.numeric_entitlements(pool_id);
CREATE TRIGGER trigger_numeric_entitlements_updated_at
BEFORE UPDATE ON core.numeric_entitlements
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.numeric_entitlement_contributions (
contribution_id UUID NOT NULL DEFAULT uuidv7(),
entitlement_id UUID NOT NULL,
provision_id UUID NOT NULL,
contributed_value BIGINT NOT NULL,
stacking_policy VARCHAR(20) NOT NULL DEFAULT 'additive',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_numeric_entitlement_contributions PRIMARY KEY (contribution_id),
CONSTRAINT uq_numeric_entitlement_contributions_scope UNIQUE (entitlement_id, provision_id),
CONSTRAINT fk_numeric_entitlement_contributions_entitlement_id FOREIGN KEY (entitlement_id) REFERENCES core.numeric_entitlements(entitlement_id),
CONSTRAINT fk_numeric_entitlement_contributions_provision_id FOREIGN KEY (provision_id) REFERENCES core.pool_provisions(provision_id)
);
CREATE INDEX idx_numeric_entitlement_contributions_entitlement_id ON core.numeric_entitlement_contributions(entitlement_id);
CREATE INDEX idx_numeric_entitlement_contributions_provision_id ON core.numeric_entitlement_contributions(provision_id);
CREATE TRIGGER trigger_numeric_entitlement_contributions_updated_at
BEFORE UPDATE ON core.numeric_entitlement_contributions
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- resource_key is a denormalized copy, no FK of its own. hot-path composite
-- index for entitlement checks (D11).
CREATE TABLE core.numeric_entitlement_usage (
usage_id UUID NOT NULL DEFAULT uuidv7(),
entitlement_id UUID NOT NULL,
pool_id UUID NOT NULL,
resource_key VARCHAR(100) NOT NULL,
current_usage BIGINT NOT NULL DEFAULT 0,
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
last_reset_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_numeric_entitlement_usage PRIMARY KEY (usage_id),
CONSTRAINT uq_numeric_entitlement_usage_entitlement_id UNIQUE (entitlement_id),
CONSTRAINT fk_numeric_entitlement_usage_entitlement_id FOREIGN KEY (entitlement_id) REFERENCES core.numeric_entitlements(entitlement_id),
CONSTRAINT fk_numeric_entitlement_usage_pool_id FOREIGN KEY (pool_id) REFERENCES core.resource_pools(pool_id)
);
CREATE INDEX idx_numeric_entitlement_usage_pool_resource ON core.numeric_entitlement_usage(pool_id, resource_key);
CREATE TRIGGER trigger_numeric_entitlement_usage_updated_at
BEFORE UPDATE ON core.numeric_entitlement_usage
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
-- credit rules (NULL resource_key) and deactivated history are
-- unconstrained by design.
CREATE TABLE core.entitlement_set_rules (
rule_id UUID NOT NULL DEFAULT uuidv7(),
set_id UUID NOT NULL,
rule_type VARCHAR(20) NOT NULL,
resource_key VARCHAR(100),
resource_value BIGINT,
resource_per_unit BOOLEAN,
stacking_policy VARCHAR(20) DEFAULT 'additive',
reset_period VARCHAR(20),
credit_amount INTEGER,
credit_currency VARCHAR(3),
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_entitlement_set_rules PRIMARY KEY (rule_id),
CONSTRAINT fk_entitlement_set_rules_set_id FOREIGN KEY (set_id) REFERENCES core.entitlement_sets(set_id),
CONSTRAINT fk_entitlement_set_rules_resource_key FOREIGN KEY (resource_key) REFERENCES core.resource_keys(resource_key),
CONSTRAINT chk_entitlement_set_rules_type CHECK ((rule_type = 'boolean' AND resource_key IS NOT NULL
AND resource_value IS NULL AND stacking_policy IS NULL
AND reset_period IS NULL AND resource_per_unit IS NULL
AND credit_amount IS NULL AND credit_currency IS NULL)
OR (rule_type = 'limit' AND resource_key IS NOT NULL AND resource_value IS NOT NULL
AND reset_period IS NULL
AND credit_amount IS NULL AND credit_currency IS NULL)
OR (rule_type = 'quota' AND resource_key IS NOT NULL AND resource_value IS NOT NULL
AND reset_period IS NOT NULL
AND credit_amount IS NULL AND credit_currency IS NULL)
OR (rule_type = 'credit' AND credit_amount IS NOT NULL AND credit_currency IS NOT NULL
AND resource_key IS NULL AND resource_value IS NULL
AND stacking_policy IS NULL AND reset_period IS NULL AND resource_per_unit IS NULL))
);
CREATE INDEX idx_entitlement_set_rules_set_id ON core.entitlement_set_rules(set_id);
CREATE UNIQUE INDEX uq_entitlement_set_rules_active_resource_key ON core.entitlement_set_rules(set_id, resource_key) WHERE is_active = TRUE AND resource_key IS NOT NULL;
CREATE TRIGGER trigger_entitlement_set_rules_updated_at
BEFORE UPDATE ON core.entitlement_set_rules
FOR EACH ROW
EXECUTE FUNCTION public.update_updated_at_column();
CREATE TABLE core.provider_operations (
provider TEXT NOT NULL,
operation TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_provider_operations PRIMARY KEY (provider, operation),
CONSTRAINT fk_provider_operations_provider FOREIGN KEY (provider) REFERENCES core.providers(slug) ON DELETE CASCADE,
CONSTRAINT chk_provider_operations_verb_valid CHECK (operation IN ('create', 'set_status', 'delete', 'list', 'describe'))
);
-- hygiene format, not a closed enum -- per-provider vocabulary.
CREATE TABLE core.provider_states (
provider TEXT NOT NULL,
state TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_provider_states PRIMARY KEY (provider, state),
CONSTRAINT fk_provider_states_provider FOREIGN KEY (provider) REFERENCES core.providers(slug) ON DELETE CASCADE,
CONSTRAINT chk_provider_states_value_hygiene CHECK (state ~ '^[a-z][a-z0-9_]*$')
);
-- ---------------------------------------------------------------------------
-- Integration infrastructure: webhooks & outbox
-- ---------------------------------------------------------------------------
-- Idempotent inbound webhook capture, partitioned monthly. Processors poll by
-- status; partitioning keeps old data manageable.
CREATE TABLE core.webhook_events (
id BIGINT GENERATED ALWAYS AS IDENTITY,
provider TEXT NOT NULL,
provider_event_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'received',
retry_count INT NOT NULL DEFAULT 0,
error_message TEXT,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_webhook_events PRIMARY KEY (id, received_at),
CONSTRAINT uq_webhook_events_provider_event UNIQUE (provider, provider_event_id, received_at)
) PARTITION BY RANGE (received_at);
-- +goose StatementBegin
-- Create initial partitions: current month + next 2 months. Future
-- partitions are created by application boot or a scheduler.
DO $$
DECLARE
m_start DATE;
m_end DATE;
part TEXT;
BEGIN
FOR i IN 0..2 LOOP
m_start := DATE_TRUNC('month', NOW()) + (i || ' months')::INTERVAL;
m_end := m_start + '1 month'::INTERVAL;
part := 'core.webhook_events_' || TO_CHAR(m_start, 'YYYY_MM');
EXECUTE FORMAT(
'CREATE TABLE IF NOT EXISTS %s PARTITION OF core.webhook_events
FOR VALUES FROM (%L) TO (%L)',
part, m_start, m_end
);
END LOOP;
END $$;
-- +goose StatementEnd
CREATE INDEX idx_webhook_events_status_received
ON core.webhook_events (status, received_at)
WHERE status IN ('received', 'failed');
CREATE INDEX idx_webhook_events_provider_event
ON core.webhook_events (provider, provider_event_id);
-- has updated_at but no maintaining trigger anywhere in the old migration
-- chain -- pre-existing gap, not fixed here (same gap as core.providers).
-- Transactional outbound actions with retry + dead-letter.
CREATE TABLE core.outbox (
id BIGINT GENERATED ALWAYS AS IDENTITY,
provider TEXT NOT NULL,
action_type TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_outbox PRIMARY KEY (id)
);
CREATE INDEX idx_outbox_pending
ON core.outbox (status, next_attempt_at)
WHERE status IN ('pending', 'failed');
-- has updated_at but no maintaining trigger (same gap).
-- ---------------------------------------------------------------------------
-- Derived views
-- ---------------------------------------------------------------------------
-- Derived-kind view: consumers asking "is this a plan product?" read this
-- instead of products.product_type, which has nothing to say for plans
-- (Doc 31 Amendment #3 -- plan-ness is structural, via plan_ladder_tiers
-- membership, not a label column).
CREATE VIEW core.product_kinds AS
SELECT
p.product_id,
(CASE
WHEN EXISTS (
SELECT 1 FROM core.plan_ladder_tiers t
WHERE t.product_id = p.product_id
) THEN 'plan'
ELSE p.product_type
END)::VARCHAR AS product_kind
FROM core.products p;
-- ---------------------------------------------------------------------------
-- Roles (Decision 3): the core role triple only. Integration streams
-- (fedwiki, stripe) each create and grant their own {slug}_owner/writer/
-- reader roles in their own migration stream's 00001 instead of here
-- (Decision 7 -- self-contained ownership; core always runs first (core ->
-- fedwiki -> stripe, per internal/migrate/sources.go), so core_reader
-- exists by the time each integration stream's cross-schema reader grant
-- runs).
--
-- Roles are cluster-global, not database-scoped, so creation is guarded:
-- a second database in the same cluster must not abort this stream.
-- ---------------------------------------------------------------------------
-- +goose StatementBegin
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'core_owner') THEN
CREATE ROLE core_owner NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'core_writer') THEN
CREATE ROLE core_writer NOLOGIN;
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'core_reader') THEN
CREATE ROLE core_reader NOLOGIN;
END IF;
END
$$;
-- +goose StatementEnd
-- Schema-level access.
GRANT USAGE ON SCHEMA core TO core_reader, core_writer, core_owner;
GRANT CREATE ON SCHEMA core TO core_owner;
-- Table-level privileges (also covers core.product_kinds: GRANT ON ALL
-- TABLES IN SCHEMA includes views, and the view already exists above).
GRANT ALL ON ALL TABLES IN SCHEMA core TO core_owner;
GRANT ALL ON ALL TABLES IN SCHEMA core TO core_writer;
GRANT SELECT ON ALL TABLES IN SCHEMA core TO core_reader;
-- Cross-schema reader inheritance: now that identity/organization/billing/
-- entitlements/integration are one schema, the five intra-core edges from
-- the old per-module reader-inheritance pattern dissolve into a no-op and
-- are not reproduced here. Integration streams' equivalent grants
-- (`GRANT core_reader TO {slug}_writer` for fedwiki and stripe) live in
-- their own stream's 00001 (Decision 7).
-- member_console (the DSN login role, created by Postgres initdb via
-- POSTGRES_USER) previously acquired privileges only by being a cluster
-- superuser -- undocumented in-repo (design.md Decision 3 gap). State its
-- application-level privileges explicitly; owner roles stay reserved for
-- migrations. Each integration stream's own `GRANT {slug}_writer TO
-- member_console` lives in its own stream's 00001 (Decision 7).
GRANT core_writer TO member_console;
-- +goose Down
-- +goose StatementBegin
REVOKE core_writer FROM member_console;
REVOKE SELECT ON ALL TABLES IN SCHEMA core FROM core_reader;
REVOKE ALL ON ALL TABLES IN SCHEMA core FROM core_writer;
REVOKE ALL ON ALL TABLES IN SCHEMA core FROM core_owner;
REVOKE CREATE ON SCHEMA core FROM core_owner;
REVOKE USAGE ON SCHEMA core FROM core_reader, core_writer, core_owner;
-- Roles are cluster-global: another database in this cluster may still own
-- objects or hold grants under them, in which case DROP ROLE raises
-- dependent_objects_still_exist. Leave such a role standing rather than
-- failing this database's rollback over another database's state.
DO $$
DECLARE
role_name TEXT;
BEGIN
FOREACH role_name IN ARRAY ARRAY['core_reader', 'core_writer', 'core_owner'] LOOP
BEGIN
EXECUTE format('DROP ROLE IF EXISTS %I', role_name);
EXCEPTION WHEN dependent_objects_still_exist THEN
RAISE NOTICE 'role % is still in use elsewhere in this cluster; left in place', role_name;
END;
END LOOP;
END
$$;
DROP VIEW core.product_kinds;
-- +goose StatementEnd
-- +goose StatementBegin
DROP TABLE IF EXISTS core.outbox CASCADE;
DROP TABLE IF EXISTS core.webhook_events CASCADE;
DROP TABLE IF EXISTS core.provider_states CASCADE;
DROP TABLE IF EXISTS core.provider_operations CASCADE;
DROP TABLE IF EXISTS core.entitlement_set_rules CASCADE;
DROP TABLE IF EXISTS core.numeric_entitlement_usage CASCADE;
DROP TABLE IF EXISTS core.numeric_entitlement_contributions CASCADE;
DROP TABLE IF EXISTS core.numeric_entitlements CASCADE;
DROP TABLE IF EXISTS core.resource_keys CASCADE;
DROP TABLE IF EXISTS core.providers CASCADE;
DROP TABLE IF EXISTS core.pool_provision_transitions CASCADE;
DROP TABLE IF EXISTS core.pool_provision_ladders CASCADE;
DROP TABLE IF EXISTS core.pool_provisions CASCADE;
DROP TABLE IF EXISTS core.grants CASCADE;
DROP TABLE IF EXISTS core.entitlement_sets CASCADE;
DROP TABLE IF EXISTS core.pool_assignments CASCADE;
DROP TABLE IF EXISTS core.resource_pools CASCADE;
DROP TABLE IF EXISTS core.subscription_scheduled_changes CASCADE;
DROP TABLE IF EXISTS core.plan_ladder_tiers CASCADE;
DROP TABLE IF EXISTS core.payments CASCADE;
DROP TABLE IF EXISTS core.invoice_line_items CASCADE;
DROP TABLE IF EXISTS core.invoices CASCADE;
DROP TABLE IF EXISTS core.payment_methods CASCADE;
DROP TABLE IF EXISTS core.subscription_changes CASCADE;
DROP TABLE IF EXISTS core.subscription_items CASCADE;
DROP TABLE IF EXISTS core.subscriptions CASCADE;
DROP TABLE IF EXISTS core.accounts CASCADE;
DROP TABLE IF EXISTS core.role_assignments CASCADE;
DROP TABLE IF EXISTS core.workspaces CASCADE;
DROP TABLE IF EXISTS core.org_members CASCADE;
DROP TABLE IF EXISTS core.roles CASCADE;
DROP TABLE IF EXISTS core.organizations CASCADE;
DROP TABLE IF EXISTS core.org_types CASCADE;
DROP TABLE IF EXISTS core.plan_ladders CASCADE;
DROP TABLE IF EXISTS core.prices CASCADE;
DROP TABLE IF EXISTS core.products CASCADE;
DROP TABLE IF EXISTS core.persons CASCADE;
DROP TABLE IF EXISTS core.users CASCADE;
-- +goose StatementEnd
-- +goose StatementBegin
DROP FUNCTION IF EXISTS core.check_grant_lineage_immutable();
DROP FUNCTION IF EXISTS core.check_grant_lineage_same_org();
DROP FUNCTION IF EXISTS core.sync_pool_provision_ladders();
DROP FUNCTION IF EXISTS public.update_updated_at_column();
-- +goose StatementEnd
-- btree_gist is left in place (harmless, potentially shared with other
-- extensions/roles) -- mirrors the old chain, which never dropped it either.
DROP SCHEMA IF EXISTS core;