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

@@ -52,6 +52,16 @@ class Settings(BaseSettings):
# Registration
REQUIRE_INVITE_CODE: bool = True # Set to False to allow open registration
# Stripe
STRIPE_SECRET_KEY: Optional[str] = None
STRIPE_PUBLISHABLE_KEY: Optional[str] = None
STRIPE_WEBHOOK_SECRET: Optional[str] = None
@property
def stripe_enabled(self) -> bool:
"""Check if Stripe is configured."""
return self.STRIPE_SECRET_KEY is not None and self.STRIPE_WEBHOOK_SECRET is not None
# CORS - set FRONTEND_URL in production (e.g., https://patherly.up.railway.app)
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173", "http://localhost:5174"]
FRONTEND_URL: Optional[str] = None

View File

@@ -1,12 +1,12 @@
"""
Centralized permission checks for ResolutionFlow.
Role hierarchy: super_admin > team_admin > engineer > viewer
Role hierarchy: super_admin > owner > engineer > viewer
- super_admin: is_super_admin=True, full system access
- team_admin: is_team_admin=True + valid team_id, manage team resources
- engineer: role='engineer' (default), CRUD own trees/steps
- viewer: role='viewer', read-only (can browse, run sessions, rate steps)
- owner: account_role='owner', manage account resources
- engineer: account_role='engineer' (default), CRUD own trees/steps
- viewer: account_role='viewer', read-only (can browse, run sessions, rate steps)
"""
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
@@ -21,19 +21,19 @@ if TYPE_CHECKING:
ROLE_HIERARCHY = {
"super_admin": 4,
"team_admin": 3,
"owner": 3,
"engineer": 2,
"viewer": 1,
}
def get_effective_role(user: User) -> str:
"""Get the effective role considering is_super_admin and is_team_admin flags."""
"""Get the effective role considering is_super_admin and account_role."""
if user.is_super_admin:
return "super_admin"
if user.is_team_admin and user.team_id is not None:
return "team_admin"
return user.role # "engineer" or "viewer"
if user.account_role == "owner":
return "owner"
return user.account_role # "engineer" or "viewer"
def has_minimum_role(user: User, minimum_role: str) -> bool:
@@ -55,7 +55,7 @@ def can_edit_tree(user: User, tree: Tree) -> bool:
return False
if tree.author_id == user.id:
return True
if user.is_team_admin and tree.team_id == user.team_id and user.team_id is not None:
if user.account_role == "owner" and tree.account_id == user.account_id and user.account_id is not None:
return True
return False
@@ -78,7 +78,7 @@ def can_manage_category(user: User, category: TreeCategory) -> bool:
"""Can the user edit/delete this category?"""
if user.is_super_admin:
return True
if user.is_team_admin and category.team_id == user.team_id and user.team_id is not None:
if user.account_role == "owner" and category.account_id == user.account_id and user.account_id is not None:
return True
return False
@@ -91,7 +91,7 @@ def can_manage_tree_tags(user: User, tree: Tree) -> bool:
return False
if tree.author_id == user.id:
return True
if user.is_team_admin and tree.team_id == user.team_id and user.team_id is not None:
if user.account_role == "owner" and tree.account_id == user.account_id and user.account_id is not None:
return True
return False
@@ -102,7 +102,7 @@ def can_access_tree(user: User, tree: Tree) -> bool:
return True
if tree.author_id == user.id:
return True
if tree.team_id == user.team_id and user.team_id is not None:
if tree.account_id == user.account_id and user.account_id is not None:
return True
if user.is_super_admin:
return True
@@ -116,35 +116,35 @@ def can_view_step(user: User, step: StepLibrary) -> bool:
if step.visibility == "private":
return step.created_by == user.id
if step.visibility == "team":
return (step.team_id == user.team_id and user.team_id is not None) or user.is_super_admin
return (step.account_id == user.account_id and user.account_id is not None) or user.is_super_admin
return False
def can_create_tag(user: User, team_id: Optional[UUID]) -> bool:
def can_create_tag(user: User, account_id: Optional[UUID]) -> bool:
"""Can the user create a tag for the given scope?
- Super admins can create global tags (team_id=None) or any team's tags
- Engineers can create team tags for their own team
- Super admins can create global tags (account_id=None) or any account's tags
- Engineers can create account tags for their own account
- Viewers cannot create tags
"""
if user.is_super_admin:
return True
if not can_create_content(user):
return False
if team_id is not None and team_id == user.team_id:
if account_id is not None and account_id == user.account_id:
return True
return False
def can_create_category(user: User, team_id: Optional[UUID]) -> bool:
"""Can the user create a category for the given team?
def can_create_category(user: User, account_id: Optional[UUID]) -> bool:
"""Can the user create a category for the given account?
- Super admins can create global or any team's categories
- Team admins can create categories for their own team
- Super admins can create global or any account's categories
- Account owners can create categories for their own account
"""
if user.is_super_admin:
return True
if user.is_team_admin and team_id == user.team_id and user.team_id is not None:
if user.account_role == "owner" and account_id == user.account_id and user.account_id is not None:
return True
return False
@@ -153,19 +153,19 @@ def can_manage_step_category(user: User, category: StepCategory) -> bool:
"""Can the user edit/delete this step category?"""
if user.is_super_admin:
return True
if user.is_team_admin and category.team_id == user.team_id and user.team_id is not None:
if user.account_role == "owner" and category.account_id == user.account_id and user.account_id is not None:
return True
return False
def can_create_step_category(user: User, team_id: Optional[UUID]) -> bool:
"""Can the user create a step category for the given team?
def can_create_step_category(user: User, account_id: Optional[UUID]) -> bool:
"""Can the user create a step category for the given account?
- Super admins can create global or any team's step categories
- Team admins can create step categories for their own team
- Super admins can create global or any account's step categories
- Account owners can create step categories for their own account
"""
if user.is_super_admin:
return True
if user.is_team_admin and team_id == user.team_id and user.team_id is not None:
if user.account_role == "owner" and account_id == user.account_id and user.account_id is not None:
return True
return False

View File

@@ -0,0 +1,37 @@
"""Stripe webhook event handlers (stub implementations).
These handlers log events but don't process them until Stripe is fully configured.
"""
import logging
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
async def handle_checkout_completed(event: dict, db: AsyncSession) -> None:
logger.info("Stripe: checkout.session.completed — %s", event.get("id"))
async def handle_invoice_paid(event: dict, db: AsyncSession) -> None:
logger.info("Stripe: invoice.paid — %s", event.get("id"))
async def handle_invoice_payment_failed(event: dict, db: AsyncSession) -> None:
logger.warning("Stripe: invoice.payment_failed — %s", event.get("id"))
async def handle_subscription_updated(event: dict, db: AsyncSession) -> None:
logger.info("Stripe: customer.subscription.updated — %s", event.get("id"))
async def handle_subscription_deleted(event: dict, db: AsyncSession) -> None:
logger.info("Stripe: customer.subscription.deleted — %s", event.get("id"))
WEBHOOK_HANDLERS = {
"checkout.session.completed": handle_checkout_completed,
"invoice.paid": handle_invoice_paid,
"invoice.payment_failed": handle_invoice_payment_failed,
"customer.subscription.updated": handle_subscription_updated,
"customer.subscription.deleted": handle_subscription_deleted,
}

View File

@@ -0,0 +1,113 @@
"""Subscription limit checks and plan helpers."""
from typing import Optional
from uuid import UUID
from datetime import datetime, timezone
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.subscription import Subscription
from app.models.plan_limits import PlanLimits
from app.models.tree import Tree
from app.models.session import Session
async def get_account_subscription(account_id: UUID, db: AsyncSession) -> Optional[Subscription]:
result = await db.execute(
select(Subscription).where(Subscription.account_id == account_id)
)
return result.scalar_one_or_none()
async def get_plan_limits(plan: str, db: AsyncSession) -> Optional[PlanLimits]:
result = await db.execute(
select(PlanLimits).where(PlanLimits.plan == plan)
)
return result.scalar_one_or_none()
async def get_user_plan_limits(user_account_id: UUID, db: AsyncSession) -> Optional[PlanLimits]:
sub = await get_account_subscription(user_account_id, db)
if sub is None:
return await get_plan_limits("free", db)
return await get_plan_limits(sub.plan, db)
async def check_tree_limit(account_id: UUID, db: AsyncSession) -> tuple[bool, Optional[int], int]:
"""Check if account can create a new tree.
Returns: (can_create, limit, current_count)
"""
sub = await get_account_subscription(account_id, db)
if sub is None:
return False, 0, 0
limits = await get_plan_limits(sub.plan, db)
if limits is None or limits.max_trees is None:
return True, None, 0 # unlimited
current_count = await db.scalar(
select(func.count(Tree.id)).where(
Tree.account_id == account_id,
Tree.deleted_at.is_(None),
)
)
current_count = current_count or 0
return current_count < limits.max_trees, limits.max_trees, current_count
async def check_session_limit(account_id: UUID, db: AsyncSession) -> tuple[bool, Optional[int], int]:
"""Check if account can create a new session this month.
Returns: (can_create, limit, current_count)
"""
sub = await get_account_subscription(account_id, db)
if sub is None:
return False, 0, 0
limits = await get_plan_limits(sub.plan, db)
if limits is None or limits.max_sessions_per_month is None:
return True, None, 0 # unlimited
# Count sessions this calendar month for all users in this account
now = datetime.now(timezone.utc)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
from app.models.user import User
current_count = await db.scalar(
select(func.count(Session.id)).where(
Session.user_id.in_(
select(User.id).where(User.account_id == account_id)
),
Session.started_at >= month_start,
)
)
current_count = current_count or 0
return current_count < limits.max_sessions_per_month, limits.max_sessions_per_month, current_count
async def get_account_usage(account_id: UUID, db: AsyncSession) -> dict:
"""Get current usage stats for an account."""
tree_count = await db.scalar(
select(func.count(Tree.id)).where(
Tree.account_id == account_id,
Tree.deleted_at.is_(None),
)
) or 0
now = datetime.now(timezone.utc)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
from app.models.user import User
session_count = await db.scalar(
select(func.count(Session.id)).where(
Session.user_id.in_(
select(User.id).where(User.account_id == account_id)
),
Session.started_at >= month_start,
)
) or 0
return {"tree_count": tree_count, "session_count_this_month": session_count}