Files
saas_backend/alembic/versions/d4f6a8b0c902_subscription_lifecycle.py
T

113 lines
4.0 KiB
Python
Raw Normal View History

2026-08-31 20:04:12 -04:00
"""Grace periods, cancellation, and a record of what changed.
Before this, a subscription had two states: working and locked out. A customer
whose card failed went straight from one to the other, with nothing in between
and no record of when or why it happened.
Adds:
- `subscription_plans.grace_period_days` — how long after expiry a workspace stays
read-only rather than locked out. Per plan, because it is a commercial decision
rather than a constant. Defaults to 0, which is exactly the old behaviour.
- `tenants.cancelled_at` — cancellation is a decision with a date, not the absence
of an active flag.
- `tenant_subscription_history` — what changed, when, and who did it. Answering
"why does this workspace have that plan" currently requires guessing.
Revision ID: d4f6a8b0c902
Revises: c3d5e7f9a801
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "d4f6a8b0c902"
down_revision: Union[str, Sequence[str], None] = "c3d5e7f9a801"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"subscription_plans",
sa.Column("grace_period_days", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"tenants", sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True)
)
op.create_table(
"tenant_subscription_history",
sa.Column(
"id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")
),
sa.Column(
"tenant_id",
UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"from_plan_id",
UUID(as_uuid=True),
sa.ForeignKey("subscription_plans.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"to_plan_id",
UUID(as_uuid=True),
sa.ForeignKey("subscription_plans.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("change_type", sa.String(30), nullable=False),
sa.Column("from_end_date", sa.Date(), nullable=True),
sa.Column("to_end_date", sa.Date(), nullable=True),
sa.Column("from_status", sa.String(30), nullable=True),
sa.Column("to_status", sa.String(30), nullable=True),
sa.Column(
"changed_by_id",
UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("changed_by_email", sa.String(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
)
op.create_index(
"ix_tenant_subscription_history_tenant",
"tenant_subscription_history",
["tenant_id", sa.text("created_at DESC")],
)
op.execute("ALTER TABLE tenant_subscription_history ENABLE ROW LEVEL SECURITY")
op.execute("ALTER TABLE tenant_subscription_history FORCE ROW LEVEL SECURITY")
op.execute(
"""
CREATE POLICY tenant_isolation ON tenant_subscription_history
USING (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
WITH CHECK (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
"""
)
def downgrade() -> None:
op.execute("DROP POLICY IF EXISTS tenant_isolation ON tenant_subscription_history")
op.drop_index(
"ix_tenant_subscription_history_tenant", table_name="tenant_subscription_history"
)
op.drop_table("tenant_subscription_history")
op.drop_column("tenants", "cancelled_at")
op.drop_column("subscription_plans", "grace_period_days")