Files
resolutionflow/docs/archive/subscription-tier-architecture.md
chihlasm 350c977eda feat: add procedural flows with intake forms, navigation, and seed templates
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>
2026-02-14 04:13:52 -05:00

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:

  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

  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:

  • roleaccount_role (renamed for clarity, values: owner, engineer, viewer)
  • team_idaccount_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

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

  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.roleuser.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