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