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>
37 KiB
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_idfields - 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_limitstable) - 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:
-
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)
-
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
-
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)
-
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:
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:
-
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'
-
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)
-
Validate:
SELECT COUNT(*) FROM users WHERE account_id IS NULL→ must be 0SELECT 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
- Add
account_idcolumn (UUID, nullable, indexed) - Migrate data:
UPDATE table SET account_id = (SELECT account_id FROM users WHERE users.team_id = table.team_id LIMIT 1) - 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
-
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')
-
Content tables:
- Add FK: account_id → accounts.id (CASCADE, nullable OK for global content)
-
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
- Drop team_id FKs and columns from: trees, step_library, tree_categories, tree_tags, step_categories
- Drop from users: team_id, is_team_admin, role (replaced by account_role)
- Drop CHECK constraint on old role field
- 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:
ROLE_HIERARCHY = {
"super_admin": 4,
"owner": 3, # NEW - replaces is_team_admin
"engineer": 2,
"viewer": 1,
}
Key function updates:
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_idis_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:
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:
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:
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:
@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:
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)
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
@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)
@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 IDsinvoice.paid- Update period dates, set status=activeinvoice.payment_failed- Set status=past_due, notify ownercustomer.subscription.updated- Sync plan changescustomer.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:
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:
@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
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)
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:
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)
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
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)
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
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:
-
Subscription Info
- Current plan badge (Free/Pro/Team)
- Usage stats (trees, sessions this month)
- Upgrade button (if free plan)
-
Team Members (only for account owners)
- List members with role dropdowns
- Remove member button
- Generate invite link button
-
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:
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)
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)
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:
pip install stripe
pip freeze > requirements.txt
Frontend:
npm install @stripe/stripe-js @stripe/react-stripe-js
4.2: Stripe Dashboard Setup (Manual)
When ready to enable billing:
-
Create Products:
- ResolutionFlow Pro
- Monthly price (note price_id:
price_pro_monthly) - Annual price (note price_id:
price_pro_annual)
- Monthly price (note price_id:
- ResolutionFlow Team
- Monthly tiers (5 users, 15 users, 50 users)
- Annual tiers
- ResolutionFlow Pro
-
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
- Endpoint URL:
-
Customer Portal:
- Enable self-service billing management
- Configure allowed actions (cancel, change plan, update payment method)
4.3: Environment Variables
Backend .env (production):
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
Frontend .env.production:
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:
# 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:
# 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:
# 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 responsestests/test_account_management.py- Member role changes, account owner permissionstests/test_permissions_account.py- New account-role-based permission checks
Goal: 61+ passing tests before merging to main.
Phase 3: Frontend Testing
Type checking:
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:
# Use Stripe test keys
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_test_...
Webhook testing with Stripe CLI:
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)
# 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)
- Open PR:
feat/subscription-tiers→main - Railway auto-creates preview environment
- Generate domains for PR services in Railway dashboard
- Run migrations on PR database (happens automatically via
releaseCommand) - Test thoroughly using preview URL
- 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:
-
Merge PR:
feat/subscription-tiers→main -
Railway auto-deploys to production
-
Migrations run automatically (via
releaseCommandin railway.json) -
Monitor deployment:
- Check Railway logs for migration success
- Watch for errors in backend logs
- Test login/registration immediately
-
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
-
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)
# 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)
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:
- Restore from database backup (recommended)
- 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:
-
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
-
backend/app/models/user.py- Core model change
- Affects all relationships and permission checks
- Breaking change point
-
backend/app/core/permissions.py- Permission system refactor
- Every endpoint depends on this
- Must handle account_role correctly
-
backend/app/core/subscriptions.py- Feature gating logic
- Used by all content creation endpoints
- Pattern for limit enforcement
-
backend/tests/conftest.py- Test fixture foundation
- Updating correctly ensures incremental test fixing
- Prevents catastrophic test breakage
-
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:
-
Monitor for 48 hours:
- Watch error logs for permission issues
- Track failed tree creation attempts (limits)
- Monitor webhook endpoint for spam/attacks
-
User communication:
- Notify users of new account system
- Explain free tier limits
- Link to upgrade page (when ready)
-
Documentation updates:
- Update README with new architecture
- Document account management for team admins
- Add subscription tier comparison table
-
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!