Files
resolutionflow/docs/archive/subscription-tier-architecture.md
chihlasm 350c977eda feat: add procedural flows with intake forms, navigation, and seed templates
Adds a new "procedural" tree type for linear step-by-step project workflows
(domain controller setup, M365 onboarding, VPN config, etc). Includes intake
form builder, two-panel step navigation, variable resolution, procedural
exports, 3 seed templates, and UI rename from "Trees" to "Flows".

Also archives 19 implemented plan docs and creates deferred features backlog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 04:13:52 -05:00

459 lines
18 KiB
Markdown

# 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`