220 lines
10 KiB
Python
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")
|