From 5d464c1287e36701e190d7188e63363d5536d209 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 6 Feb 2026 03:04:06 -0500 Subject: [PATCH] docs: add subscription tier architecture plan Co-Authored-By: Claude Opus 4.6 --- docs/plans/subscription-tier-architecture.md | 458 +++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 docs/plans/subscription-tier-architecture.md diff --git a/docs/plans/subscription-tier-architecture.md b/docs/plans/subscription-tier-architecture.md new file mode 100644 index 00000000..c99fbd64 --- /dev/null +++ b/docs/plans/subscription-tier-architecture.md @@ -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 + +``` + +### 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`