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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
191 lines
8.6 KiB
Markdown
191 lines
8.6 KiB
Markdown
# 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
|