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>
459 lines
18 KiB
Markdown
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`
|