Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
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
- Define three tiers (Basic, Pro, Premium) with a clear feature-to-tier mapping
- Gate features at navigation, page, and server-action levels
- Backfill all existing tenants to Pro (grandfathered)
- Map Stripe products to tiers so new tenants get the correct tier automatically
- Show upsell placeholders on gated features directing users to upgrade
- Deploy in phases with zero disruption to production
- 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
- Existing MSP customer — Currently using all features. After deployment, sees no change (grandfathered to Pro).
- New EE customer via Stripe — Signs up through NM-Store checkout. Tier is resolved from Stripe product (
alga-psa-preview→ Pro). - CE self-registration user — Registers without Stripe. Gets Pro (no gating in CE).
- Basic-tier tenant (future) — Sees core features only. Billing, Projects, Technician Dispatch are hidden. Upsell placeholders guide to upgrade.
Primary Flows
- Login → Session loads tier from JWT → TierProvider makes it available client-side
- Navigate sidebar → Items filtered by tier → Gated items hidden
- Direct URL to gated page → UpsellPlaceholder shown instead of content
- Server action on gated feature → TierAccessError thrown
- Stripe product change → Webhook updates
tenants.plan→ Session refreshes within 5 min (or instant viarefreshTier())
UX / UI Notes
- Navigation: Gated items are hidden from sidebar (not grayed out)
- Page-level: Full-page
UpsellPlaceholderwith icon, heading ("{Feature} requires {Tier Label}"), description, and CTA button linking to/msp/account - Misconfigured state: Warning banner ("Subscription not configured — contact support") when
planis 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,premiumas const tuple TenantTiertype derived from the tupleresolveTier(plan)returns{ tier, isMisconfigured }— NULL → basic + misconfigured flagisValidTier()type guardTIER_LABELSfor display names
FR2: Tier-to-Feature Mapping
TIER_FEATURESenum:BILLING,PROJECTS,TECHNICIAN_DISPATCH,EXTENSIONS(extensible)TIER_FEATURE_MAP: basic = [], pro = [BILLING, PROJECTS, TECHNICIAN_DISPATCH], premium = [...pro, EXTENSIONS]tierHasFeature(tier, feature)→ booleanFEATURE_MINIMUM_TIERreverse mapping
FR3: ITenant Interface Update
- Narrow
plan?: stringtoplan?: TenantTierin both interface locations
FR4: Registration Flow
- Both
Tenant.insertcalls inuseRegister.tsxsetplan: '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: changeplan: 'test'→plan: 'pro'
FR7: Session Integration
planfield 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
TierProviderwraps app insideAppSessionProvideruseTier()hook: tier, isMisconfigured, isBasic, isPro, isPremium, hasFeature(), refreshTier()
FR9: Stripe Product → Tier Mapping
STRIPE_PRODUCT_TIER_MAPconfig:alga-psa-preview→ pro, future products pre-mappedtierFromStripeProduct()function, unknown products default to pro
FR10: Tenant Creation Workflow Integration
createTenantInDB()resolves tier from Stripe price → setstenantData.plan- Covers: Stripe checkout, Nine Minds extension, Provisioning API (all same Temporal workflow)
FR11: Checkout Webhook Integration
handleCheckoutCompleted()andhandleSubscriptionUpdated()resolve product → tier → updatetenants.plan
FR12: Upsell Placeholder Component
- Full-page placeholder: icon, heading, description, CTA to
/msp/account
FR13: TierGate Components
- Client-side
TierGatewrapper (uses TierContext) - Server-side
ServerTierGate(reads session directly)
FR14: Navigation Gating
requiredFeaturefield onMenuIteminterface- 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)throwsTierAccessErrorif 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.plancolumn (varchar, already exists, nullable — NULL = error state) - Stripe: Product name → tier mapping via
STRIPE_PRODUCT_TIER_MAP - NextAuth: JWT carries
planandlast_plan_checkfields - 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)
- All existing production tenants have
plan = 'pro' - New tenants via Stripe checkout get tier resolved from product
- JWT contains
planfield, refreshed every 5 minutes - Basic-tier tenant sees gated features hidden from sidebar
- Direct navigation to gated page shows upsell placeholder
- Server actions on gated features throw TierAccessError
- Account page shows actual tier (not hardcoded 'Professional')
- CE self-registration creates tenants with
plan = 'pro' - NULL plan shows warning banner + basic-level access