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:
chihlasm
2026-02-07 02:38:47 -05:00
parent fb84bd8144
commit 4ccb93ee31
22 changed files with 933 additions and 47 deletions

View 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')

View File

@@ -0,0 +1,31 @@
"""add account_id and account_role columns to users
Revision ID: 017
Revises: 016
Create Date: 2026-02-07
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '017'
down_revision: Union[str, None] = '016'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('account_id', UUID(as_uuid=True), nullable=True))
op.add_column('users', sa.Column('account_role', sa.String(50), nullable=True))
op.create_index('ix_users_account_id', 'users', ['account_id'])
def downgrade() -> None:
op.drop_index('ix_users_account_id', table_name='users')
op.drop_column('users', 'account_role')
op.drop_column('users', 'account_id')

View File

@@ -0,0 +1,187 @@
"""migrate existing users and teams to accounts
Revision ID: 018
Revises: 017
Create Date: 2026-02-07
This is the most critical migration. It creates a _team_account_mapping table
for deterministic cross-migration lookups, then migrates all users to accounts.
Three paths:
A) Teams with users → Account with deterministic owner
B) Teams with zero users → Account with owner_id=NULL, subscription status='orphaned'
C) Users without a team → Personal account, user is owner
"""
import secrets
import string
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '018'
down_revision: Union[str, None] = '017'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Characters for display codes — exclude confusing chars
DISPLAY_CODE_CHARS = string.ascii_uppercase + string.digits
DISPLAY_CODE_CHARS = DISPLAY_CODE_CHARS.replace('0', '').replace('O', '').replace('I', '').replace('1', '').replace('L', '')
def _generate_display_code(existing_codes: set) -> str:
"""Generate a unique 8-character display code."""
for _ in range(100):
code = ''.join(secrets.choice(DISPLAY_CODE_CHARS) for _ in range(8))
if code not in existing_codes:
existing_codes.add(code)
return code
raise RuntimeError("Failed to generate unique display code after 100 attempts")
def upgrade() -> None:
conn = op.get_bind()
# Create mapping table for deterministic cross-migration lookups
op.create_table(
'_team_account_mapping',
sa.Column('team_id', UUID(as_uuid=True), nullable=False),
sa.Column('account_id', UUID(as_uuid=True), nullable=False),
sa.Column('owner_user_id', UUID(as_uuid=True), nullable=True),
sa.PrimaryKeyConstraint('team_id'),
)
existing_codes: set = set()
# --- Path A & B: Process all teams ---
teams = conn.execute(sa.text("SELECT id, name FROM teams")).fetchall()
for team in teams:
team_id = team[0]
team_name = team[1]
display_code = _generate_display_code(existing_codes)
# Find deterministic owner: team admin first, then earliest user
owner_row = conn.execute(sa.text("""
SELECT id FROM users
WHERE team_id = :tid
ORDER BY is_team_admin DESC, created_at ASC, id ASC
LIMIT 1
"""), {"tid": team_id}).fetchone()
owner_user_id = owner_row[0] if owner_row else None
# Create account
conn.execute(sa.text("""
INSERT INTO accounts (id, name, display_code, owner_id, created_at, updated_at)
VALUES (gen_random_uuid(), :name, :code, :owner_id, NOW(), NOW())
"""), {"name": team_name, "code": display_code, "owner_id": owner_user_id})
# Get the account we just created
account_row = conn.execute(sa.text(
"SELECT id FROM accounts WHERE display_code = :code"
), {"code": display_code}).fetchone()
account_id = account_row[0]
# Insert mapping
conn.execute(sa.text("""
INSERT INTO _team_account_mapping (team_id, account_id, owner_user_id)
VALUES (:tid, :aid, :uid)
"""), {"tid": team_id, "aid": account_id, "uid": owner_user_id})
if owner_user_id is not None:
# Path A: Team with users
# Create active subscription
conn.execute(sa.text("""
INSERT INTO subscriptions (id, account_id, plan, status, created_at, updated_at)
VALUES (gen_random_uuid(), :aid, 'free', 'active', NOW(), NOW())
"""), {"aid": account_id})
# Update all users in this team
# Team admins become owners, others keep their role
conn.execute(sa.text("""
UPDATE users SET
account_id = :aid,
account_role = CASE
WHEN is_team_admin = true THEN 'owner'
ELSE role
END
WHERE team_id = :tid
"""), {"aid": account_id, "tid": team_id})
else:
# Path B: Team with zero users (orphan)
conn.execute(sa.text("""
INSERT INTO subscriptions (id, account_id, plan, status, created_at, updated_at)
VALUES (gen_random_uuid(), :aid, 'free', 'orphaned', NOW(), NOW())
"""), {"aid": account_id})
# --- Path C: Users without a team ---
teamless_users = conn.execute(sa.text(
"SELECT id, name FROM users WHERE team_id IS NULL AND account_id IS NULL"
)).fetchall()
for user in teamless_users:
user_id = user[0]
user_name = user[1]
display_code = _generate_display_code(existing_codes)
# Create personal account (owner_id set to NULL initially)
conn.execute(sa.text("""
INSERT INTO accounts (id, name, display_code, owner_id, created_at, updated_at)
VALUES (gen_random_uuid(), :name, :code, NULL, NOW(), NOW())
"""), {"name": f"{user_name}'s Account", "code": display_code})
account_row = conn.execute(sa.text(
"SELECT id FROM accounts WHERE display_code = :code"
), {"code": display_code}).fetchone()
account_id = account_row[0]
# Update user
conn.execute(sa.text("""
UPDATE users SET account_id = :aid, account_role = 'owner'
WHERE id = :uid
"""), {"aid": account_id, "uid": user_id})
# Set owner
conn.execute(sa.text("""
UPDATE accounts SET owner_id = :uid WHERE id = :aid
"""), {"uid": user_id, "aid": account_id})
# Create free subscription
conn.execute(sa.text("""
INSERT INTO subscriptions (id, account_id, plan, status, created_at, updated_at)
VALUES (gen_random_uuid(), :aid, 'free', 'active', NOW(), NOW())
"""), {"aid": account_id})
# --- Validation ---
orphaned_users = conn.execute(sa.text(
"SELECT COUNT(*) FROM users WHERE account_id IS NULL"
)).scalar()
if orphaned_users > 0:
raise RuntimeError(
f"Migration 018 failed validation: {orphaned_users} users still have NULL account_id"
)
team_count = conn.execute(sa.text("SELECT COUNT(*) FROM teams")).scalar()
mapping_count = conn.execute(sa.text("SELECT COUNT(*) FROM _team_account_mapping")).scalar()
if mapping_count != team_count:
raise RuntimeError(
f"Migration 018 failed: mapping count ({mapping_count}) != team count ({team_count})"
)
def downgrade() -> None:
conn = op.get_bind()
# Clear account data from users
conn.execute(sa.text("UPDATE users SET account_id = NULL, account_role = NULL"))
# Delete all subscriptions and accounts created by this migration
conn.execute(sa.text("DELETE FROM subscriptions"))
conn.execute(sa.text("DELETE FROM accounts"))
# Drop mapping table
op.drop_table('_team_account_mapping')

View File

@@ -0,0 +1,56 @@
"""add account_id to content tables and backfill from _team_account_mapping
Revision ID: 019
Revises: 018
Create Date: 2026-02-07
Uses the _team_account_mapping table from migration 018 for deterministic
FK backfill instead of non-deterministic LIMIT 1 subqueries.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '019'
down_revision: Union[str, None] = '018'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Tables that have team_id and need account_id
CONTENT_TABLES = ['trees', 'step_library', 'tree_categories', 'tree_tags', 'step_categories']
def upgrade() -> None:
conn = op.get_bind()
for table in CONTENT_TABLES:
# Add account_id column
op.add_column(table, sa.Column('account_id', UUID(as_uuid=True), nullable=True))
op.create_index(f'ix_{table}_account_id', table, ['account_id'])
# Backfill from mapping table (deterministic)
conn.execute(sa.text(f"""
UPDATE {table} SET account_id = m.account_id
FROM _team_account_mapping m
WHERE {table}.team_id = m.team_id
"""))
# Validate: no rows with team_id but missing account_id
orphaned = conn.execute(sa.text(f"""
SELECT COUNT(*) FROM {table}
WHERE team_id IS NOT NULL AND account_id IS NULL
""")).scalar()
if orphaned > 0:
raise RuntimeError(
f"Migration 019 failed: {table} has {orphaned} rows with team_id but no account_id"
)
def downgrade() -> None:
for table in reversed(CONTENT_TABLES):
op.drop_index(f'ix_{table}_account_id', table_name=table)
op.drop_column(table, 'account_id')

View File

@@ -0,0 +1,105 @@
"""finalize account migration — add constraints, clean up orphans
Revision ID: 020
Revises: 019
Create Date: 2026-02-07
Adds NOT NULL constraints, foreign keys, and CHECK constraints.
Cleans up orphan accounts (zero-user teams with no content).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = '020'
down_revision: Union[str, None] = '019'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
CONTENT_TABLES = ['trees', 'step_library', 'tree_categories', 'tree_tags', 'step_categories']
def upgrade() -> None:
conn = op.get_bind()
# 1. Clean up orphan accounts (zero-user teams with no content)
conn.execute(sa.text("""
DELETE FROM subscriptions WHERE account_id IN (
SELECT a.id FROM accounts a
WHERE a.owner_id IS NULL
AND NOT EXISTS (SELECT 1 FROM trees t WHERE t.account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM tree_categories tc WHERE tc.account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM tree_tags tt WHERE tt.account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM step_categories sc WHERE sc.account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM step_library sl WHERE sl.account_id = a.id)
)
"""))
# Also remove the mapping entries for these orphans
conn.execute(sa.text("""
DELETE FROM _team_account_mapping WHERE account_id IN (
SELECT a.id FROM accounts a
WHERE a.owner_id IS NULL
AND NOT EXISTS (SELECT 1 FROM trees t WHERE t.account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM tree_categories tc WHERE tc.account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM tree_tags tt WHERE tt.account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM step_categories sc WHERE sc.account_id = a.id)
AND NOT EXISTS (SELECT 1 FROM step_library sl WHERE sl.account_id = a.id)
)
"""))
conn.execute(sa.text("""
DELETE FROM accounts
WHERE owner_id IS NULL
AND NOT EXISTS (SELECT 1 FROM trees t WHERE t.account_id = accounts.id)
AND NOT EXISTS (SELECT 1 FROM tree_categories tc WHERE tc.account_id = accounts.id)
AND NOT EXISTS (SELECT 1 FROM tree_tags tt WHERE tt.account_id = accounts.id)
AND NOT EXISTS (SELECT 1 FROM step_categories sc WHERE sc.account_id = accounts.id)
AND NOT EXISTS (SELECT 1 FROM step_library sl WHERE sl.account_id = accounts.id)
"""))
# 2. Users: enforce NOT NULL and add FK + CHECK
op.alter_column('users', 'account_id', nullable=False)
op.alter_column('users', 'account_role', nullable=False)
op.create_foreign_key(
'fk_users_account_id', 'users', 'accounts',
['account_id'], ['id'], ondelete='CASCADE'
)
op.create_check_constraint(
'ck_users_account_role_enum', 'users',
"account_role IN ('owner', 'engineer', 'viewer')"
)
# 3. Content tables: add FK on account_id (nullable OK — NULL means global)
for table in CONTENT_TABLES:
op.create_foreign_key(
f'fk_{table}_account_id', table, 'accounts',
['account_id'], ['id'], ondelete='CASCADE'
)
# 4. Accounts: enforce owner_id NOT NULL + FK
op.alter_column('accounts', 'owner_id', nullable=False)
op.create_foreign_key(
'fk_accounts_owner_id', 'accounts', 'users',
['owner_id'], ['id'], ondelete='RESTRICT'
)
def downgrade() -> None:
# Remove account owner FK and nullable constraint
op.drop_constraint('fk_accounts_owner_id', 'accounts', type_='foreignkey')
op.alter_column('accounts', 'owner_id', nullable=True)
# Remove content table FKs
for table in reversed(CONTENT_TABLES):
op.drop_constraint(f'fk_{table}_account_id', table, type_='foreignkey')
# Remove user constraints
op.drop_constraint('ck_users_account_role_enum', 'users', type_='check')
op.drop_constraint('fk_users_account_id', 'users', type_='foreignkey')
op.alter_column('users', 'account_role', nullable=True)
op.alter_column('users', 'account_id', nullable=True)