Files
motovaultpro/docs/TIER-GATING.md
Eric Gullickson f494f77150
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 4m35s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 27s
Deploy to Staging / Verify Staging (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
feat: Implement user tier-based feature gating system (refs #8)
Add subscription tier system to gate features behind Free/Pro/Enterprise tiers.

Backend:
- Create feature-tiers.ts with FEATURE_TIERS config and utilities
- Add /api/config/feature-tiers endpoint for frontend config fetch
- Create requireTier middleware for route-level tier enforcement
- Add subscriptionTier to request.userContext in auth plugin
- Gate scanForMaintenance in documents controller (Pro+ required)
- Add migration to reset scanForMaintenance for free users

Frontend:
- Create useTierAccess hook for tier checking
- Create UpgradeRequiredDialog component (responsive)
- Gate DocumentForm checkbox with lock icon for free users
- Add SubscriptionTier type to profile.types.ts

Documentation:
- Add TIER-GATING.md with usage guide

Tests: 30 passing (feature-tiers, tier-guard, controller)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:34:47 -06:00

9.5 KiB

Tier-Based Feature Gating

This document describes the user subscription tier system and how to gate features behind specific tiers.

Overview

MotoVaultPro supports three subscription tiers with hierarchical access:

Tier Level Description
free 0 Default tier, limited features
pro 1 Mid-tier, most features
enterprise 2 Full access to all features

Higher tiers automatically have access to all lower-tier features (tier hierarchy).

Architecture

┌─────────────────────────────────────────────────────────────┐
│                        Frontend                              │
│  ┌─────────────────┐    ┌──────────────────────────────┐   │
│  │ useTierAccess   │───▶│ UpgradeRequiredDialog        │   │
│  │ hook            │    │ component                     │   │
│  └────────┬────────┘    └──────────────────────────────┘   │
│           │                                                  │
│           ▼ fetches                                          │
├─────────────────────────────────────────────────────────────┤
│                        Backend API                           │
│  ┌─────────────────┐    ┌──────────────────────────────┐   │
│  │ /api/config/    │    │ /api/documents               │   │
│  │ feature-tiers   │    │ (tier validation)            │   │
│  └────────┬────────┘    └────────┬─────────────────────┘   │
│           │                      │                          │
│           ▼                      ▼                          │
│  ┌─────────────────────────────────────────────────────┐   │
│  │ feature-tiers.ts (single source of truth)           │   │
│  │ - FEATURE_TIERS config                              │   │
│  │ - canAccessFeature(), getTierLevel(), etc.          │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Backend Implementation

Feature Configuration

All gated features are defined in backend/src/core/config/feature-tiers.ts:

export const FEATURE_TIERS = {
  'document.scanMaintenanceSchedule': {
    minTier: 'pro',
    name: 'Scan for Maintenance Schedule',
    upgradePrompt: 'Upgrade to Pro to automatically extract maintenance schedules from your manuals.'
  },
  // Add new features here
} as const;

Utility Functions

import {
  canAccessFeature,
  getTierLevel,
  getRequiredTier,
  getFeatureConfig
} from '../core/config/feature-tiers';

// Check if user can access a feature
canAccessFeature('pro', 'document.scanMaintenanceSchedule'); // true
canAccessFeature('free', 'document.scanMaintenanceSchedule'); // false

// Get numeric tier level for comparison
getTierLevel('pro'); // 1

// Get minimum required tier for a feature
getRequiredTier('document.scanMaintenanceSchedule'); // 'pro'

Route-Level Protection (Middleware)

Use requireTier for routes that require a minimum tier:

// In routes file
fastify.post('/premium-endpoint', {
  preHandler: [fastify.requireTier({ minTier: 'pro' })],
  handler: controller.premiumAction
});

// Or by feature key
fastify.post('/scan-maintenance', {
  preHandler: [fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })],
  handler: controller.scanMaintenance
});

Controller-Level Validation

For conditional feature checks within a controller:

async create(request: FastifyRequest, reply: FastifyReply) {
  const userTier = request.userContext?.subscriptionTier || 'free';

  if (request.body.scanForMaintenance && !canAccessFeature(userTier, 'document.scanMaintenanceSchedule')) {
    const config = getFeatureConfig('document.scanMaintenanceSchedule');
    return reply.code(403).send({
      error: 'TIER_REQUIRED',
      requiredTier: config?.minTier || 'pro',
      currentTier: userTier,
      feature: 'document.scanMaintenanceSchedule',
      featureName: config?.name || null,
      upgradePrompt: config?.upgradePrompt || 'Upgrade to access this feature.',
    });
  }
  // ... continue with operation
}

403 Error Response Format

{
  "error": "TIER_REQUIRED",
  "requiredTier": "pro",
  "currentTier": "free",
  "feature": "document.scanMaintenanceSchedule",
  "featureName": "Scan for Maintenance Schedule",
  "upgradePrompt": "Upgrade to Pro to automatically extract maintenance schedules from your manuals."
}

Frontend Implementation

useTierAccess Hook

import { useTierAccess } from '@/core/hooks';

const MyComponent = () => {
  const { tier, loading, hasAccess, checkAccess } = useTierAccess();

  // Simple boolean check
  if (!hasAccess('document.scanMaintenanceSchedule')) {
    // Show upgrade prompt or disable feature
  }

  // Detailed access info
  const access = checkAccess('document.scanMaintenanceSchedule');
  // {
  //   allowed: false,
  //   requiredTier: 'pro',
  //   config: { name: '...', upgradePrompt: '...' }
  // }
};

UpgradeRequiredDialog Component

import { UpgradeRequiredDialog } from '@/shared-minimal/components';

const MyComponent = () => {
  const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
  const { hasAccess } = useTierAccess();

  return (
    <>
      <button
        disabled={!hasAccess('document.scanMaintenanceSchedule')}
        onClick={() => {
          if (!hasAccess('document.scanMaintenanceSchedule')) {
            setUpgradeDialogOpen(true);
          } else {
            // Perform action
          }
        }}
      >
        Premium Feature
      </button>

      <UpgradeRequiredDialog
        featureKey="document.scanMaintenanceSchedule"
        open={upgradeDialogOpen}
        onClose={() => setUpgradeDialogOpen(false)}
      />
    </>
  );
};

Gating a Checkbox (Example)

const { hasAccess } = useTierAccess();
const canScanMaintenance = hasAccess('document.scanMaintenanceSchedule');

<label className={canScanMaintenance ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}>
  <input
    type="checkbox"
    checked={canScanMaintenance ? scanForMaintenance : false}
    onChange={(e) => canScanMaintenance && setScanForMaintenance(e.target.checked)}
    disabled={!canScanMaintenance}
  />
  <span>Scan for Maintenance Schedule</span>
</label>
{!canScanMaintenance && (
  <button onClick={() => setUpgradeDialogOpen(true)}>
    <LockIcon />
  </button>
)}

Adding a New Gated Feature

Step 1: Add to Feature Config (Backend)

// backend/src/core/config/feature-tiers.ts
export const FEATURE_TIERS = {
  // ... existing features
  'reports.advancedAnalytics': {
    minTier: 'enterprise',
    name: 'Advanced Analytics',
    upgradePrompt: 'Upgrade to Enterprise for advanced fleet analytics and reporting.'
  },
} as const;

Step 2: Add Backend Validation

Either use middleware on the route:

fastify.get('/reports/analytics', {
  preHandler: [fastify.requireTier({ featureKey: 'reports.advancedAnalytics' })],
  handler: controller.getAnalytics
});

Or validate in the controller for conditional features.

Step 3: Add Frontend Check

const { hasAccess } = useTierAccess();

if (!hasAccess('reports.advancedAnalytics')) {
  return <UpgradePrompt featureKey="reports.advancedAnalytics" />;
}

API Endpoint

The frontend fetches tier configuration from:

GET /api/config/feature-tiers

Response:
{
  "tiers": { "free": 0, "pro": 1, "enterprise": 2 },
  "features": {
    "document.scanMaintenanceSchedule": {
      "minTier": "pro",
      "name": "Scan for Maintenance Schedule",
      "upgradePrompt": "..."
    }
  }
}

User Tier Management

Admin UI

Admins can change user tiers via the Admin Users page dropdown.

Database

User tiers are stored in user_profiles.subscription_tier column (enum: free, pro, enterprise).

Auth Context

The user's tier is included in request.userContext.subscriptionTier after authentication.

Testing

Backend Tests

# Run tier-related tests
npm test -- --testPathPattern="feature-tiers|tier-guard|documents.controller.tier"

Test Cases to Cover

  1. Free user attempting to use Pro feature returns 403
  2. Pro user can use Pro features
  3. Enterprise user can use all features
  4. Unknown features are allowed (fail open)
  5. Missing userContext defaults to free tier

Future Considerations

  • Stripe billing integration for tier upgrades
  • Subscription expiration handling
  • Grace periods for downgraded users
  • Feature usage analytics