Move completed design/implementation docs from docs/plans/ to docs/archive/ to keep the plans folder focused on active and future work. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1316 lines
37 KiB
Markdown
1316 lines
37 KiB
Markdown
# 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;
|
|
|
|
<span className="text-sm text-muted-foreground">
|
|
{limits.max_trees === null
|
|
? `${treeCount} trees`
|
|
: `${treeCount} / ${limits.max_trees} trees`
|
|
}
|
|
</span>
|
|
```
|
|
|
|
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 (
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
<AlertCircle className="text-yellow-600" />
|
|
<div>
|
|
<h3>{feature} requires an upgrade</h3>
|
|
<p>{description}</p>
|
|
<Button>Upgrade from {plan}</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
Use when API returns 402 Payment Required.
|
|
|
|
---
|
|
|
|
#### 3.10: Add Stripe Checkout Button (Disabled)
|
|
|
|
**File:** `frontend/src/components/subscription/CheckoutButton.tsx` (new)
|
|
|
|
```tsx
|
|
const stripePromise = import.meta.env.VITE_STRIPE_KEY
|
|
? loadStripe(import.meta.env.VITE_STRIPE_KEY)
|
|
: null;
|
|
|
|
export function CheckoutButton({ plan, interval }) {
|
|
const handleCheckout = async () => {
|
|
if (!stripePromise) {
|
|
alert('Payment processing coming soon!');
|
|
return;
|
|
}
|
|
|
|
// TODO: Create Checkout Session and redirect
|
|
};
|
|
|
|
return (
|
|
<Button onClick={handleCheckout} disabled={!stripePromise}>
|
|
{stripePromise ? 'Subscribe Now' : 'Coming Soon'}
|
|
</Button>
|
|
);
|
|
}
|
|
```
|
|
|
|
Shows "Coming Soon" until `VITE_STRIPE_KEY` is set.
|
|
|
|
**Add to `.env.local.example`:**
|
|
```
|
|
VITE_STRIPE_KEY=pk_test_... # Optional - only needed for paid plans
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 4: Stripe Preparation (Days 11-12)
|
|
|
|
Set up Stripe infrastructure without requiring active billing.
|
|
|
|
#### 4.1: Install Stripe Libraries
|
|
|
|
**Backend:**
|
|
```bash
|
|
pip install stripe
|
|
pip freeze > requirements.txt
|
|
```
|
|
|
|
**Frontend:**
|
|
```bash
|
|
npm install @stripe/stripe-js @stripe/react-stripe-js
|
|
```
|
|
|
|
---
|
|
|
|
#### 4.2: Stripe Dashboard Setup (Manual)
|
|
|
|
When ready to enable billing:
|
|
|
|
1. **Create Products:**
|
|
- ResolutionFlow Pro
|
|
- Monthly price (note price_id: `price_pro_monthly`)
|
|
- Annual price (note price_id: `price_pro_annual`)
|
|
- ResolutionFlow Team
|
|
- Monthly tiers (5 users, 15 users, 50 users)
|
|
- Annual tiers
|
|
|
|
2. **Configure Webhook:**
|
|
- Endpoint URL: `https://api.resolutionflow.com/api/v1/webhooks/stripe`
|
|
- Select events: checkout.session.completed, invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted
|
|
- Note webhook signing secret → `STRIPE_WEBHOOK_SECRET`
|
|
|
|
3. **Customer Portal:**
|
|
- Enable self-service billing management
|
|
- Configure allowed actions (cancel, change plan, update payment method)
|
|
|
|
---
|
|
|
|
#### 4.3: Environment Variables
|
|
|
|
**Backend `.env` (production):**
|
|
```bash
|
|
STRIPE_SECRET_KEY=sk_live_...
|
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
```
|
|
|
|
**Frontend `.env.production`:**
|
|
```bash
|
|
VITE_STRIPE_KEY=pk_live_...
|
|
```
|
|
|
|
Leave empty until ready to enable billing. Code handles missing keys gracefully.
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Phase 1: Database Migration Testing
|
|
|
|
After each migration:
|
|
```bash
|
|
# Run migration
|
|
alembic upgrade head
|
|
|
|
# Verify schema
|
|
docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d users"
|
|
docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d accounts"
|
|
|
|
# Verify data integrity
|
|
docker exec -it patherly_postgres psql -U postgres -d patherly -c "
|
|
SELECT COUNT(*) FROM users WHERE account_id IS NULL;
|
|
SELECT COUNT(*) FROM accounts;
|
|
SELECT COUNT(*) FROM subscriptions;
|
|
SELECT plan, COUNT(*) FROM subscriptions GROUP BY plan;
|
|
"
|
|
|
|
# Run backend tests (should pass even before code changes for migrations 16-17)
|
|
cd backend
|
|
pytest --override-ini="addopts="
|
|
```
|
|
|
|
**Test migration 018 on production copy:**
|
|
```bash
|
|
# Dump production data
|
|
docker exec patherly_postgres pg_dump -U postgres patherly > prod_backup.sql
|
|
|
|
# Load into test DB
|
|
docker exec -i patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_migration_test"
|
|
docker exec -i patherly_postgres psql -U postgres patherly_migration_test < prod_backup.sql
|
|
|
|
# Run migration 018 on test DB
|
|
# Edit DATABASE_URL to point to patherly_migration_test
|
|
alembic upgrade 018
|
|
|
|
# Verify results, then drop test DB when satisfied
|
|
```
|
|
|
|
---
|
|
|
|
### Phase 2: Backend Testing
|
|
|
|
**Incremental test fixing:**
|
|
```bash
|
|
# Fix test fixtures first
|
|
pytest tests/conftest.py -v
|
|
|
|
# Fix tests one module at a time
|
|
pytest tests/test_auth.py -v
|
|
pytest tests/test_trees.py -v
|
|
pytest tests/test_sessions.py -v
|
|
pytest tests/test_admin.py -v
|
|
pytest tests/test_permissions.py -v
|
|
|
|
# Full suite
|
|
pytest --override-ini="addopts="
|
|
```
|
|
|
|
**Add new tests:**
|
|
- `tests/test_subscription_limits.py` - Tree/session limit enforcement, 402 responses
|
|
- `tests/test_account_management.py` - Member role changes, account owner permissions
|
|
- `tests/test_permissions_account.py` - New account-role-based permission checks
|
|
|
|
**Goal:** 61+ passing tests before merging to main.
|
|
|
|
---
|
|
|
|
### Phase 3: Frontend Testing
|
|
|
|
**Type checking:**
|
|
```bash
|
|
cd frontend
|
|
npm run type-check # or npx tsc --noEmit
|
|
```
|
|
|
|
**Manual test checklist:**
|
|
- [ ] Login with migrated user sees account info
|
|
- [ ] Settings page shows subscription (Free plan, 3/3 trees)
|
|
- [ ] Tree library shows usage count (X / Y trees)
|
|
- [ ] Creating tree at limit shows 402 error → upgrade prompt
|
|
- [ ] Account owner can see team members
|
|
- [ ] Account owner can change member role (engineer ↔ viewer)
|
|
- [ ] Viewer cannot create trees (403 error)
|
|
- [ ] Viewer can browse trees and start sessions
|
|
- [ ] Super admin bypasses all checks
|
|
- [ ] Logout and re-login preserves account/subscription in store
|
|
|
|
---
|
|
|
|
### Phase 4: Stripe Testing
|
|
|
|
**Test mode validation:**
|
|
```bash
|
|
# Use Stripe test keys
|
|
STRIPE_SECRET_KEY=sk_test_...
|
|
STRIPE_WEBHOOK_SECRET=whsec_test_...
|
|
```
|
|
|
|
**Webhook testing with Stripe CLI:**
|
|
```bash
|
|
stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe
|
|
|
|
# Trigger test events
|
|
stripe trigger checkout.session.completed
|
|
stripe trigger invoice.paid
|
|
stripe trigger customer.subscription.deleted
|
|
```
|
|
|
|
Verify:
|
|
- [ ] Webhook endpoint receives events
|
|
- [ ] Signature verification works
|
|
- [ ] Events are logged (even if handlers are stubs)
|
|
- [ ] Invalid signature returns 400
|
|
|
|
---
|
|
|
|
## Deployment Strategy
|
|
|
|
### Development (Feature Branch)
|
|
|
|
```bash
|
|
# Create feature branch
|
|
git checkout -b feat/subscription-tiers
|
|
|
|
# Commit each phase incrementally
|
|
git add backend/alembic/versions/016_*.py
|
|
git commit -m "feat: add subscription tables (migration 016)"
|
|
|
|
git add backend/alembic/versions/017_*.py
|
|
git commit -m "feat: add account_id to users (migration 017)"
|
|
|
|
# ... continue for each phase
|
|
```
|
|
|
|
**Keep commits atomic:** Each migration in its own commit for easy rollback.
|
|
|
|
---
|
|
|
|
### Staging/Preview (Railway PR Environment)
|
|
|
|
1. **Open PR:** `feat/subscription-tiers` → `main`
|
|
2. Railway auto-creates preview environment
|
|
3. **Generate domains** for PR services in Railway dashboard
|
|
4. **Run migrations** on PR database (happens automatically via `releaseCommand`)
|
|
5. **Test thoroughly** using preview URL
|
|
6. **QA checklist:**
|
|
- [ ] Migrations ran successfully (check Railway logs)
|
|
- [ ] Backend tests pass in CI
|
|
- [ ] Frontend builds without errors
|
|
- [ ] Manual smoke test: register new user → see Free account → browse trees
|
|
- [ ] Webhook endpoint returns 200 (even with no Stripe config)
|
|
|
|
---
|
|
|
|
### Production Deployment
|
|
|
|
**Prerequisites:**
|
|
- [ ] All tests passing in CI
|
|
- [ ] Manual QA complete on PR environment
|
|
- [ ] Database backup created
|
|
- [ ] Rollback plan reviewed
|
|
|
|
**Steps:**
|
|
|
|
1. **Merge PR:** `feat/subscription-tiers` → `main`
|
|
2. Railway auto-deploys to production
|
|
3. Migrations run automatically (via `releaseCommand` in railway.json)
|
|
4. **Monitor deployment:**
|
|
- Check Railway logs for migration success
|
|
- Watch for errors in backend logs
|
|
- Test login/registration immediately
|
|
|
|
5. **Smoke test:**
|
|
- [ ] Login as existing user → verify account_id populated
|
|
- [ ] View trees → verify account_id filter works
|
|
- [ ] Create tree → verify limit enforcement (if at limit)
|
|
- [ ] Check settings page → see Free plan, usage stats
|
|
|
|
6. **Rollback if needed:**
|
|
- Railway: Redeploy previous version
|
|
- Database: `alembic downgrade <migration_number>`
|
|
- Restore from backup if migration cannot be reversed
|
|
|
|
**Expected downtime:** <1 minute (time for migrations to run)
|
|
|
|
---
|
|
|
|
## Rollback Plans
|
|
|
|
### Rollback After Migration 016-018 (Safe)
|
|
|
|
```bash
|
|
# Downgrade migrations one at a time
|
|
alembic downgrade -1 # Undo 018
|
|
alembic downgrade -1 # Undo 017
|
|
alembic downgrade -1 # Undo 016
|
|
|
|
# Verify rollback
|
|
docker exec -it patherly_postgres psql -U postgres -d patherly -c "\dt"
|
|
# Should NOT see: accounts, subscriptions, plan_limits, account_invites
|
|
# users.account_id should be NULL for all rows
|
|
|
|
# Run tests to confirm
|
|
pytest --override-ini="addopts="
|
|
```
|
|
|
|
---
|
|
|
|
### Rollback After Migration 019-020 (Careful)
|
|
|
|
```bash
|
|
alembic downgrade 018 # Undo constraints and FK migration
|
|
|
|
# Manually verify
|
|
docker exec -it patherly_postgres psql -U postgres -d patherly -c "
|
|
SELECT column_name, is_nullable
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'users' AND column_name IN ('account_id', 'account_role');
|
|
"
|
|
# account_id should be nullable again
|
|
```
|
|
|
|
---
|
|
|
|
### Rollback After Migration 021 (Point of No Return)
|
|
|
|
**Cannot easily rollback** - teams table is dropped.
|
|
|
|
**Options:**
|
|
1. Restore from database backup (recommended)
|
|
2. Manually recreate teams table and reverse-migrate data (complex, error-prone)
|
|
|
|
**Prevention:** Only run migration 021 AFTER Phase 2 backend code is deployed, tested, and stable in production for at least 24 hours.
|
|
|
|
---
|
|
|
|
## Risk Assessment
|
|
|
|
### High-Risk Items
|
|
|
|
| Risk | Impact | Mitigation |
|
|
|------|--------|------------|
|
|
| **Migration 018 data loss** | High - could orphan users/content | Test on production copy first, comprehensive validation in migration code |
|
|
| **Permission system breaks access** | High - users locked out of content | Thorough test coverage, manual QA with different roles |
|
|
| **Test suite breakage** | Medium - deployment blocked | Fix fixtures first, update tests incrementally |
|
|
| **Circular FK deadlock** | Medium - migration fails | Keep owner_id nullable until after user creation |
|
|
|
|
### Medium-Risk Items
|
|
|
|
| Risk | Impact | Mitigation |
|
|
|------|--------|------------|
|
|
| **Stripe webhook downtime** | Medium - subscriptions not updated | Webhook endpoint logs events even if handlers fail |
|
|
| **Frontend type errors** | Medium - build fails | TypeScript strict mode, incremental type updates |
|
|
| **Plan limit edge cases** | Low - users blocked unexpectedly | Conservative limits initially, easy to adjust via DB |
|
|
|
|
### Low-Risk Items
|
|
|
|
- Stripe integration (disabled by default, no impact until enabled)
|
|
- UI changes (progressive enhancement, no breaking changes)
|
|
- Account settings page (new page, doesn't affect existing flows)
|
|
|
|
---
|
|
|
|
## Critical Files
|
|
|
|
These files are the foundation of the implementation. Get these right and the rest follows:
|
|
|
|
1. **`backend/alembic/versions/018_migrate_users_to_accounts.py`**
|
|
- Most critical migration
|
|
- Handles data transformation from teams → accounts
|
|
- Must be idempotent and thoroughly tested on production copy
|
|
|
|
2. **`backend/app/models/user.py`**
|
|
- Core model change
|
|
- Affects all relationships and permission checks
|
|
- Breaking change point
|
|
|
|
3. **`backend/app/core/permissions.py`**
|
|
- Permission system refactor
|
|
- Every endpoint depends on this
|
|
- Must handle account_role correctly
|
|
|
|
4. **`backend/app/core/subscriptions.py`**
|
|
- Feature gating logic
|
|
- Used by all content creation endpoints
|
|
- Pattern for limit enforcement
|
|
|
|
5. **`backend/tests/conftest.py`**
|
|
- Test fixture foundation
|
|
- Updating correctly ensures incremental test fixing
|
|
- Prevents catastrophic test breakage
|
|
|
|
6. **`frontend/src/types/user.ts` + `account.ts`**
|
|
- Type foundation for frontend
|
|
- Breaking change for all components using User type
|
|
- Must match backend schemas exactly
|
|
|
|
---
|
|
|
|
## Success Criteria
|
|
|
|
### Minimum Viable Implementation
|
|
|
|
- [ ] All 6 migrations run successfully
|
|
- [ ] All 61+ backend tests passing
|
|
- [ ] Frontend builds without type errors
|
|
- [ ] Can register new user → creates account + free subscription
|
|
- [ ] Can login → sees account info in settings
|
|
- [ ] Tree creation enforces limits (shows 402 at limit)
|
|
- [ ] Account owners can manage team members
|
|
- [ ] Viewers are blocked from content creation
|
|
- [ ] Super admins bypass all checks
|
|
- [ ] Webhook endpoint exists and responds 200
|
|
|
|
### Production Ready
|
|
|
|
- [ ] Deployed to Railway production
|
|
- [ ] Existing users migrated to accounts successfully
|
|
- [ ] All features working as expected
|
|
- [ ] No permission leaks or access control issues
|
|
- [ ] Monitoring in place for errors
|
|
- [ ] Rollback plan tested and documented
|
|
|
|
### Stripe Ready (Future)
|
|
|
|
- [ ] Stripe products/prices created
|
|
- [ ] Webhook handlers fully implemented
|
|
- [ ] Checkout flow tested in test mode
|
|
- [ ] Customer portal configured
|
|
- [ ] Production keys added to environment
|
|
|
|
---
|
|
|
|
## Post-Implementation
|
|
|
|
After successful deployment:
|
|
|
|
1. **Monitor for 48 hours:**
|
|
- Watch error logs for permission issues
|
|
- Track failed tree creation attempts (limits)
|
|
- Monitor webhook endpoint for spam/attacks
|
|
|
|
2. **User communication:**
|
|
- Notify users of new account system
|
|
- Explain free tier limits
|
|
- Link to upgrade page (when ready)
|
|
|
|
3. **Documentation updates:**
|
|
- Update README with new architecture
|
|
- Document account management for team admins
|
|
- Add subscription tier comparison table
|
|
|
|
4. **Future enhancements:**
|
|
- Usage dashboard (current vs limits)
|
|
- Email notifications for limit warnings
|
|
- Team invite flow UI
|
|
- Multi-path registration (start free / start pro / join team)
|
|
- Stripe Checkout integration
|
|
|
|
---
|
|
|
|
## Timeline Summary
|
|
|
|
| Phase | Duration | Milestone |
|
|
|-------|----------|-----------|
|
|
| Phase 1: Database Migration | 2-3 days | All migrations pass, data migrated |
|
|
| Phase 2: Backend Updates | 3-4 days | All tests passing, endpoints updated |
|
|
| Phase 3: Frontend Updates | 2-3 days | TypeScript errors fixed, UI complete |
|
|
| Phase 4: Stripe Prep | 1-2 days | Webhook skeleton, test mode working |
|
|
| **Total** | **10-12 days** | **Production deployment** |
|
|
|
|
Add 1-2 days buffer for unexpected issues, QA, and deployment.
|
|
|
|
---
|
|
|
|
## Verification Checklist
|
|
|
|
Before merging to main:
|
|
|
|
**Database:**
|
|
- [ ] All 6 migrations run successfully in order
|
|
- [ ] Rollback tested for migrations 016-018
|
|
- [ ] Data integrity verified (no NULL account_id)
|
|
- [ ] plan_limits table seeded with 3 rows
|
|
|
|
**Backend:**
|
|
- [ ] 61+ tests passing
|
|
- [ ] New tests added for subscriptions and limits
|
|
- [ ] All endpoints use account_id (not team_id)
|
|
- [ ] Permissions use account_role (not role/is_team_admin)
|
|
- [ ] 402 returned when hitting plan limits
|
|
- [ ] Webhook endpoint responds to Stripe events
|
|
|
|
**Frontend:**
|
|
- [ ] TypeScript compiles with no errors
|
|
- [ ] All components use account_id
|
|
- [ ] usePermissions hook updated
|
|
- [ ] useSubscription hook works
|
|
- [ ] Settings page shows subscription info
|
|
- [ ] Usage indicators display correctly
|
|
- [ ] Upgrade prompts show on 402 errors
|
|
|
|
**Integration:**
|
|
- [ ] Can register new user
|
|
- [ ] Can login and see account info
|
|
- [ ] Can create trees (up to limit)
|
|
- [ ] Cannot create tree at limit (shows upgrade prompt)
|
|
- [ ] Account owner can manage members
|
|
- [ ] Viewer role blocked from creation
|
|
- [ ] Super admin bypasses everything
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
- **Keep this plan updated** as implementation progresses
|
|
- **Document deviations** if approach changes during implementation
|
|
- **Link commits** back to plan phases for traceability
|
|
- **Celebrate milestones** - this is a major architectural upgrade!
|
|
|