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>
18 KiB
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:
- Subscription tier → What features and limits you have access to
- 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
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_roleownerreplaces this)- The
ownerrole 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_idreferences on trees/categories/tags migrated toaccount_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/stripeendpoint (no auth required, signature-verified) - Store webhook events in an
eventstable 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:
- Feature access — Does this user's subscription plan include this feature? (checked against
plan_limits) - Permission — Does this user's
account_roleallow this action? (owner > engineer > viewer)
Backend: Middleware / Dependencies
# 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
// 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:
- Create
accountstable - Create
subscriptionstable - Create
plan_limitstable and seed with initial values - Create
account_invitestable - For every existing user:
- Create an
Accountwith planfree - Set
user.account_idto that account - Map
user.role→user.account_role(engineer → engineer, viewer → viewer) - If
is_team_adminwas true → setaccount_role = 'owner'
- Create an
- For every existing team:
- Create an
Accountfrom the team (name, created_at) - Move all team members'
account_idto the new account - Set the team admin as
account_role = 'owner'
- Create an
- Migrate
team_idreferences on trees, categories, tags, step_categories toaccount_id - Drop
team_id,is_team_admin,rolecolumns from users - Drop
teamstable
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_idtoaccount_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_idinstead ofteam_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
- Free tier limits — What specific numbers feel right? (trees, sessions/month)
- Pricing — What price points for Pro and Team?
- Team seat brackets — What brackets? (e.g., 1-5 at $X, 6-15 at $Y, 16-50 at $Z, 50+ custom)
- Free trial — Should paid plans have a trial period? If so, how long?
- Downgrade behavior — When a paid user cancels, do they keep their content but lose access to features? Or does content get archived?
- Existing user migration — When this ships, should all current users become Pro or stay Free?
- Account owner transfer — Can an owner transfer ownership to another user? (Important for when someone leaves a company)
- 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