Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

8.6 KiB

PRD — Tenant Tier System (Basic, Pro, Premium)

  • Slug: tenant-tiers
  • Date: 2026-03-03
  • Status: Draft

Summary

Add tenant-wide tiers (Basic, Pro, Premium) alongside the existing license count model. Tiers control feature access — Basic restricts billing, projects, technician dispatch (and more later), showing an upsell placeholder. Existing customers are grandfathered into Pro. The tier source evolves over three horizons: Stripe product mapping → new Stripe products per tier → internal contracts system.

Design principle: The plan column on the tenants table is the single source of truth for a tenant's tier. The gating infrastructure only reads this column. What writes it changes over time, but the read side stays stable.

Problem

Currently all tenants have equal access to all features regardless of their subscription level. There is no mechanism to:

  • Differentiate feature access between free/trial and paying customers
  • Offer a tiered product (Basic → Pro → Premium) with progressive feature unlock
  • Show upgrade prompts when users encounter gated features
  • Map Stripe products to feature tiers automatically

Goals

  1. Define three tiers (Basic, Pro, Premium) with a clear feature-to-tier mapping
  2. Gate features at navigation, page, and server-action levels
  3. Backfill all existing tenants to Pro (grandfathered)
  4. Map Stripe products to tiers so new tenants get the correct tier automatically
  5. Show upsell placeholders on gated features directing users to upgrade
  6. Deploy in phases with zero disruption to production
  7. Design for future horizons: new Stripe products per tier → internal contracts system

Non-goals

  • Tier upgrade/downgrade self-service flow (Horizon 2)
  • Internal contracts managing tiers (Horizon 3)
  • Pricing page or public tier comparison
  • Per-feature billing or usage-based gating
  • Admin UI for managing tier-to-feature mappings
  • Monitoring, metrics, or analytics for tier usage

Users and Primary Flows

Personas

  1. Existing MSP customer — Currently using all features. After deployment, sees no change (grandfathered to Pro).
  2. New EE customer via Stripe — Signs up through NM-Store checkout. Tier is resolved from Stripe product (alga-psa-preview → Pro).
  3. CE self-registration user — Registers without Stripe. Gets Pro (no gating in CE).
  4. Basic-tier tenant (future) — Sees core features only. Billing, Projects, Technician Dispatch are hidden. Upsell placeholders guide to upgrade.

Primary Flows

  1. Login → Session loads tier from JWT → TierProvider makes it available client-side
  2. Navigate sidebar → Items filtered by tier → Gated items hidden
  3. Direct URL to gated page → UpsellPlaceholder shown instead of content
  4. Server action on gated feature → TierAccessError thrown
  5. Stripe product change → Webhook updates tenants.plan → Session refreshes within 5 min (or instant via refreshTier())

UX / UI Notes

  • Navigation: Gated items are hidden from sidebar (not grayed out)
  • Page-level: Full-page UpsellPlaceholder with icon, heading ("{Feature} requires {Tier Label}"), description, and CTA button linking to /msp/account
  • Misconfigured state: Warning banner ("Subscription not configured — contact support") when plan is NULL/invalid
  • Account page: Shows current tier badge, tier comparison card, upgrade action

Requirements

Functional Requirements

FR1: Tier Constants & Type System

  • Three tiers: basic, pro, premium as const tuple
  • TenantTier type derived from the tuple
  • resolveTier(plan) returns { tier, isMisconfigured } — NULL → basic + misconfigured flag
  • isValidTier() type guard
  • TIER_LABELS for display names

FR2: Tier-to-Feature Mapping

  • TIER_FEATURES enum: BILLING, PROJECTS, TECHNICIAN_DISPATCH, EXTENSIONS (extensible)
  • TIER_FEATURE_MAP: basic = [], pro = [BILLING, PROJECTS, TECHNICIAN_DISPATCH], premium = [...pro, EXTENSIONS]
  • tierHasFeature(tier, feature) → boolean
  • FEATURE_MINIMUM_TIER reverse mapping

FR3: ITenant Interface Update

  • Narrow plan?: string to plan?: TenantTier in both interface locations

FR4: Registration Flow

  • Both Tenant.insert calls in useRegister.tsx set plan: 'pro'
  • CE tenants always get Pro (never gated)

FR5: Migration Backfill

  • All existing tenants with NULL or empty plan → 'pro'
  • No column default — NULL is intentional error state

FR6: Test Data Fix

  • testDataFactory.ts: change plan: 'test'plan: 'pro'

FR7: Session Integration

  • plan field added to JWT, Session.user, User, ExtendedUser interfaces
  • JWT callback fetches plan on initial sign-in
  • Throttled refresh every 5 minutes on subsequent requests
  • Session callback propagates plan to client

FR8: Client-Side Tier Context

  • TierProvider wraps app inside AppSessionProvider
  • useTier() hook: tier, isMisconfigured, isBasic, isPro, isPremium, hasFeature(), refreshTier()

FR9: Stripe Product → Tier Mapping

  • STRIPE_PRODUCT_TIER_MAP config: alga-psa-preview → pro, future products pre-mapped
  • tierFromStripeProduct() function, unknown products default to pro

FR10: Tenant Creation Workflow Integration

  • createTenantInDB() resolves tier from Stripe price → sets tenantData.plan
  • Covers: Stripe checkout, Nine Minds extension, Provisioning API (all same Temporal workflow)

FR11: Checkout Webhook Integration

  • handleCheckoutCompleted() and handleSubscriptionUpdated() resolve product → tier → update tenants.plan

FR12: Upsell Placeholder Component

  • Full-page placeholder: icon, heading, description, CTA to /msp/account

FR13: TierGate Components

  • Client-side TierGate wrapper (uses TierContext)
  • Server-side ServerTierGate (reads session directly)

FR14: Navigation Gating

  • requiredFeature field on MenuItem interface
  • Sidebar filters items by tier
  • Gated: Billing → BILLING, Projects → PROJECTS, Technician Dispatch → TECHNICIAN_DISPATCH

FR15: Page-Level Gating

  • Billing, Projects, Technician Dispatch pages wrapped with gate components

FR16: Server Action Gating

  • assertTierAccess(tenant, feature) throws TierAccessError if tier lacks feature
  • Applied to billing, project, technician dispatch server actions

FR17: Account Page Enhancement

  • Replace hardcoded plan_name: 'Professional' with actual tier
  • Add tier badge, tier comparison card, upgrade action

Non-functional Requirements

  • Zero disruption deployment — Phase A changes nothing visible, Phase B deployed while all tenants are Pro
  • Session plan refresh throttled to 5-minute intervals (avoids DB queries on every request)
  • Pure TypeScript tier config — no DB or PostHog dependency for feature mapping

Data / API / Integrations

  • Database: tenants.plan column (varchar, already exists, nullable — NULL = error state)
  • Stripe: Product name → tier mapping via STRIPE_PRODUCT_TIER_MAP
  • NextAuth: JWT carries plan and last_plan_check fields
  • Temporal: createTenantInDB() sets plan from Stripe product

Security / Permissions

  • Server-side assertTierAccess() prevents API-level bypass of client gating
  • Tier is read from DB in server actions, not trusted from client

Rollout / Migration

Phase A: Foundation (deploy first, no behavior change)

  • Migration backfills all tenants to Pro
  • Session carries tier info
  • TierProvider available but everyone is Pro — no visible change

Phase B: Gating Infrastructure (deploy second, still no behavior change)

  • Gate components, navigation filtering, server action guards deployed
  • Since all tenants are Pro, no one is gated

Phase C: EE Registration Gating (deploy when ready)

  • EE self-registration sets plan: 'basic' (CE stays 'pro')

Open Questions

None — all questions resolved during planning:

  • NULL plan handling: error state (basic access + warning banner)
  • CE mode: always Pro, never gated
  • Dev seeds: already set plan: 'pro'
  • Nine Minds extension: same Temporal workflow, covered by A9
  • Test factory: updated to 'pro'

Acceptance Criteria (Definition of Done)

  1. All existing production tenants have plan = 'pro'
  2. New tenants via Stripe checkout get tier resolved from product
  3. JWT contains plan field, refreshed every 5 minutes
  4. Basic-tier tenant sees gated features hidden from sidebar
  5. Direct navigation to gated page shows upsell placeholder
  6. Server actions on gated features throw TierAccessError
  7. Account page shows actual tier (not hardcoded 'Professional')
  8. CE self-registration creates tenants with plan = 'pro'
  9. NULL plan shows warning banner + basic-level access