Files
docqube_backend/alembic/versions/d1_0_plans_and_entitlements.py
T
2026-09-08 11:00:05 +05:30

220 lines
10 KiB
Python

"""D1 — plans, limits, subscriptions and overrides
The landing page advertises four priced plans and the backend had never heard of
a plan. Meanwhile `tenants` carried four limit columns set per tenant by hand, so
two customers on the same advertised plan could silently have different limits.
This gives those limits something to be derived from.
**Behaviour-neutral by construction.** Every tenant's four current values become
**overrides**, not plan limits — so whatever they have today is exactly what they
keep, whichever plan they are placed on. The plan is then chosen to match, or
`custom` when nothing does. Nobody's limits move.
The `tenants` columns are **not** dropped. They stay for one release and remain
the last-resort fallback in `EntitlementService`, the same transition shape as
`users.role_id` and for the same reason: a rollback must not be able to remove
every tenant's quota.
`plans` and `plan_limits` are deliberately **not** tenant-scoped — a catalogue is
shared, like `accesses`. `tenant_subscriptions` and `tenant_limit_overrides` are,
and are added to the RLS policy here.
Revision ID: d1_0_plans_and_entitlements
Revises: c3_0_org_unit_path
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision = "d1_0_plans_and_entitlements"
down_revision = "c3_0_org_unit_path"
branch_labels = None
depends_on = None
TENANT_TABLES = ["tenant_subscriptions", "tenant_limit_overrides"]
POLICY = "tenant_isolation"
USING = """
coalesce(current_setting('docqube.bypass', true), '') = 'on'
OR tenant_id IS NULL
OR tenant_id = nullif(current_setting('docqube.tenant_id', true), '')::uuid
"""
# The four plans the pricing page already sells, with the limits it claims.
# Seeded here rather than in `scripts/seed.py` so that a database which has run
# migrations is one a customer could be placed on — the page and the schema
# start out agreeing.
#
# -1 means unlimited, matching the convention `tenants.envelope_limit` already
# uses.
GB = 1024 ** 3
PLANS = [
# code, name, price, public, order
("starter", "Starter plan", "$12", True, 1),
("professional", "Professional plan", "$22", True, 2),
("elite", "Elite plan", "$29", True, 3),
("custom", "Custom plan", "Contact Us", True, 4),
]
LIMITS = {
"starter": {"storage_bytes": 10 * GB, "seats": 5,
"chat_tokens_daily": 500_000, "envelopes": 25},
"professional": {"storage_bytes": 100 * GB, "seats": 25,
"chat_tokens_daily": 2_000_000, "envelopes": 250},
"elite": {"storage_bytes": 500 * GB, "seats": 100,
"chat_tokens_daily": 10_000_000, "envelopes": -1},
"custom": {"storage_bytes": -1, "seats": -1,
"chat_tokens_daily": -1, "envelopes": -1},
}
def upgrade() -> None:
op.create_table(
"plans",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("code", sa.String(50), nullable=False, unique=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("price_display", sa.String(50), nullable=True),
sa.Column("currency", sa.String(10), nullable=True),
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_plans_code", "plans", ["code"])
op.create_table(
"plan_limits",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("plan_id", UUID(as_uuid=True),
sa.ForeignKey("plans.id", ondelete="CASCADE"), nullable=False),
sa.Column("key", sa.String(50), nullable=False),
sa.Column("value", sa.BigInteger(), nullable=False),
sa.UniqueConstraint("plan_id", "key", name="uq_plan_limits_plan_key"),
)
op.create_index("ix_plan_limits_plan_id", "plan_limits", ["plan_id"])
op.create_table(
"tenant_subscriptions",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
# RESTRICT: deleting a plan somebody is on would silently drop them to
# the defaults. Move them first, deliberately.
sa.Column("plan_id", UUID(as_uuid=True),
sa.ForeignKey("plans.id", ondelete="RESTRICT"), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
sa.Column("started_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False),
sa.Column("current_period_end", sa.DateTime(timezone=True), nullable=True),
sa.Column("cancel_at_period_end", sa.Boolean(), nullable=False,
server_default=sa.false()),
sa.Column("external_ref", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False),
# One live subscription per tenant: a second "current" row is an
# ambiguity nothing can resolve.
sa.UniqueConstraint("tenant_id", name="uq_tenant_subscriptions_tenant"),
)
op.create_index("ix_tenant_subscriptions_tenant_id", "tenant_subscriptions", ["tenant_id"])
op.create_index("ix_tenant_subscriptions_plan_id", "tenant_subscriptions", ["plan_id"])
op.create_index("ix_tenant_subscriptions_status", "tenant_subscriptions", ["status"])
op.create_index("ix_tenant_subscriptions_external_ref", "tenant_subscriptions", ["external_ref"])
op.create_table(
"tenant_limit_overrides",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("key", sa.String(50), nullable=False),
sa.Column("value", sa.BigInteger(), nullable=False),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True),
server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("tenant_id", "key", name="uq_tenant_limit_overrides"),
)
op.create_index("ix_tenant_limit_overrides_tenant_id", "tenant_limit_overrides", ["tenant_id"])
# --- seed the catalogue -------------------------------------------------
for code, name, price, public, order in PLANS:
op.execute(
sa.text(
"INSERT INTO plans (id, code, name, price_display, currency, "
"is_public, sort_order, created_at, updated_at) "
"VALUES (gen_random_uuid(), :code, :name, :price, 'USD', "
":public, :order, now(), now())"
).bindparams(code=code, name=name, price=price, public=public, order=order)
)
for key, value in LIMITS[code].items():
op.execute(
sa.text(
"INSERT INTO plan_limits (id, plan_id, key, value) "
"SELECT gen_random_uuid(), id, :key, :value FROM plans "
" WHERE code = :code"
).bindparams(key=key, value=value, code=code)
)
# --- backfill, behaviour-neutral ----------------------------------------
#
# Current values become **overrides**, so every tenant keeps exactly what it
# has regardless of which plan it lands on. The plan is a label until
# somebody removes the override.
op.execute(
"""
INSERT INTO tenant_limit_overrides (id, tenant_id, key, value, note, created_at)
SELECT gen_random_uuid(), t.id, v.key, v.value,
'Carried over from the tenant row at D1; not a negotiated exception',
now()
FROM tenants t
CROSS JOIN LATERAL (VALUES
('storage_bytes', t.storage_quota_bytes),
('seats', COALESCE(t.max_users_allowed, -1)),
('chat_tokens_daily', t.chat_token_daily_limit),
('envelopes', t.envelope_limit)
) AS v(key, value)
WHERE t.is_deleted = false
ON CONFLICT DO NOTHING
"""
)
# Every live tenant is placed on `custom`, not on a guessed plan.
#
# Matching limits to a plan would be inference, and inferring a commercial
# fact is how somebody ends up on Starter because their quota happened to
# look like it. `custom` is honest: nobody has said what they are on yet,
# and their overrides mean it changes nothing.
op.execute(
"""
INSERT INTO tenant_subscriptions (id, tenant_id, plan_id, status,
started_at, created_at, updated_at)
SELECT gen_random_uuid(), t.id, p.id, 'active', now(), now(), now()
FROM tenants t
CROSS JOIN (SELECT id FROM plans WHERE code = 'custom') p
WHERE t.is_deleted = false
ON CONFLICT DO NOTHING
"""
)
for table in TENANT_TABLES:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}")
op.execute(f"CREATE POLICY {POLICY} ON {table} USING ({USING}) WITH CHECK ({USING})")
def downgrade() -> None:
for table in TENANT_TABLES:
op.execute(f"DROP POLICY IF EXISTS {POLICY} ON {table}")
# Safe to reverse: the `tenants` columns were never touched, so removing
# these tables returns the application to the limits it still reads.
op.drop_table("tenant_limit_overrides")
op.drop_table("tenant_subscriptions")
op.drop_table("plan_limits")
op.drop_table("plans")