feat: add account-based subscription model with migrations
Transition from team-based to account-based multi-tenancy (Free/Pro/Team). Migrations 016-020 create accounts, subscriptions, plan_limits, and account_invites tables, then migrate existing users and content FKs. New models: Account, Subscription, PlanLimits, AccountInvite. Updated models add account_id alongside existing team_id (coexistence for safe two-PR deployment). Permissions and deps refactored for account_role instead of is_team_admin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
110
backend/alembic/versions/016_add_subscription_tables.py
Normal file
110
backend/alembic/versions/016_add_subscription_tables.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""add accounts, subscriptions, plan_limits, and account_invites tables
|
||||
|
||||
Revision ID: 016
|
||||
Revises: 015
|
||||
Create Date: 2026-02-07
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '016'
|
||||
down_revision: Union[str, None] = '015'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. accounts table
|
||||
op.create_table(
|
||||
'accounts',
|
||||
sa.Column('id', UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('display_code', sa.String(8), nullable=False),
|
||||
sa.Column('owner_id', UUID(as_uuid=True), nullable=True), # nullable until user created
|
||||
sa.Column('stripe_customer_id', sa.String(255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('display_code', name='uq_accounts_display_code'),
|
||||
)
|
||||
|
||||
# 2. subscriptions table
|
||||
op.create_table(
|
||||
'subscriptions',
|
||||
sa.Column('id', UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('account_id', UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('stripe_subscription_id', sa.String(255), nullable=True),
|
||||
sa.Column('stripe_price_id', sa.String(255), nullable=True),
|
||||
sa.Column('plan', sa.String(50), nullable=False, server_default='free'),
|
||||
sa.Column('billing_interval', sa.String(20), nullable=True), # 'monthly' or 'annual'
|
||||
sa.Column('status', sa.String(50), nullable=False, server_default='active'),
|
||||
sa.Column('seat_limit', sa.Integer, nullable=True),
|
||||
sa.Column('current_period_start', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('current_period_end', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('cancel_at_period_end', sa.Boolean, nullable=False, server_default='false'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
|
||||
sa.UniqueConstraint('account_id', name='uq_subscriptions_account_id'),
|
||||
)
|
||||
op.create_index('ix_subscriptions_account_id', 'subscriptions', ['account_id'])
|
||||
op.create_index('ix_subscriptions_plan', 'subscriptions', ['plan'])
|
||||
|
||||
# 3. plan_limits table (configuration — seeded with 3 rows)
|
||||
op.create_table(
|
||||
'plan_limits',
|
||||
sa.Column('plan', sa.String(50), nullable=False),
|
||||
sa.Column('max_trees', sa.Integer, nullable=True), # NULL = unlimited
|
||||
sa.Column('max_sessions_per_month', sa.Integer, nullable=True),
|
||||
sa.Column('max_users', sa.Integer, nullable=True),
|
||||
sa.Column('custom_branding', sa.Boolean, nullable=False, server_default='false'),
|
||||
sa.Column('priority_support', sa.Boolean, nullable=False, server_default='false'),
|
||||
sa.Column('export_formats', JSONB, nullable=False, server_default='["markdown", "text"]'),
|
||||
sa.PrimaryKeyConstraint('plan'),
|
||||
)
|
||||
|
||||
# Seed plan_limits
|
||||
op.execute("""
|
||||
INSERT INTO plan_limits (plan, max_trees, max_sessions_per_month, max_users, custom_branding, priority_support, export_formats)
|
||||
VALUES
|
||||
('free', 3, 20, 1, false, false, '["markdown", "text"]'),
|
||||
('pro', 25, 200, 1, false, true, '["markdown", "text", "html", "pdf"]'),
|
||||
('team', NULL, NULL, NULL, true, true, '["markdown", "text", "html", "pdf"]')
|
||||
""")
|
||||
|
||||
# 4. account_invites table
|
||||
op.create_table(
|
||||
'account_invites',
|
||||
sa.Column('id', UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
|
||||
sa.Column('account_id', UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('invited_by_id', UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('email', sa.String(255), nullable=False),
|
||||
sa.Column('code', sa.String(32), nullable=False),
|
||||
sa.Column('role', sa.String(50), nullable=False, server_default='engineer'),
|
||||
sa.Column('accepted_by_id', UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False),
|
||||
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['invited_by_id'], ['users.id']),
|
||||
sa.ForeignKeyConstraint(['accepted_by_id'], ['users.id']),
|
||||
sa.UniqueConstraint('code', name='uq_account_invites_code'),
|
||||
sa.CheckConstraint("role IN ('engineer', 'viewer')", name='ck_account_invites_role'),
|
||||
)
|
||||
op.create_index('ix_account_invites_account_id', 'account_invites', ['account_id'])
|
||||
op.create_index('ix_account_invites_email', 'account_invites', ['email'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('account_invites')
|
||||
op.drop_table('plan_limits')
|
||||
op.drop_table('subscriptions')
|
||||
op.drop_table('accounts')
|
||||
Reference in New Issue
Block a user