Files
resolutionflow/backend/app/core/subscriptions.py
chihlasm 4ccb93ee31 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>
2026-02-07 02:38:47 -05:00

114 lines
3.7 KiB
Python

"""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}