Skip to main content

Overview

The EntitlementEngine class evaluates feature access for a single customer based on their active plan, purchased add-ons, and subscription status. It is stateless, has no side effects, and is safe to call from any context.

Class Signature

class EntitlementEngine {
  constructor(
    plan: PlanDef,
    addons?: AddonDef[],
    subscriptionStatus?: SubscriptionStatus
  )

  check(featureId: string, currentUsage?: number): CheckResult
  checkBatch(usages: Record<string, number>): Record<string, CheckResult>
}

Constructor

plan
PlanDef
required
The customer’s active base plan.
addons
AddonDef[]
default:"[]"
Active add-ons the customer has purchased.
subscriptionStatus
SubscriptionStatus
default:"'active'"
Current payment/lifecycle state of the subscription:
  • active: Subscription is current and paid
  • trialing: In trial period
  • past_due: Payment failed, blocks all access
  • canceled: Subscription canceled, blocks all access
  • paused: Subscription paused

Methods

check()

Verify if the customer has access to a specific feature.
check(featureId: string, currentUsage?: number): CheckResult
featureId
string
required
The feature slug to check (e.g., "seats", "api_calls").
currentUsage
number
default:"0"
Current consumption count for metered features.
result
CheckResult
The access decision and metadata.

checkBatch()

Evaluate multiple features in a single pass.
checkBatch(usages: Record<string, number>): Record<string, CheckResult>
usages
Record<string, number>
required
Map of featureSlug → currentUsage to evaluate.
results
Record<string, CheckResult>
Map of featureSlug → CheckResult.

Usage

Basic Check - Boolean Feature

import { EntitlementEngine } from "@revstack/core";

const engine = new EntitlementEngine(plan, addons, "active");

const result = engine.check("sso");

if (result.allowed) {
  // Customer has SSO access
  enableSSOLogin();
} else {
  // Show upgrade prompt
  showUpgradeModal();
}

Static Feature - Seats

import { EntitlementEngine } from "@revstack/core";

const engine = new EntitlementEngine(plan, addons);

const currentSeats = 8;
const result = engine.check("seats", currentSeats);

if (result.allowed) {
  console.log(`You can add ${result.remaining} more seats`);
  allowAddMember();
} else if (result.reason === "limit_reached") {
  showUpgradePrompt("You've reached your seat limit");
}

Metered Feature - API Calls

import { EntitlementEngine } from "@revstack/core";

const engine = new EntitlementEngine(plan, addons);

const currentUsage = await getMonthlyAPICallCount(customerId);
const result = engine.check("api_calls", currentUsage);

if (!result.allowed) {
  if (result.reason === "limit_reached") {
    return res.status(429).json({ 
      error: "API rate limit exceeded",
      message: "Upgrade your plan for more API calls",
    });
  } else if (result.reason === "past_due") {
    return res.status(403).json({ 
      error: "Subscription past due",
      message: "Please update your payment method",
    });
  }
}

// Process the API request
processAPIRequest();

Overage Handling

import { EntitlementEngine } from "@revstack/core";

const engine = new EntitlementEngine(plan, addons);

const usage = 105000; // Over the 100k limit
const result = engine.check("api_calls", usage);

if (result.allowed) {
  if (result.reason === "overage_allowed") {
    // Soft limit - allow but track overage
    const overage = usage - result.remaining;
    console.log(`Overage: ${overage} API calls`);
    // Bill customer based on price.overage_configuration
  } else {
    // Within included limit
    console.log(`Remaining: ${result.remaining} API calls`);
  }
}

Batch Check

import { EntitlementEngine } from "@revstack/core";

const engine = new EntitlementEngine(plan, addons);

const results = engine.checkBatch({
  seats: 8,
  projects: 15,
  storage: 50_000_000_000,
  api_calls: 45000,
});

// Check results for each feature
for (const [feature, result] of Object.entries(results)) {
  if (!result.allowed) {
    console.warn(`${feature}: ${result.reason}`);
  }
}

With Add-ons

import { EntitlementEngine } from "@revstack/core";

// Base plan: 10 seats
const plan = { 
  slug: "pro",
  features: { seats: { value_limit: 10 } },
  // ...
};

// Add-on: +5 seats
const addons = [
  {
    slug: "extra_seats",
    features: { 
      seats: { 
        value_limit: 5, 
        type: "increment",
      },
    },
    // ...
  },
];

const engine = new EntitlementEngine(plan, addons);

const result = engine.check("seats", 12);

console.log(result.allowed); // true
console.log(result.remaining); // 3 (10 + 5 - 12 = 3)
console.log(result.granted_by); // ["pro", "extra_seats"]

Subscription Status Gating

import { EntitlementEngine } from "@revstack/core";

// Subscription is past due
const engine = new EntitlementEngine(plan, addons, "past_due");

const result = engine.check("any_feature");

console.log(result.allowed); // false
console.log(result.reason); // "past_due"
console.log(result.remaining); // 0

// Block all access until payment is resolved
if (result.reason === "past_due") {
  redirectToPaymentPage();
}

Express.js Middleware

import { EntitlementEngine } from "@revstack/core";
import type { Request, Response, NextFunction } from "express";

function requireFeature(featureSlug: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const customer = req.user;
    const plan = await getPlan(customer.planId);
    const addons = await getAddons(customer.id);
    const status = customer.subscriptionStatus;

    const engine = new EntitlementEngine(plan, addons, status);
    const result = engine.check(featureSlug);

    if (!result.allowed) {
      return res.status(403).json({
        error: "Feature not available",
        reason: result.reason,
        upgrade_url: "/pricing",
      });
    }

    next();
  };
}

// Protect routes
app.post("/api/sso/configure", requireFeature("sso"), configureSSOHandler);

Next.js Server Action

import { EntitlementEngine } from "@revstack/core";
import { getCurrentUser } from "@/lib/auth";
import { getPlanWithAddons } from "@/lib/billing";

export async function createProject(data: ProjectInput) {
  const user = await getCurrentUser();
  const { plan, addons } = await getPlanWithAddons(user.id);
  
  const engine = new EntitlementEngine(plan, addons, user.subscriptionStatus);
  
  const currentProjects = await db.project.count({ 
    where: { userId: user.id },
  });
  
  const result = engine.check("projects", currentProjects);
  
  if (!result.allowed) {
    throw new Error(`Project limit reached. ${result.reason}`);
  }
  
  return await db.project.create({ data });
}

Aggregation Logic

The engine combines entitlements from the plan and all add-ons:

Processing Order

  1. Base plan is processed first
  2. Add-ons with type: "set" are processed next (sorted deterministically)
  3. Add-ons with type: "increment" are processed last

Increment Behavior

Limits are summed together:
// Plan: 10 seats
// Addon 1: +5 seats (increment)
// Addon 2: +3 seats (increment)
// Total: 10 + 5 + 3 = 18 seats

Set Behavior

Completely replaces the previous limit:
// Plan: 10 seats
// Addon 1: +5 seats (increment)
// Addon 2: 50 seats (set)
// Total: 50 seats (addon 2 overrides everything)

Soft Limits

If ANY source sets is_hard_limit: false, the entire feature becomes soft-limited:
// Plan: { value_limit: 1000, is_hard_limit: true }
// Addon: { is_hard_limit: false }
// Result: Overage allowed (soft limit)

Boolean Features

If ANY source grants value_bool: true or has_access: true, access is granted:
// Plan: { value_bool: false }
// Addon: { has_access: true }
// Result: Access granted

Blocked Statuses

When the subscription is in a blocked status, all features return:
{
  allowed: false,
  reason: "past_due",
  remaining: 0,
}
Blocked statuses: past_due, canceled Allowed statuses: active, trialing, paused

Source

Location: packages/core/src/engine.ts:42-205

Build docs developers (and LLMs) love