docs: add subscription tier architecture plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
458
docs/plans/subscription-tier-architecture.md
Normal file
458
docs/plans/subscription-tier-architecture.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# Subscription Tier Architecture — Design Document
|
||||
|
||||
> **Date:** 2026-02-05
|
||||
> **Status:** Draft
|
||||
> **Scope:** Subscription-based access control, Stripe integration, feature gating, and registration flow redesign
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
ResolutionFlow currently uses a flat role-based system (`engineer` / `viewer`) with boolean flags (`is_super_admin`, `is_team_admin`) for elevated permissions. This was built for internal/single-team use.
|
||||
|
||||
The product is moving to a SaaS model with three subscription tiers (Free, Pro, Team), Stripe billing, and a registration flow where permissions derive from **subscription plan + team role** rather than a standalone role field.
|
||||
|
||||
This document defines the architecture for that transition.
|
||||
|
||||
---
|
||||
|
||||
## Subscription Tiers
|
||||
|
||||
| | Free | Pro | Team |
|
||||
|---|---|---|---|
|
||||
| **Price** | $0 | TBD/month | TBD/month |
|
||||
| **Billing** | None | Monthly or Annual | Monthly or Annual |
|
||||
| **Users** | 1 | 1 | Tiered brackets (e.g., 1-5, 6-15, 16-50) |
|
||||
| **Trees** | Limited (e.g., 3) | Higher limit (e.g., 25) | Unlimited or high limit |
|
||||
| **Sessions/month** | Limited (e.g., 20) | Higher limit (e.g., 200) | Unlimited or high limit |
|
||||
| **Custom branding** | No | No | Yes |
|
||||
| **Priority support** | No | No | Yes |
|
||||
| **Role assignment** | N/A (single user) | N/A (single user) | Team admin assigns roles |
|
||||
|
||||
> **Note:** Specific limits and pricing are TBD. The architecture supports changing these values without code changes (configured in the database, not hardcoded).
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Two-Layer Permission Model
|
||||
|
||||
The current single-layer model (`role` determines everything) is replaced by two layers:
|
||||
|
||||
1. **Subscription tier** → What **features and limits** you have access to
|
||||
2. **Team role** → What you can **do** within your workspace (only meaningful for Team plans)
|
||||
|
||||
**How they interact:**
|
||||
|
||||
| Scenario | Subscription Tier | Team Role | What They Can Do |
|
||||
|---|---|---|---|
|
||||
| Free user signs up | Free | owner (implicit) | Access free-tier features, sole user in their account |
|
||||
| Pro user signs up | Pro | owner (implicit) | Access pro-tier features, sole user in their account |
|
||||
| Person buys Team plan | Team | owner | Full admin over team, assigns roles, manages billing |
|
||||
| Invited to a Team | Team (inherited) | Assigned by team owner (engineer/viewer) | Access team-tier features, scoped by their assigned role |
|
||||
| Free user joins a Team | Team (overrides Free) | Assigned by team owner | Gains team-tier access, loses individual billing |
|
||||
|
||||
### Account vs. User
|
||||
|
||||
This is the biggest conceptual shift. Today, `User` is the top-level entity. In the new model:
|
||||
|
||||
- **Account** — The billing entity. Has a subscription, owns the Stripe relationship. Every user belongs to exactly one account.
|
||||
- **User** — A person. Authenticates, creates content, runs sessions. Has a role within their account.
|
||||
|
||||
A Free or Pro user has their own 1-person account. A Team account has multiple users. When a free user joins a team, their individual account is deactivated and they move under the team's account.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### New Tables
|
||||
|
||||
#### `accounts`
|
||||
|
||||
The billing entity that replaces the direct user-to-subscription relationship.
|
||||
|
||||
```
|
||||
accounts
|
||||
├── id: UUID (PK)
|
||||
├── name: String(255) -- User-chosen, not unique (UUID is the real identifier)
|
||||
├── display_code: String(8) (unique) -- Auto-generated short code (e.g., "A7K2") for admin/internal disambiguation
|
||||
├── owner_id: UUID (FK → users.id) -- The person who created/pays for this account
|
||||
├── stripe_customer_id: String(255) -- Stripe customer ID
|
||||
├── created_at: DateTime
|
||||
└── updated_at: DateTime
|
||||
```
|
||||
|
||||
#### `subscriptions`
|
||||
|
||||
Tracks the active Stripe subscription for an account.
|
||||
|
||||
```
|
||||
subscriptions
|
||||
├── id: UUID (PK)
|
||||
├── account_id: UUID (FK → accounts.id, unique) -- One active sub per account
|
||||
├── stripe_subscription_id: String(255) -- Stripe subscription ID
|
||||
├── stripe_price_id: String(255) -- Which Stripe price (plan + interval)
|
||||
├── plan: String(50) -- 'free', 'pro', 'team'
|
||||
├── billing_interval: String(20) -- 'monthly', 'annual', or null (free)
|
||||
├── status: String(50) -- 'active', 'past_due', 'canceled', 'trialing'
|
||||
├── seat_limit: Integer -- Max users allowed (null = unlimited)
|
||||
├── current_period_start: DateTime
|
||||
├── current_period_end: DateTime
|
||||
├── cancel_at_period_end: Boolean
|
||||
├── created_at: DateTime
|
||||
└── updated_at: DateTime
|
||||
```
|
||||
|
||||
#### `plan_limits`
|
||||
|
||||
Configurable feature limits per plan — avoids hardcoding limits in application code.
|
||||
|
||||
```
|
||||
plan_limits
|
||||
├── id: UUID (PK)
|
||||
├── plan: String(50) (unique) -- 'free', 'pro', 'team'
|
||||
├── max_trees: Integer -- null = unlimited
|
||||
├── max_sessions_per_month: Integer -- null = unlimited
|
||||
├── max_users: Integer -- null = unlimited (overridden by subscription.seat_limit for tiered brackets)
|
||||
├── custom_branding: Boolean
|
||||
├── priority_support: Boolean
|
||||
├── export_formats: JSONB -- e.g., ["txt", "md"] for free, ["txt", "md", "html", "pdf", "docx"] for paid
|
||||
└── updated_at: DateTime
|
||||
```
|
||||
|
||||
> This table is admin-seeded and rarely changes. It acts as a configuration table so you can adjust limits without deploying code.
|
||||
|
||||
#### `account_invites`
|
||||
|
||||
Replaces the current `invite_codes` table for team invitations (the existing invite code system for gating registration remains separate).
|
||||
|
||||
```
|
||||
account_invites
|
||||
├── id: UUID (PK)
|
||||
├── account_id: UUID (FK → accounts.id)
|
||||
├── invited_by_id: UUID (FK → users.id)
|
||||
├── email: String(255) -- Pre-targeted invite (optional)
|
||||
├── code: String(16) (unique) -- Shareable join code
|
||||
├── role: String(50) -- Role they'll get when they join: 'engineer' or 'viewer'
|
||||
├── accepted_by_id: UUID (FK → users.id, nullable)
|
||||
├── expires_at: DateTime
|
||||
├── created_at: DateTime
|
||||
└── accepted_at: DateTime
|
||||
```
|
||||
|
||||
### Modified Tables
|
||||
|
||||
#### `users` — Changes
|
||||
|
||||
```diff
|
||||
users
|
||||
├── id
|
||||
├── email
|
||||
├── password_hash
|
||||
├── name
|
||||
- ├── role: String(50) -- REMOVE standalone role
|
||||
+ ├── account_id: UUID (FK → accounts.id) -- Every user belongs to an account
|
||||
+ ├── account_role: String(50) -- 'owner', 'engineer', 'viewer'
|
||||
├── is_super_admin: Boolean -- KEEP (system-level, not account-level)
|
||||
- ├── is_team_admin: Boolean -- REMOVE (replaced by account_role = 'owner')
|
||||
- ├── team_id: UUID -- REMOVE (replaced by account_id)
|
||||
- ├── invite_code_id: UUID -- KEEP for registration gating
|
||||
├── is_active: Boolean -- ADD (from security audit Phase B)
|
||||
├── created_at
|
||||
└── last_login
|
||||
```
|
||||
|
||||
**Key changes:**
|
||||
- `role` → `account_role` (renamed for clarity, values: `owner`, `engineer`, `viewer`)
|
||||
- `team_id` → `account_id` (accounts replace teams as the grouping entity)
|
||||
- `is_team_admin` → removed (account_role `owner` replaces this)
|
||||
- The `owner` role is new — it's the person who created the account and manages billing
|
||||
|
||||
#### `teams` → Absorbed into `accounts`
|
||||
|
||||
The existing `teams` table is conceptually merged into `accounts`. A Team-plan account *is* a team. The `teams` table can either be:
|
||||
- **Option A:** Dropped entirely, with `team_id` references on trees/categories/tags migrated to `account_id`
|
||||
- **Option B:** Kept as a sub-grouping within large accounts (e.g., an MSP with multiple departments)
|
||||
|
||||
**Recommendation:** Option A for now. Sub-teams add complexity you don't need yet, and you can always add a `teams` table under accounts later.
|
||||
|
||||
### Relationship Diagram
|
||||
|
||||
```
|
||||
Account (1) ──── (1) Subscription
|
||||
│ │
|
||||
│ └── references plan_limits
|
||||
│
|
||||
├──── (many) Users
|
||||
│ └── account_role: owner/engineer/viewer
|
||||
│
|
||||
├──── (many) Trees
|
||||
├──── (many) Categories
|
||||
├──── (many) Tags
|
||||
└──── (many) Account Invites
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Registration Flow Redesign
|
||||
|
||||
### Current Flow
|
||||
```
|
||||
User visits /register → enters name/email/password + invite code → gets role "engineer" → done
|
||||
```
|
||||
|
||||
### New Flow
|
||||
|
||||
```
|
||||
User visits /register
|
||||
├── Step 1: Name, email, password
|
||||
├── Step 2: Choose path
|
||||
│ ├── "Start free" → Creates Account (free plan), account_role = owner
|
||||
│ ├── "Start Pro plan" → Creates Account, redirects to Stripe Checkout
|
||||
│ ├── "Start Team plan" → Creates team Account, names team, redirects to Stripe Checkout
|
||||
│ └── "Join existing team" → Enter invite code → joins that Account with assigned role
|
||||
└── Step 3: Email verification (from security audit backlog)
|
||||
```
|
||||
|
||||
**For invite-based joins:**
|
||||
- If the user already has a free/pro account, their individual account is deactivated (not deleted — preserves history)
|
||||
- They're moved under the team's account with whatever role the invite specifies
|
||||
- Their existing trees/sessions stay linked to them but become visible under the team account
|
||||
|
||||
**For team plan purchase:**
|
||||
- The buyer becomes `account_role = 'owner'`
|
||||
- They can generate invite codes/links for others
|
||||
- Invited users either create new accounts or migrate existing ones into the team
|
||||
|
||||
### Stripe Checkout Integration
|
||||
|
||||
Registration for paid plans follows this sequence:
|
||||
|
||||
```
|
||||
1. User completes registration form (account + user created in DB with plan='free' temporarily)
|
||||
2. Frontend redirects to Stripe Checkout (passing account_id in metadata)
|
||||
3. Stripe processes payment
|
||||
4. Stripe sends webhook → backend updates subscription record
|
||||
5. User redirected back to app with active paid plan
|
||||
```
|
||||
|
||||
This avoids creating Stripe customers before you have a confirmed user, and handles payment failures gracefully (user still has a free account).
|
||||
|
||||
---
|
||||
|
||||
## Stripe Integration Architecture
|
||||
|
||||
### Stripe Objects Mapping
|
||||
|
||||
| ResolutionFlow | Stripe |
|
||||
|---|---|
|
||||
| Account | Customer |
|
||||
| Subscription | Subscription |
|
||||
| Plan + Interval | Price (linked to a Product) |
|
||||
|
||||
### Stripe Products & Prices to Create
|
||||
|
||||
```
|
||||
Product: "ResolutionFlow Pro"
|
||||
├── Price: $X/month (price_pro_monthly)
|
||||
└── Price: $Y/year (price_pro_annual)
|
||||
|
||||
Product: "ResolutionFlow Team"
|
||||
├── Price: $X/month per bracket (price_team_5_monthly, price_team_15_monthly, etc.)
|
||||
└── Price: $Y/year per bracket (price_team_5_annual, price_team_15_annual, etc.)
|
||||
```
|
||||
|
||||
### Webhook Events to Handle
|
||||
|
||||
| Event | Action |
|
||||
|---|---|
|
||||
| `checkout.session.completed` | Create/update subscription record, upgrade plan |
|
||||
| `invoice.paid` | Update `current_period_start/end`, confirm active status |
|
||||
| `invoice.payment_failed` | Set status to `past_due`, notify account owner |
|
||||
| `customer.subscription.updated` | Sync plan changes (upgrades, downgrades, seat changes) |
|
||||
| `customer.subscription.deleted` | Set status to `canceled`, downgrade to free tier limits |
|
||||
|
||||
### Webhook Security
|
||||
|
||||
- Verify Stripe signature on every webhook (`stripe.Webhook.construct_event`)
|
||||
- Use a dedicated `/api/v1/webhooks/stripe` endpoint (no auth required, signature-verified)
|
||||
- Store webhook events in an `events` table for debugging/replay
|
||||
- Make webhook handlers idempotent (safe to process the same event twice)
|
||||
|
||||
---
|
||||
|
||||
## Feature Gating System
|
||||
|
||||
### How It Works
|
||||
|
||||
Instead of checking `user.role` to decide what someone can do, the app checks **two things**:
|
||||
|
||||
1. **Feature access** — Does this user's subscription plan include this feature? (checked against `plan_limits`)
|
||||
2. **Permission** — Does this user's `account_role` allow this action? (owner > engineer > viewer)
|
||||
|
||||
### Backend: Middleware / Dependencies
|
||||
|
||||
```python
|
||||
# New dependency: get the user's plan limits
|
||||
async def get_plan_limits(current_user: User, db: Session) -> PlanLimits:
|
||||
subscription = await get_account_subscription(current_user.account_id, db)
|
||||
return await get_limits_for_plan(subscription.plan, db)
|
||||
|
||||
# New dependency: check a specific feature
|
||||
async def require_feature(feature: str):
|
||||
"""Factory for feature-gated dependencies."""
|
||||
async def checker(limits: PlanLimits = Depends(get_plan_limits)):
|
||||
if not getattr(limits, feature, False):
|
||||
raise HTTPException(402, f"This feature requires a plan upgrade")
|
||||
return checker
|
||||
|
||||
# Usage in endpoints:
|
||||
@router.post("/trees")
|
||||
async def create_tree(
|
||||
current_user: User = Depends(require_engineer_or_above), # permission check
|
||||
limits: PlanLimits = Depends(get_plan_limits), # feature check
|
||||
):
|
||||
tree_count = await count_user_trees(current_user.account_id, db)
|
||||
if limits.max_trees and tree_count >= limits.max_trees:
|
||||
raise HTTPException(402, "Tree limit reached. Upgrade your plan for more.")
|
||||
...
|
||||
```
|
||||
|
||||
### Frontend: Subscription Context
|
||||
|
||||
```typescript
|
||||
// New context providing plan info to all components
|
||||
const SubscriptionContext = React.createContext<{
|
||||
plan: 'free' | 'pro' | 'team'
|
||||
limits: PlanLimits
|
||||
usage: CurrentUsage // e.g., { trees: 2, sessionsThisMonth: 15 }
|
||||
canUseFeature: (feature: string) => boolean
|
||||
isAtLimit: (resource: string) => boolean
|
||||
}>()
|
||||
|
||||
// Usage in components:
|
||||
const { isAtLimit, plan } = useSubscription()
|
||||
|
||||
// Disable "Create Tree" if at limit
|
||||
<Button disabled={isAtLimit('trees')}>
|
||||
{isAtLimit('trees') ? 'Upgrade to create more trees' : 'Create Tree'}
|
||||
</Button>
|
||||
```
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 403 | Permission denied (wrong role) |
|
||||
| 402 | Payment required (feature/limit needs upgrade) |
|
||||
|
||||
Using `402` for subscription-related blocks distinguishes them from role-based `403` errors, so the frontend can show an "upgrade" prompt instead of a "you don't have permission" message.
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Database Migration
|
||||
|
||||
This is the most delicate part. The migration needs to:
|
||||
|
||||
1. Create `accounts` table
|
||||
2. Create `subscriptions` table
|
||||
3. Create `plan_limits` table and seed with initial values
|
||||
4. Create `account_invites` table
|
||||
5. For every existing user:
|
||||
- Create an `Account` with plan `free`
|
||||
- Set `user.account_id` to that account
|
||||
- Map `user.role` → `user.account_role` (engineer → engineer, viewer → viewer)
|
||||
- If `is_team_admin` was true → set `account_role = 'owner'`
|
||||
6. For every existing team:
|
||||
- Create an `Account` from the team (name, created_at)
|
||||
- Move all team members' `account_id` to the new account
|
||||
- Set the team admin as `account_role = 'owner'`
|
||||
7. Migrate `team_id` references on trees, categories, tags, step_categories to `account_id`
|
||||
8. Drop `team_id`, `is_team_admin`, `role` columns from users
|
||||
9. Drop `teams` table
|
||||
|
||||
> **Important:** This migration should be tested extensively on a copy of the production database before running it for real.
|
||||
|
||||
### Phase 2: Backend Changes
|
||||
|
||||
- Add Stripe configuration to `config.py` (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY`)
|
||||
- Create Stripe webhook endpoint
|
||||
- Update all permission checks from `role`/`team_id` to `account_role`/`account_id`
|
||||
- Add feature-gating dependencies
|
||||
- Create account management endpoints (invite, remove user, change roles)
|
||||
- Create subscription management endpoints (current plan, upgrade, cancel)
|
||||
|
||||
### Phase 3: Frontend Changes
|
||||
|
||||
- New registration flow with plan selection
|
||||
- Stripe Checkout redirect integration
|
||||
- Subscription context provider
|
||||
- Account settings page (manage members, view plan, billing portal link)
|
||||
- Upgrade prompts on feature-gated actions
|
||||
- Usage indicators (e.g., "3 of 5 trees used")
|
||||
|
||||
### Phase 4: Stripe Dashboard Setup
|
||||
|
||||
- Create Products and Prices in Stripe
|
||||
- Configure webhook endpoint URL
|
||||
- Set up Stripe Customer Portal (for self-service billing management)
|
||||
- Test the full flow in Stripe Test Mode
|
||||
|
||||
---
|
||||
|
||||
## Impact on Existing Features
|
||||
|
||||
### What Changes
|
||||
|
||||
| Feature | Before | After |
|
||||
|---|---|---|
|
||||
| Registration | Email + password + invite code | Email + password + plan selection (or invite code to join team) |
|
||||
| "Who can see my trees?" | Based on `team_id` match | Based on `account_id` match |
|
||||
| "Who can edit my tree?" | Author or team admin | Author or account owner |
|
||||
| "Am I an admin?" | `is_team_admin` flag | `account_role == 'owner'` |
|
||||
| Tree/session limits | None | Enforced per plan via `plan_limits` |
|
||||
| Invite codes | Global registration gate | Two systems: registration gate (existing) + team invites (new) |
|
||||
|
||||
### What Stays the Same
|
||||
|
||||
- `is_super_admin` — Still a system-level flag for ResolutionFlow operators (you)
|
||||
- Session ownership — Still scoped to the individual user
|
||||
- Tree authorship — Still tracked per user
|
||||
- The security audit fixes (Phases A-D) — All still apply, just with `account_id` instead of `team_id`
|
||||
|
||||
---
|
||||
|
||||
## Existing Invite Code System
|
||||
|
||||
The current `invite_codes` table serves as a **registration gate** — you must have a valid code to sign up at all. This is separate from team invites.
|
||||
|
||||
**Recommendation:** Keep both systems:
|
||||
- `invite_codes` — Controls who can register for ResolutionFlow at all (beta access, controlled rollout)
|
||||
- `account_invites` — Controls who can join a specific team account
|
||||
|
||||
Once ResolutionFlow is publicly available, you can disable the registration gate (`REQUIRE_INVITE_CODE=false`) while team invites remain active.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Free tier limits** — What specific numbers feel right? (trees, sessions/month)
|
||||
2. **Pricing** — What price points for Pro and Team?
|
||||
3. **Team seat brackets** — What brackets? (e.g., 1-5 at $X, 6-15 at $Y, 16-50 at $Z, 50+ custom)
|
||||
4. **Free trial** — Should paid plans have a trial period? If so, how long?
|
||||
5. **Downgrade behavior** — When a paid user cancels, do they keep their content but lose access to features? Or does content get archived?
|
||||
6. **Existing user migration** — When this ships, should all current users become Pro or stay Free?
|
||||
7. **Account owner transfer** — Can an owner transfer ownership to another user? (Important for when someone leaves a company)
|
||||
8. **Multiple accounts** — Can one email belong to multiple accounts? (e.g., pro account + work team) Or is it strictly one account per user?
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Stripe Checkout: https://docs.stripe.com/payments/checkout
|
||||
- Stripe Customer Portal: https://docs.stripe.com/customer-management/portal-deep-dive
|
||||
- Stripe Webhooks: https://docs.stripe.com/webhooks
|
||||
- Current models: `backend/app/models/`
|
||||
- Permissions audit: `docs/plans/2026-02-05-permissions-audit-design.md`
|
||||
Reference in New Issue
Block a user