diff --git a/docs/plans/2026-02-06-subscription-tier-implementation.md b/docs/plans/2026-02-06-subscription-tier-implementation.md new file mode 100644 index 00000000..7c34ba3d --- /dev/null +++ b/docs/plans/2026-02-06-subscription-tier-implementation.md @@ -0,0 +1,1315 @@ +# Subscription Tier Architecture - Implementation Plan + +**Date:** 2026-02-06 +**Status:** Ready for implementation +**Branch Strategy:** Feature branch (`feat/subscription-tiers`) +**Estimated Duration:** 10-12 days + +--- + +## Context + +ResolutionFlow is transitioning from a flat team-based system to a SaaS subscription model with three tiers (Free, Pro, Team). This is a **foundational architectural change** that affects the core data model, permission system, and user experience. + +### Why This Change Is Needed + +The current system uses `teams` as the multi-tenant boundary with basic RBAC (`role` + `is_team_admin` flags). This was designed for internal/single-team use and lacks: +- Billing/subscription tracking +- Feature gating by plan tier +- Account ownership model +- Scalable permission system for SaaS + +The new architecture enables monetization while maintaining clean separation between **subscription features** (what you can access) and **account roles** (what you can do). + +### Current State Summary + +**Existing:** +- User model: `role`, `is_team_admin`, `team_id` fields +- Team model with FKs from 5 content tables (Tree, StepLibrary, Category, Tag, StepCategory) +- Permission system in `backend/app/core/permissions.py` +- 61 passing integration tests +- Production deployment on Railway (test/dev data only) + +**Missing:** +- No Stripe integration (not even the library) +- No Account/Subscription models +- No plan limits or feature gating +- No multi-path registration flow + +### Key Decisions + +- **Timeline:** ASAP (1-2 weeks) +- **Data:** Only test/dev data exists - safe to drop/recreate DB if needed +- **Scope:** Full 3-tier system (Free + Pro + Team) with Stripe webhook skeleton +- **Stripe:** Build Stripe-ready infrastructure, enable when ready to monetize +- **Migration Strategy:** All existing users → Free tier accounts +- **Limits:** 3 trees, 20 sessions/month for Free (configurable via `plan_limits` table) +- **Development:** Feature branch with thorough testing before merge + +--- + +## Implementation Phases + +### Phase 1: Database Migration (Days 1-3) + +Create new tables and migrate existing data in 6 separate migrations for safety and rollback capability. + +#### Migration 016: Create New Tables + +**File:** `backend/alembic/versions/016_add_subscription_tables.py` + +Create 4 new tables: +1. **accounts** - Billing entity (replaces teams conceptually) + - Columns: id, name, display_code (8-char unique), owner_id, stripe_customer_id, timestamps + - Owner initially nullable (set after user creation) + +2. **subscriptions** - One per account + - Columns: id, account_id (unique FK), stripe_subscription_id, stripe_price_id, plan, billing_interval, status, seat_limit, period dates, cancel flag + - Indexed on account_id and plan + +3. **plan_limits** - Configuration table (not user data) + - Columns: plan (unique), max_trees, max_sessions_per_month, max_users, custom_branding, priority_support, export_formats (JSONB) + - Seed with 3 rows: free (3 trees, 20 sessions), pro (25/200), team (unlimited) + +4. **account_invites** - Team invitation codes + - Columns: id, account_id, invited_by_id, email, code, role, accepted_by_id, expires_at, timestamps + +**Why separate:** Independent tables, no FK dependencies yet, zero impact on existing code. + +**Rollback:** Drop tables in reverse order. + +**Validation:** +```sql +SELECT COUNT(*) FROM plan_limits; -- Should be 3 +SELECT plan, max_trees FROM plan_limits ORDER BY plan; +``` + +--- + +#### Migration 017: Add Columns to Users + +**File:** `backend/alembic/versions/017_add_account_id_to_users.py` + +Add to `users` table: +- `account_id` (UUID, nullable, indexed) +- `account_role` (String, nullable - will become 'owner'/'engineer'/'viewer') + +**Why separate:** Keeps this atomic, allows testing before data migration. + +**Rollback:** Drop columns and index. + +**Validation:** All 61 existing tests should still pass (columns nullable and unused). + +--- + +#### Migration 018: Migrate Users to Accounts (CRITICAL) + +**File:** `backend/alembic/versions/018_migrate_users_to_accounts.py` + +**This is the most critical migration.** Must be idempotent with comprehensive validation. + +**Logic:** + +1. **For each existing team:** + - Create Account (name from team.name, generate 8-char display_code) + - Set owner_id to first team admin (or first user who joined) + - Create free Subscription for that account + - Update all users in that team: set account_id, map role → account_role: + - `is_team_admin=True` → `account_role='owner'` + - `role='engineer'` → `account_role='engineer'` + - `role='viewer'` → `account_role='viewer'` + +2. **For each user without a team:** + - Create personal Account (name: "User's Account") + - Set owner_id to that user + - Create free Subscription + - Update user: set account_id, account_role='owner' (personal accounts default to owner) + +3. **Validate:** + - `SELECT COUNT(*) FROM users WHERE account_id IS NULL` → must be 0 + - `SELECT COUNT(*) FROM accounts` → should equal (teams + teamless users) + - `SELECT COUNT(*) FROM subscriptions` → should equal accounts + +**Display code generation:** Use `secrets.choice()` with charset excluding confusing chars (0, O, I, 1, L). Check uniqueness before inserting. + +**Rollback:** Downgrade clears account_id/account_role, deletes all accounts/subscriptions. + +**Test before production:** Run on a copy of production DB first! + +--- + +#### Migration 019: Migrate Foreign Keys + +**File:** `backend/alembic/versions/019_migrate_team_fks_to_account.py` + +For each table with `team_id`: trees, step_library, tree_categories, tree_tags, step_categories + +1. Add `account_id` column (UUID, nullable, indexed) +2. Migrate data: `UPDATE table SET account_id = (SELECT account_id FROM users WHERE users.team_id = table.team_id LIMIT 1)` +3. Validate: Ensure no rows have team_id but NULL account_id + +**Why separate:** Allows verifying user migration succeeded before touching content tables. + +**Rollback:** Drop account_id columns and indexes. + +--- + +#### Migration 020: Add Constraints + +**File:** `backend/alembic/versions/020_finalize_account_migration.py` + +1. Users table: + - Make account_id NOT NULL + - Make account_role NOT NULL + - Add FK: account_id → accounts.id (CASCADE) + - Add CHECK: account_role IN ('owner', 'engineer', 'viewer') + +2. Content tables: + - Add FK: account_id → accounts.id (CASCADE, nullable OK for global content) + +3. Accounts table: + - Make owner_id NOT NULL + - Add FK: owner_id → users.id (RESTRICT - can't delete owner without transfer) + +**Why last:** Only enforces constraints after data integrity verified. + +**Point of no return:** After this, old team-based code will break. + +--- + +#### Migration 021: Drop Old Columns + +**File:** `backend/alembic/versions/021_drop_old_team_columns.py` + +1. Drop team_id FKs and columns from: trees, step_library, tree_categories, tree_tags, step_categories +2. Drop from users: team_id, is_team_admin, role (replaced by account_role) +3. Drop CHECK constraint on old role field +4. Drop teams table entirely + +**Why last:** Point of no return. Run ONLY after Phase 2 backend code is deployed and tested. + +**Rollback:** Complex - requires recreating teams table and restoring relationships. Keep database backup! + +--- + +### Phase 2: Backend Updates (Days 4-7) + +Update all backend code to use account-based system and add subscription features. + +#### 2.1: Create New Models + +**Files to create:** + +- `backend/app/models/account.py` + - Relationships: owner (User), users (list), subscription (one), trees, categories, tags + - Generated display_code in migration, not model default + +- `backend/app/models/subscription.py` + - Relationship: account (back_populates) + - Properties: is_active, is_paid, days_until_renewal + +- `backend/app/models/plan_limits.py` + - No relationships, pure configuration + - JSONB export_formats field + +- `backend/app/models/account_invite.py` + - Relationships: account, invited_by, accepted_by + - Properties: is_valid, is_expired + +**Files to modify:** + +- `backend/app/models/user.py` + - **Remove:** team_id, is_team_admin, role + - **Add:** account_id (FK, NOT NULL), account_role (String, NOT NULL) + - **Update relationships:** account (via account_id), owned_account (via Account.owner_id) + - **Add properties:** is_account_owner, can_manage_account + - **Add CHECK:** account_role IN ('owner', 'engineer', 'viewer') + +- Update Tree, StepLibrary, Category, Tag, StepCategory: + - Replace team_id → account_id + - Update relationships: account (not team) + +**Critical:** Handle multiple FKs to users table with explicit `foreign_keys` parameters. + +--- + +#### 2.2: Refactor Permissions System + +**File:** `backend/app/core/permissions.py` (major refactor) + +**New role hierarchy:** +```python +ROLE_HIERARCHY = { + "super_admin": 4, + "owner": 3, # NEW - replaces is_team_admin + "engineer": 2, + "viewer": 1, +} +``` + +**Key function updates:** + +```python +def get_effective_role(user: User) -> str: + return "super_admin" if user.is_super_admin else user.account_role + +def can_manage_account(user: User) -> bool: + return user.is_super_admin or user.account_role == "owner" + +def can_edit_tree(user: User, tree: Tree) -> bool: + if user.is_super_admin: return True + if not can_create_content(user): return False + if tree.author_id == user.id: return True + # Account owners can edit any tree in their account + if user.account_role == "owner" and tree.account_id == user.account_id: + return True + return False +``` + +**Replace everywhere:** +- `team_id` → `account_id` +- `is_team_admin` → `account_role == "owner"` +- Add `can_manage_account()` for billing/member management + +--- + +#### 2.3: Add Subscription Helpers + +**File:** `backend/app/core/subscriptions.py` (new) + +Core functions: +```python +async def get_account_subscription(account_id, db) -> Subscription +async def get_plan_limits(plan: str, db) -> PlanLimits +async def get_user_plan_limits(user_id, db) -> PlanLimits + +async def check_tree_limit(account_id, db) -> tuple[bool, Optional[int], int] + # Returns: (can_create, limit, current_count) + +async def check_session_limit(account_id, db) -> tuple[bool, Optional[int], int] + # Counts sessions this month for entire account +``` + +These will be used by endpoints for feature gating. + +--- + +#### 2.4: Update API Dependencies + +**File:** `backend/app/api/deps.py` + +Update: +```python +async def require_engineer_or_admin(current_user): + if current_user.is_super_admin: return current_user + if current_user.account_role in ("owner", "engineer"): return current_user + raise HTTPException(403, "Engineer or admin access required") + +async def require_account_owner(current_user): + if current_user.is_super_admin or current_user.account_role == "owner": + return current_user + raise HTTPException(403, "Account owner access required") +``` + +Add new: +```python +async def get_plan_limits_for_user(current_user, db) -> PlanLimits: + from app.core.subscriptions import get_user_plan_limits + return await get_user_plan_limits(current_user.id, db) + +def require_feature(feature_name: str): + """Factory for feature-gated dependencies.""" + async def checker(limits = Depends(get_plan_limits_for_user)): + if not getattr(limits, feature_name, False): + raise HTTPException(402, "This feature requires a plan upgrade") + return checker +``` + +Use HTTP 402 for subscription blocks (vs 403 for permission blocks). + +--- + +#### 2.5: Update All Endpoints + +**Files to modify:** trees.py, sessions.py, steps.py, categories.py, tags.py, folders.py, admin.py + +**Pattern for creation endpoints:** + +```python +@router.post("/trees", response_model=TreeResponse) +async def create_tree( + tree_data: TreeCreate, + current_user: Annotated[User, Depends(require_engineer_or_admin)], + db: Annotated[AsyncSession, Depends(get_db)], +): + # Check plan limit + from app.core.subscriptions import check_tree_limit + can_create, limit, current = await check_tree_limit(current_user.account_id, db) + + if not can_create: + raise HTTPException(402, f"Tree limit reached ({current}/{limit}). Upgrade your plan.") + + new_tree = Tree( + author_id=current_user.id, + account_id=current_user.account_id, # Changed from team_id + # ... + ) +``` + +**Pattern for access filters:** + +```python +def build_tree_access_filter(current_user: User): + if current_user.is_super_admin: + return sa_true() + + conditions = [ + Tree.is_default == True, + Tree.is_public == True, + Tree.author_id == current_user.id, + ] + + if current_user.account_id: + conditions.append(Tree.account_id == current_user.account_id) + + return or_(*conditions) +``` + +--- + +#### 2.6: Add Account Management Endpoints + +**File:** `backend/app/api/endpoints/accounts.py` (new) + +```python +GET /api/v1/accounts/me → AccountResponse +GET /api/v1/accounts/me/subscription → SubscriptionResponse (includes limits) +GET /api/v1/accounts/me/members → List[User] +PATCH /api/v1/accounts/me/members/{id}/role (owner only) +DELETE /api/v1/accounts/me/members/{id} (owner only) +PATCH /api/v1/accounts/me → Update account name +``` + +Member management requires `require_account_owner` dependency. + +--- + +#### 2.7: Update Registration Endpoint + +**File:** `backend/app/api/endpoints/auth.py` + +```python +@router.post("/register") +async def register(user_data: UserCreate, db): + # Validate invite code (if required) + # Check email uniqueness + + # 1. Create account + display_code = generate_unique_display_code(db) + new_account = Account( + name=f"{user_data.name}'s Account", + display_code=display_code, + owner_id=None # Set after user creation + ) + db.add(new_account) + await db.flush() + + # 2. Create user + new_user = User( + email=user_data.email, + password_hash=get_password_hash(user_data.password), + name=user_data.name, + account_id=new_account.id, + account_role="owner", # Personal accounts default to owner + ) + db.add(new_user) + await db.flush() + + # 3. Set account owner + new_account.owner_id = new_user.id + + # 4. Create free subscription + subscription = Subscription( + account_id=new_account.id, + plan="free", + status="active", + ) + db.add(subscription) + + await db.commit() + return new_user +``` + +--- + +#### 2.8: Add Stripe Webhook Skeleton + +**File:** `backend/app/api/endpoints/webhooks.py` (new) + +```python +@router.post("/stripe") +async def stripe_webhook(request: Request, db: AsyncSession): + if not settings.STRIPE_WEBHOOK_SECRET: + return {"status": "ignored"} # Not configured yet + + # Verify signature + payload = await request.body() + sig = request.headers.get("stripe-signature") + event = stripe.Webhook.construct_event(payload, sig, settings.STRIPE_WEBHOOK_SECRET) + + # Dispatch to handler + handler = WEBHOOK_HANDLERS.get(event['type']) + if handler: + await handler(event, db) + + return {"status": "received"} +``` + +**File:** `backend/app/core/stripe_handlers.py` (new) + +Implement handlers for: +- `checkout.session.completed` - Create/update subscription with Stripe IDs +- `invoice.paid` - Update period dates, set status=active +- `invoice.payment_failed` - Set status=past_due, notify owner +- `customer.subscription.updated` - Sync plan changes +- `customer.subscription.deleted` - Downgrade to free + +Initially these can be stubs that log events. Full implementation when Stripe is enabled. + +**File:** `backend/app/core/config.py` + +Add optional Stripe settings: +```python +STRIPE_SECRET_KEY: Optional[str] = None +STRIPE_PUBLISHABLE_KEY: Optional[str] = None +STRIPE_WEBHOOK_SECRET: Optional[str] = None + +@property +def stripe_enabled(self) -> bool: + return bool(self.STRIPE_SECRET_KEY and self.STRIPE_WEBHOOK_SECRET) +``` + +**Add to requirements.txt:** `stripe` + +--- + +#### 2.9: Update Test Fixtures + +**File:** `backend/tests/conftest.py` + +**Critical updates:** + +```python +@pytest.fixture +async def test_account(test_db): + """Create account with free subscription.""" + account = Account( + name="Test Account", + display_code=generate_unique_code(), + owner_id=None + ) + test_db.add(account) + await test_db.flush() + + subscription = Subscription( + account_id=account.id, + plan="free", + status="active", + ) + test_db.add(subscription) + await test_db.commit() + return account + +@pytest.fixture +async def test_user(test_db, test_account): + """Create user linked to test account.""" + user = User( + email="test@example.com", + password_hash=get_password_hash("TestPassword123!"), + name="Test User", + account_id=test_account.id, + account_role="owner" + ) + test_db.add(user) + await test_db.flush() + + test_account.owner_id = user.id + await test_db.commit() + return user + +@pytest.fixture +async def test_admin(test_db): + """Create super admin.""" + # Create admin account + user with is_super_admin=True +``` + +**Strategy:** Update fixtures first, then fix failing tests incrementally (one module at a time). + +--- + +### Phase 3: Frontend Updates (Days 8-10) + +Update React app to use account-based API and add subscription UI. + +#### 3.1: Update Type Definitions + +**File:** `frontend/src/types/user.ts` + +```typescript +export interface User { + id: string; + email: string; + name: string; + account_id: string; // Changed from team_id + account_role: 'owner' | 'engineer' | 'viewer'; // Changed from role + is_super_admin: boolean; + is_active: boolean; + created_at: string; + last_login: string | null; +} +``` + +**File:** `frontend/src/types/account.ts` (new) + +```typescript +export interface Account { + id: string; + name: string; + display_code: string; + owner_id: string; + stripe_customer_id: string | null; + created_at: string; + updated_at: string; +} + +export interface Subscription { + plan: 'free' | 'pro' | 'team'; + status: 'active' | 'past_due' | 'canceled' | 'trialing'; + // ... other fields +} + +export interface PlanLimits { + plan: string; + max_trees: number | null; + max_sessions_per_month: number | null; + custom_branding: boolean; + export_formats: string[]; +} + +export interface SubscriptionDetails { + subscription: Subscription; + limits: PlanLimits; +} +``` + +--- + +#### 3.2: Update Auth Store + +**File:** `frontend/src/store/authStore.ts` + +Add to state: +```typescript +account: Account | null; +subscription: SubscriptionDetails | null; + +setUser: (user: User, account: Account, subscription: SubscriptionDetails) => void; +``` + +Persist account and subscription in localStorage. + +--- + +#### 3.3: Create Subscription Hook + +**File:** `frontend/src/hooks/useSubscription.ts` (new) + +```typescript +export function useSubscription() { + const { subscription } = useAuthStore(); + + return { + plan: subscription.subscription.plan, + limits: subscription.limits, + + canUseFeature: (feature: keyof PlanLimits) => Boolean(limits[feature]), + isPaidPlan: plan !== 'free', + isTeamPlan: plan === 'team', + + formatLimit: (resource: 'trees' | 'sessions') => { + const key = resource === 'trees' ? 'max_trees' : 'max_sessions_per_month'; + return limits[key] === null ? 'Unlimited' : String(limits[key]); + } + }; +} +``` + +--- + +#### 3.4: Update usePermissions Hook + +**File:** `frontend/src/hooks/usePermissions.ts` + +```typescript +const effectiveRole = user.is_super_admin ? 'super_admin' : user.account_role; + +return { + canCreateContent: effectiveRole !== 'viewer', + canManageAccount: user.is_super_admin || user.account_role === 'owner', + isAccountOwner: user.account_role === 'owner', + + canEditTree: (tree) => { + if (user.is_super_admin) return true; + if (effectiveRole === 'viewer') return false; + if (tree.author_id === user.id) return true; + if (user.account_role === 'owner' && tree.account_id === user.account_id) return true; + return false; + }, + + canDeleteTree: (tree) => { + if (user.is_super_admin) return true; + return user.account_role === 'owner' && tree.account_id === user.account_id; + }, +}; +``` + +--- + +#### 3.5: Create Account API Client + +**File:** `frontend/src/api/accounts.ts` (new) + +```typescript +export const accountsApi = { + getMyAccount: () => api.get('/api/v1/accounts/me'), + getMySubscription: () => api.get('/api/v1/accounts/me/subscription'), + getMembers: () => api.get('/api/v1/accounts/me/members'), + updateMemberRole: (userId: string, role: 'engineer' | 'viewer') => + api.patch(`/api/v1/accounts/me/members/${userId}/role`, { role }), + removeMember: (userId: string) => + api.delete(`/api/v1/accounts/me/members/${userId}`), +}; +``` + +--- + +#### 3.6: Update Login/Register to Fetch Account Data + +**File:** `frontend/src/api/auth.ts` + +```typescript +export const authApi = { + login: async (credentials) => { + const { data } = await api.post('/api/v1/auth/login/json', credentials); + const { access_token, refresh_token, user } = data; + + // Fetch account and subscription + const [accountRes, subRes] = await Promise.all([ + accountsApi.getMyAccount(), + accountsApi.getMySubscription() + ]); + + return { + access_token, + refresh_token, + user, + account: accountRes.data, + subscription: subRes.data + }; + } +}; +``` + +Update auth store login action to call `setUser(user, account, subscription)`. + +--- + +#### 3.7: Add Account Settings Page + +**File:** `frontend/src/pages/AccountSettingsPage.tsx` (new) + +Sections: +1. **Subscription Info** + - Current plan badge (Free/Pro/Team) + - Usage stats (trees, sessions this month) + - Upgrade button (if free plan) + +2. **Team Members** (only for account owners) + - List members with role dropdowns + - Remove member button + - Generate invite link button + +3. **Billing** (only for paid plans) + - Link to Stripe Customer Portal (when Stripe enabled) + +Use `useSubscription()` and `usePermissions()` hooks. + +**Route:** Add `/settings/account` to router. + +--- + +#### 3.8: Add Usage Indicators + +**File:** `frontend/src/pages/TreeLibraryPage.tsx` + +Near "Create Tree" button, show: +```tsx +const { formatLimit } = useSubscription(); +const treeCount = trees?.length || 0; + + + {limits.max_trees === null + ? `${treeCount} trees` + : `${treeCount} / ${limits.max_trees} trees` + } + +``` + +Disable "Create Tree" button if at limit, show upgrade prompt. + +--- + +#### 3.9: Add Upgrade Prompt Component + +**File:** `frontend/src/components/common/UpgradePrompt.tsx` (new) + +```tsx +export function UpgradePrompt({ feature, description }: Props) { + const { plan } = useSubscription(); + + return ( +
{description}
+ +