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