Plans & Entitlements

Most products eventually offer different configurations to different customers.

One workspace may invite three members, another twenty. One receives community support, another a dedicated contact.

Vuesion models this with plans and entitlements.

A plan defines the product configuration available to a workspace:

Text
Free
├── maxMembers = 3
└── supportLevel = COMMUNITY

Pro
├── maxMembers = 20
└── supportLevel = PRIORITY

Every workspace is assigned one plan. A workspace may additionally receive feature-specific overrides.

Application behavior never asks which plan a workspace has. It asks what the workspace is entitled to.

The central rule

Product code depends on features, not on plan names or keys.

Instead of checking the plan:

TypeScript
// Don't do this
if (plan.key === 'pro') {
  // ...
}

Vuesion resolves the effective entitlement of a workspace and makes decisions based on it:

Text
       PlanFeature
            │
            ▼
WorkspaceFeatureOverride
            │
            ▼
  effective entitlement
            │
            ▼
 policy / application behavior

This keeps plans pure business configuration. Plans can be renamed, replaced, or reconfigured without touching product code.

Architecture

Text
     Feature Catalog          (code)
            │
            ▼
        Feature               (database projection)
            │
            ▼
   Plan ── PlanFeature
            │
            ▼
      WorkspacePlan
            │
            ▼
WorkspaceFeatureOverride
            │
            ▼
   Entitlement Service
            │
            ▼
 Policies / Product API
            │
            ▼
       Product UI
  • Feature Catalog: the code-defined source of truth for feature keys, feature types, and ENUM values.
  • Feature: the database table that persists the Feature Catalog for relational references. It is not an admin-managed catalog.
  • Plan: a named product configuration. Plans are database-owned business configuration managed by Platform Admins.
  • PlanFeature: configures the value of one feature for one plan.
  • PlanPrice: one purchasable billing option of a plan. It does not affect entitlements (see Plan prices).
  • WorkspacePlan: assigns exactly one plan to a workspace. It is not a billing subscription.
  • Subscription: the local projection of a provider subscription. While a workspace has a current subscription, it decides the workspace plan (see Subscriptions).
  • WorkspaceFeatureOverride: replaces the plan value of one feature for one specific workspace.
  • Entitlement Service: resolves the effective feature values of a workspace.
  • Policies: use effective entitlements wherever a feature needs authoritative backend enforcement.

The relevant code lives in:

Text
src/shared/domain/feature/feature-catalog.ts               Feature Catalog
src/server/domain/feature/                                 feature synchronization
src/server/domain/plan/                                    plans, default plan, bootstrap
src/server/domain/plan-feature/                            plan feature values
src/server/domain/plan-price/                              plan prices
src/server/domain/subscription/                            subscriptions and their plan synchronization
src/server/domain/workspace-plan/                          plan assignment
src/server/domain/workspace-feature-override/              workspace overrides
src/server/domain/entitlement/use-entitlement-service.ts   effective entitlements

Feature types

Every feature has one of three types.

BOOLEAN

A capability that is granted or not.

Text
apiKeys = true

The default catalog currently contains no BOOLEAN feature, but the type is fully supported. The guide below adds one as an example.

NUMBER

A numeric limit, such as the maximum number of workspace members:

Text
maxMembers = 3

A NUMBER value is either a finite, non-negative integer or explicitly unlimited. 0 is a valid finite value.

Unlimited is not encoded as a magic number such as -1 or Infinity. It is a separate state:

Entitlement.ts
export type NumberEntitlement = { value: number; unlimited: false } | { value: null; unlimited: true };

ENUM

One value out of a fixed set defined in the Feature Catalog:

Text
supportLevel = COMMUNITY

The current support levels are COMMUNITY, EMAIL, PRIORITY, and DEDICATED.

Feature Catalog

Features are defined in code:

feature-catalog.ts
export const SupportLevel = {
  COMMUNITY: 'COMMUNITY',
  EMAIL: 'EMAIL',
  PRIORITY: 'PRIORITY',
  DEDICATED: 'DEDICATED',
} as const;

export const Feature = {
  MAX_MEMBERS: 'maxMembers',
  SUPPORT_LEVEL: 'supportLevel',
} as const;

export const FeatureCatalog = {
  [Feature.MAX_MEMBERS]: {
    type: FeatureType.NUMBER,
  },
  [Feature.SUPPORT_LEVEL]: {
    type: FeatureType.ENUM,
    values: Object.values(SupportLevel),
  },
} as const;

The catalog defines:

  • The stable technical key of every feature
  • The type of every feature
  • The allowed values of ENUM features

Application code refers to features through the catalog, for example Feature.MAX_MEMBERS, instead of using string literals.

Platform Admins cannot create features. A feature only makes sense if the application understands it, and only code can provide that understanding.

The catalog is still persisted in the Feature table:

Text
Code      → defines what the application understands
Database  → provides persisted identities and foreign keys

PlanFeature and WorkspaceFeatureOverride reference Feature rows, so the database can enforce relational integrity.

Feature synchronization

The seed (prisma/seed.ts) initializes the plan and entitlement data in two steps:

Text
sync Feature Catalog
        │
        ▼
bootstrap initial plan if necessary

Run it after applying migrations, locally and on every deployment:

Shell
npm run db:migrate-deploy
npm run db:seed

Feature synchronization:

  • Creates missing Feature rows
  • Is safe to run repeatedly
  • Verifies the type of existing Feature rows against the catalog
  • Fails with a clear error when a persisted type differs from the catalog
  • Never deletes Feature rows that are no longer part of the catalog

Changing the type of an existing feature is therefore not a normal deployment operation. See Changing a feature type below.

Initial plan bootstrap

A fresh installation must be able to create workspaces immediately.

If no plan exists at all, the seed creates the initial plan:

Text
name: Free
key: free
active: true
default: true

maxMembers: 3
supportLevel: COMMUNITY

The plan and its plan features are created in one transaction.

Free is bootstrap data, not a permanent system plan.

Once at least one plan exists, the seed never creates or modifies plans again. As a result:

  • Admin changes to the plan are preserved
  • A deleted Free plan is not recreated
  • Free has no special runtime behavior
  • Application code must never depend on key === 'free'

Plans

A plan has the following properties.

key

The stable technical identifier of the plan. It is set on creation and cannot be changed.

The key identifies the plan in administration. It must not be used for feature checks.

name

The human-facing name of the plan. Several plans may share the same display name if the product owner chooses so, for example when a newer edition of a plan replaces an older one.

isActive

isActive means: this plan may be newly assigned.

It does not mean that workspaces lose their entitlements. Workspaces already assigned to an inactive plan keep it and continue to resolve its features normally.

isDefault

isDefault means: new workspaces are automatically assigned to this plan.

It does not affect existing workspaces. isDefault is not an editable setting of a plan. It is changed only through the explicit default plan operation described below.

Default plan management

Platform Admins can make an active plan the default:

Text
Free  = default
Pro   = active

Set Pro as default

Free  = ordinary plan
Pro   = default

The switch is atomic. There is always exactly one active default plan, never none and never two.

  • Only active plans can become the default.
  • The current default cannot be deactivated or deleted. Make another plan the default first.
  • Setting the current default again succeeds without changes.

Changing the default does not migrate existing workspaces:

Text
Workspace A → Free

change default to Pro

Workspace A → Free
new Workspace B → Pro

Use Set as default on the plan detail page in the administration, or usePlanService().setDefaultPlan(planId) on the server.

Editing a plan

Editing a plan changes the configured entitlements of every workspace currently assigned to it.

Text
Pro.maxMembers = 20

changed to

Pro.maxMembers = 10

All workspaces on Pro now resolve maxMembers = 10.

Plans are not versioned. This is intended behavior.

If existing customers should keep an old product configuration, create another plan instead of editing the existing one:

Text
name: Pro
key: pro-2026

name: Pro
key: pro-2027

Existing workspaces stay on the old plan, while new workspaces are assigned to the new one, for example by making it the default.

WorkspacePlan

Every workspace is assigned exactly one plan:

Text
Workspace → WorkspacePlan → Plan

When a workspace is created, it receives the current active default plan. Creating the workspace and its plan assignment happens atomically, so a workspace never exists without a plan.

If no active default plan or more than one exists, workspace creation fails with a clear error instead of guessing.

WorkspacePlan represents the assignment of a product configuration, not payment state.

It deliberately contains no provider, subscription ID, price, currency, billing interval, quantity, or seat billing state.

Workspace feature overrides

An override replaces the plan value of one feature for one specific workspace.

Text
Pro
maxMembers = 20

Acme workspace override
maxMembers = 50

Effective:
maxMembers = 50

Overrides use replacement semantics. They never add to, multiply, or merge with the plan value.

An override survives a plan change:

Text
Workspace
Plan = Pro
maxMembers override = 50

change plan → Business

maxMembers override remains 50

An override may also configure a feature that the plan does not configure.

Removing the override deletes it, and the workspace falls back to the plan value, if the plan configures one.

Effective entitlements

The Entitlement Service resolves the effective entitlements of a workspace:

TypeScript
const entitlements = await useEntitlementService().getWorkspaceEntitlements(workspaceId);

Resolution follows a single precedence rule:

Text
       PlanFeature
            │
            ▼
WorkspaceFeatureOverride   (replaces the plan value if present)
            │
            ▼
     effective value

The result only contains configured features:

Entitlement.ts
export type EntitlementValue = boolean | string | NumberEntitlement;

export type WorkspaceEntitlements = Partial<Record<FeatureKey, EntitlementValue>>;

BOOLEAN features resolve to boolean, ENUM features to their value as string, and NUMBER features to a NumberEntitlement.

The resolver is a read-only service. It does not invent defaults, create plan assignments, repair configuration, fall back to Free, or change any data.

It fails with a clear server error when the stored data is inconsistent:

  • The workspace has no plan assigned.
  • A stored feature is not part of the catalog.
  • A stored feature type differs from the catalog.
  • A stored value is invalid for its feature type.

Missing features

The system intentionally preserves the difference between these states:

Text
apiKeys = false          configured, not granted
apiKeys missing          not configured
Text
maxMembers = 0           no members allowed
maxMembers unlimited     no limit
maxMembers missing       not configured

Do not treat a missing value as false or unlimited unless your product explicitly defines that behavior for the feature.

The member limit, for example, treats a missing maxMembers entitlement as a configuration error: inviting members fails until a plan or override configures the feature.

Feature vs. policy

Entitlements and policies answer different questions.

An entitlement describes what a workspace is entitled to:

Text
Does this workspace have API keys?
How many members may it have?
What support level does it receive?

A policy decides whether a concrete operation is allowed right now. It may combine several inputs:

  • User authorization, such as the workspace role
  • Entitlements
  • Domain state

For example, "Can this user invite another member to this workspace?" depends on:

Text
the user's workspace role
+
the maxMembers entitlement
+
the current members and pending invitations
Text
Entitlements describe product capability.
Policies and domain logic enforce it.

See Authorization for how policies work in general.

Backend enforcement

Text
Frontend entitlement checks improve the user experience.

Backend policies enforce product rules.

maxMembers is the real example in Vuesion.

Member slots are counted as:

Text
used slots
=
current members
+
active pending invitations

An active invitation reserves a member slot. Expired invitations do not.

The effective maxMembers entitlement is enforced when:

  • Inviting a member
  • Renewing an expired invitation, which reserves a slot again
  • Accepting an invitation

Accepting an invitation turns its reserved slot into a membership, so it only checks the current members against the limit.

The rule itself is a policy in workspace.policy.ts:

workspace.policy.ts
export const canAddWorkspaceMember = (maxMembersEntitlement: NumberEntitlement, usedSlots: number) => {
  return maxMembersEntitlement.unlimited || usedSlots < maxMembersEntitlement.value;
};

If no slot is available, the API responds with 403 The workspace has reached its member limit.

Lowering a limit never removes existing members. A workspace above its limit keeps everyone, but operations that increase usage are blocked until usage drops below the limit again.

Capacity limits and concurrency

Member limit enforcement is safe against concurrent requests. Capacity-changing operations of the same workspace run one after another inside a database transaction, so simultaneous invitations cannot exceed the effective limit.

This is why the member limit is evaluated in the workspace invitation service rather than in the controller: the check and the write must happen atomically.

If a future entitlement represents a hard capacity limit, enforce the check and the mutation atomically on the backend as well.

Frontend usage

The product frontend reads the effective entitlements of a workspace through a scoped API:

Text
GET /api/workspaces/:id/entitlements

The endpoint:

  • Requires access to the workspace
  • Returns the same effective values as the Entitlement Service
  • Is read-only

The product UI therefore does not need to know which plan a workspace has or whether a value comes from an override.

On the client, useWorkspaceActions() provides fetchWorkspaceEntitlements(workspaceId) and currentWorkspaceEntitlements.

The workspace settings use them to show member slot usage and to disable the invite action when the limit is reached. The backend still enforces the limit, for example when the displayed state is outdated.

Platform administration

Platform Admins configure plans and workspaces in the Platform section of the sidebar:

Text
Platform
├── Plans
└── Workspaces

Plans (/admin/plans)

  • Create plans
  • Edit plan names
  • Activate and deactivate plans
  • Configure plan features
  • Configure plan prices
  • Set an active plan as default
  • Delete plans when allowed

Workspaces (/admin/workspaces)

  • Search existing workspaces
  • See the assigned plan
  • Assign another active plan, unless the workspace has a current subscription
  • Configure workspace feature overrides

The workspace administration manages plan configuration only. Workspace profiles, members, and invitations remain managed in the workspace itself.

The feature controls in both screens are generated from the Feature Catalog, so a new feature needs no feature-specific admin UI.

Platform Admin security

Platform administration is authorized by User.isAdmin.

It is separate from the workspace roles OWNER, ADMIN, and MEMBER. A workspace owner or admin is not a Platform Admin.

All /api/admin/... endpoints are protected on the server with getAdminServerSession(event), which returns 401 without a session and 403 for users who are not Platform Admins.

Hiding the sidebar section and redirecting the admin pages only improve the user experience. They are not the security boundary.

Promote a user to Platform Admin directly in the database, for example:

SQL
UPDATE "User" SET "isAdmin" = true WHERE "email" = 'admin@example.com';

Deactivating and deleting plans

  • The current default plan cannot be deactivated.
  • The current default plan cannot be deleted.
  • A plan assigned to workspaces cannot be deleted. Deactivate it instead.
  • An inactive plan may remain assigned to existing workspaces, which continue to resolve it normally.
  • A plan with prices cannot be deleted. Delete its prices first, or deactivate the plan instead.

Deleting a plan also deletes its plan features. Deleting a workspace deletes its plan assignment, overrides, and expired subscriptions; a workspace with a current subscription cannot be deleted.

Removing a feature

Removing a feature from the catalog is more than deleting a constant.

Existing PlanFeature and WorkspaceFeatureOverride rows may still reference the feature. The resolver intentionally fails when stored configuration contains a feature the application no longer understands, so every workspace with such a row would fail to resolve its entitlements.

Remove a feature in this order:

Text
1. Stop using the feature in product behavior.
2. Delete or migrate its PlanFeature and WorkspaceFeatureOverride rows.
3. Remove the feature from the catalog and its labels.
4. Deploy.

Step 2 can be a migration created with npm run db:migrate-create that deletes the rows and optionally the Feature row itself. If your deployment applies migrations before the new code serves requests, as recommended above, steps 2 to 4 can ship in the same deployment.

The Feature row is not deleted automatically. A leftover row is harmless: the administration ignores features that are not part of the catalog.

Changing a feature type

Do not change the type of an existing feature key, for example from NUMBER to BOOLEAN or from ENUM to NUMBER.

Feature synchronization detects the type mismatch and fails, and existing values could not be interpreted with the new type anyway.

If the meaning of a feature fundamentally changes, prefer:

  • A new feature key, removing the old feature as described above, or
  • A migration that explicitly converts the stored values and the Feature row

Adding a value to an ENUM feature is not a type change and needs no migration. Removing an ENUM value that is still stored makes resolution fail, just like removing a feature.

Adding a new feature

This guide adds a BOOLEAN feature apiKeys that controls whether a workspace may create API keys.

apiKeys is an example. It is not part of Vuesion.

Step 1: Add the feature to the catalog

Add the key and its definition to src/shared/domain/feature/feature-catalog.ts:

feature-catalog.ts
export const Feature = {
  MAX_MEMBERS: 'maxMembers',
  SUPPORT_LEVEL: 'supportLevel',
  API_KEYS: 'apiKeys',
} as const;

export const FeatureCatalog = {
  // ...
  [Feature.API_KEYS]: {
    type: FeatureType.BOOLEAN,
  },
} as const;

Add the feature label to getFeatureTranslations in src/shared/enums/translation-maps.ts. ENUM features also need labels for their values in getFeatureEnumValueTranslations; for other types, add an empty object:

translation-maps.ts
export const getFeatureTranslations = (t: any): Record<FeatureKey, string> => {
  return {
    // ...
    apiKeys: t('Feature.apiKeys' /* API keys */),
  };
};

export const getFeatureEnumValueTranslations = (t: any): Record<FeatureKey, Record<string, string>> => {
  return {
    // ...
    apiKeys: {},
  };
};

Type checking reports a missing entry in both maps. Then extract and translate the new messages as described in Internationalization:

Shell
npm run extract-i18n-messages

Update the expected features in feature-catalog.spec.ts.

The next npm run db:seed creates the Feature row. The test database is seeded automatically.

Step 2: Configure plans

Once the feature is synchronized, Platform Admins configure it on the plan detail pages:

Text
Free
apiKeys = false

Pro
apiKeys = true

Individual workspaces can receive an override in the workspace administration.

No admin UI changes are necessary. The feature controls are generated from the catalog.

Step 3: Decide what the feature controls

Adding a feature to the catalog enforces nothing.

Decide what the feature means and which operations it protects. For apiKeys:

  • true allows creating API keys.
  • false does not.
  • A missing value means the plan does not grant API keys. This is a deliberate product decision for this feature.

Then identify the authoritative backend operation, here: creating an API key.

Step 4: Enforce it on the backend

Express the rule as a policy of the domain:

api-key.policy.ts
import { Feature } from '#shared/domain/feature/feature-catalog';
import type { WorkspaceEntitlements } from '#shared/types/domain/Entitlement';

export const canUseApiKeys = (entitlements: WorkspaceEntitlements) => {
  return entitlements[Feature.API_KEYS] === true;
};

Evaluate it in the controller next to the role-based policy, before calling the service:

TypeScript
const workspace = await useWorkspaceService().getWorkspaceDetails(body.workspaceId, session.user.id);

if (!workspace) {
  throw NotFoundError('Workspace not found');
}

assertHasAccess(canUpdateWorkspace(workspace));

const entitlements = await useEntitlementService().getWorkspaceEntitlements(workspace.id);

if (!canUseApiKeys(entitlements)) {
  throw ForbiddenError('API keys are not included in the plan of this workspace.');
}

return createApiKey(/* ... */);

The decision flows from the workspace through its effective entitlement to the backend decision. It never inspects the plan.

Step 5: Add frontend UX if needed

If the product UI should react proactively, load the effective entitlements where they are needed:

TypeScript
const { currentWorkspaceEntitlements, fetchWorkspaceEntitlements } = useWorkspaceActions();

await fetchWorkspaceEntitlements(workspaceId.value);

const hasApiKeys = computed(() => currentWorkspaceEntitlements.value?.apiKeys === true);

Use the result to show, hide, or disable the relevant UI and to explain why an action is unavailable.

This step is optional. Frontend checks only improve the user experience. The backend remains authoritative.

Step 6: Test the behavior

The generic entitlement infrastructure is already tested. Focus on the business behavior of the new feature:

  • The catalog definition, in feature-catalog.spec.ts
  • The protected backend operation with the feature granted, not granted, and not configured
  • A workspace override changing the outcome, if overrides matter for the feature
  • The frontend UX, if you added any

Assign the configuration in server tests through plans and overrides, for example with usePlanFeatureService().upsertPlanFeature(planId, Feature.API_KEYS, { booleanValue: true }) on a plan created for the test.

NUMBER features

A capacity feature like maxMembers differs from a BOOLEAN capability in two ways.

It has more meaningful states:

Text
finite 3     three allowed
finite 0     none allowed
unlimited    no limit
missing      not configured

Check unlimited explicitly before comparing value.

It also needs enforcement around every operation that consumes capacity, and the check and the mutation must happen atomically, as described in Capacity limits and concurrency.

Adding a plan

Features are defined in code, because only code can give them meaning. Plans and prices are database data, because they are business configuration:

Text
Feature Catalog    code       a deployment adds or removes features
Plan, PlanFeature  database   Platform Admins or the seed configure them
PlanPrice          database   Platform Admins or the seed configure them

A new plan therefore needs no code change. As a Platform Admin, in Platform → Plans:

  • Create the plan with a key and a name.
  • Configure its features. Features that are not configured stay missing (see Missing features).
  • Add one price per billing interval with the External ID of the provider price (see Plan prices).
  • Optionally make the plan the default for new workspaces.

The plan is offered in Plan & Billing as soon as it is active and has at least one active price. Platform Admins can also assign any active plan, with or without prices, to a workspace without a current subscription.

On the server, the same operations are available through usePlanService(), usePlanFeatureService(), and usePlanPriceService(). To ship plans as initial data of your product, add them to the seed instead (see Development → Configuration → Customize the seed).

Plan prices

A plan price is one purchasable billing option of a plan:

Text
Pro
├── PlanFeature   maxMembers = 10
├── PlanPrice     EUR 19 / month
└── PlanPrice     EUR 190 / year

Both prices belong to the same plan and therefore grant the same entitlements. The billing interval is pricing, not product configuration, so monthly and yearly billing never need separate plans.

A plan price has the following properties:

  • interval: MONTH or YEAR.
  • amount: the price in the minor unit of the currency as an integer, for example 1900 for EUR 19.00 or 1900 for JPY 1,900. It must not be negative. Zero is allowed, for example for testing.
  • currency: a three-letter ISO 4217 code. It is normalized to uppercase. Vuesion validates only the shape of the code and keeps no list of currencies.
  • externalId: the identifier of the purchasable price in the configured billing provider, for example a variant ID or a price ID. It is unique and contains no provider-specific naming, because Vuesion is configured with one billing provider per installation.
  • isActive: this price is offered for new purchases. Deactivating a price keeps it, so it remains available to existing purchases.

The amount is the price Vuesion shows before checkout. The billing provider remains authoritative for the amount actually charged. Vuesion does not synchronize prices with the provider.

Rules:

  • A plan has at most one active price per billing interval. A plan may have no active price, for example the Free plan, which needs no price at all.
  • The service locks the plan row while it creates or activates a price, so concurrent requests cannot activate two prices for the same plan and interval.
  • interval and externalId cannot be changed after creation, because they describe the price object in the billing provider. When the provider price changes in a way that requires a new provider object, deactivate the old plan price and create a new one.
  • amount, currency, and isActive can be edited.
  • Prices may exist on inactive plans. Purchasing will require both an active plan and an active price.
  • A price can be deleted while no subscription uses it, including expired ones. A plan with prices cannot be deleted.

Platform Admins manage prices in the Prices section of the plan detail page, or through usePlanPriceService() on the server. The admin enters the price in the major unit of the currency; the number of minor-unit digits is taken from Intl.NumberFormat, so currencies without cents, such as JPY, are converted correctly.

Subscriptions

A subscription is the local projection of a subscription in the billing provider. It belongs to a workspace, never to the user who paid:

Text
Workspace
├── WorkspacePlan ──────────────→ Plan
├── WorkspaceFeatureOverride
└── Subscription ──→ PlanPrice ─→ Plan

A free workspace needs no subscription: it is simply assigned the default plan. Vuesion never creates subscription rows that do not exist in the billing provider.

A subscription stores the provider IDs (externalId, externalCustomerId), the purchased price, a provider-independent status, cancelAtPeriodEnd, and currentPeriodEnd. Invoices, payments, and taxes stay in the billing provider.

Status and access

StatusMeaningWorkspace plan
ACTIVEpaid, including when cancelled at the period endthe purchased plan
PAST_DUEa renewal failed and the provider retries itthe purchased plan
UNPAIDthe retries failedthe default plan
PAUSEDpayment collection is pausedthe default plan
EXPIREDthe subscription has endedthe default plan, once

A cancelled subscription stays ACTIVE with cancelAtPeriodEnd = true until the provider reports it as EXPIRED. The mapping lives in grantsPaidPlan() in src/server/domain/subscription/subscription-status.ts.

Current subscription and plan ownership

Every status except EXPIRED makes a subscription current: UNPAID and PAUSED subscriptions still exist in the provider and may recover. A workspace has at most one current subscription and may keep any number of expired ones as history.

While a workspace has a current subscription, billing manages its plan. No field marks this; the current subscription is the ownership:

  • useSubscriptionService().syncSubscription(workspaceId, state) is the only billing writer of WorkspacePlan. It assigns the purchased plan or the active default plan according to the status.
  • Platform Admins cannot assign another plan (409). They can still configure feature overrides, which survive every billing change.
  • The workspace cannot be deleted, and neither can the user who owns it (409).
  • Workspace ownership cannot be transferred (409). The subscription must first expire.

When the current subscription expires, the workspace is assigned the default plan once and plan management returns to normal: Platform Admins can assign plans again, and a new subscription can start. Workspaces without a current subscription are never reset by billing, so manually assigned plans stay as they are.

Synchronization

syncSubscription receives the workspace separately from the provider-independent SubscriptionState, because the workspace is trusted application context and must not be derived from provider data. In one transaction that locks the workspace, it:

  • resolves the plan price by externalPriceId and fails with 422 for an unknown price, without changing the workspace plan,
  • creates the subscription or updates it by externalId; a subscription of another workspace is rejected with 409 and never moved,
  • rejects a new current subscription with 409 while the workspace already has one, instead of choosing a winner,
  • assigns the workspace plan.

Existing subscriptions keep resolving their plan even when the price or the plan has been deactivated. isActive only controls new purchases and assignments.

A subscription that is reported as EXPIRED and was never current locally is stored as history and leaves the workspace plan unchanged.

There is no API endpoint for the synchronization. It is called by the billing integration.

Why Subscription and WorkspacePlan are separate

WorkspacePlan answers "which configuration does this workspace use?". Subscription answers "what did the provider sell to this workspace?". The answers differ often:

  • A free workspace has a plan but no subscription.
  • A Platform Admin can assign a plan without any payment, e.g. for a partner.
  • An UNPAID, PAUSED, or EXPIRED subscription still points to the purchased price, but the workspace uses the default plan.
  • Expired subscriptions remain as history, while the workspace has exactly one plan.

Keeping them separate means entitlements are always resolved the same way, from WorkspacePlan, no matter how the plan was assigned. Billing only decides which plan that is, and only through syncSubscription.

Lifecycle

Text
checkout            owner chooses a price → provider-hosted checkout → nothing changes locally
subscription        provider webhook → provider GET → syncSubscription → purchased plan
plan change         owner switches price → provider change → syncSubscription → new plan immediately
cancellation        owner cancels → ACTIVE, cancelAtPeriodEnd → plan kept until the period ends
resume              owner resumes before the period ends → ACTIVE → plan kept
payment problems    PAST_DUE keeps the plan; UNPAID or PAUSED → default plan until it recovers
expiration          provider reports EXPIRED → default plan once → plan management returns to admins

Every step that changes the workspace plan goes through syncSubscription. The following sections describe each part.

Billing provider

Lemon Squeezy is the included billing provider. Vuesion supports one billing provider per installation, so neither plans, prices, nor subscriptions store which provider they belong to.

The billing domain depends only on useBillingProviderService() in src/server/services/use-billing-provider-service.ts:

Text
createCheckout({ externalPriceId, checkoutReference, email?, redirectUrl })  → { url }
getSubscription(externalSubscriptionId)                                      → SubscriptionState
changeSubscriptionPrice(externalSubscriptionId, externalPriceId)             → SubscriptionPriceChange
cancelSubscription(externalSubscriptionId)                                   → SubscriptionState
resumeSubscription(externalSubscriptionId)                                   → SubscriptionState
getPortalUrl(externalSubscriptionId)                                         → signed URL
listSubscriptions()                                                          → ProviderSubscription[]

The operations return the same provider-independent SubscriptionState that syncSubscription consumes:

Subscription.ts
export type SubscriptionState = {
  externalId: string;
  externalCustomerId: string;
  externalPriceId: string;
  status: SubscriptionStatus;
  cancelAtPeriodEnd: boolean;
  currentPeriodEnd: Date | null;
};

listSubscriptions only returns { externalId, externalPriceId, status } of each provider subscription, for the detection of unknown subscriptions. changeSubscriptionPrice returns either { type: 'UPDATED', subscription } or { type: 'PORTAL_REQUIRED', url } when the customer must confirm the change at the provider first. The operations never synchronize the local subscription themselves; that is the job of the calling billing flow.

useBillingProviderService() returns the Lemon Squeezy implementation (use-lemon-squeezy-billing-provider-service.ts). In demo mode, it returns a local simulation instead (use-demo-billing-provider-service.ts), so the public demo needs no provider configuration and never calls Lemon Squeezy; see Development → Configuration → Demo mode → Simulated billing.

The Lemon Squeezy implementation:

  • creates checkouts through the API for the configured store, restricted to the selected variant, with the opaque checkout reference as custom data (checkout_data.custom.reference) and without a custom price, so the variant price is charged; every checkout expires 24 hours after it was created (expires_at), so an old checkout link cannot start a subscription later,
  • maps the provider statuses to Vuesion statuses; on_trial becomes ACTIVE, cancelled becomes ACTIVE with cancelAtPeriodEnd = true until the provider reports expired, and an unknown status fails with 500,
  • uses ends_at as currentPeriodEnd for cancelled and expired subscriptions and renews_at otherwise,
  • changes plans with the provider's default proration and cancels at the end of the period,
  • fetches the short-lived signed Customer Portal URL of a subscription on demand,
  • lists the subscriptions of the configured store (filter[store_id]) page by page, 100 per page, one request after another,
  • validates only the response fields it uses, and fails with 422 when the provider rejects a request and with 500 otherwise. Requests time out after 10 seconds and are never retried.

Plan & Billing and checkout

The Plan & Billing section of the workspace settings shows the workspace plan with its effective limits, the current subscription, and, without a current subscription, the purchasable plans. It reads the local projection through GET /api/workspaces/:id/billing and never calls the provider. The response contains no provider IDs or URLs.

A plan can be purchased when it is active and has at least one active price. Plans are listed by name, and each active price can be chosen.

Only the workspace owner manages billing (canManageWorkspaceBilling). Other members see the same state without checkout actions. POST /api/workspaces/:id/billing/checkout accepts only a planPriceId and, for the owner of a workspace without a current subscription, resolves everything else on the server:

Text
External ID   → from the active plan price of an active plan
reference     → createBillingReference(workspaceId)
email         → email of the signed-in user
return URL    → /workspaces/:id/settings?checkout=returned

It returns the URL of the provider-hosted checkout and changes nothing locally: no subscription, no plan assignment, no entitlements. A workspace with a current subscription gets 409.

?checkout=returned only means that the browser came back from the checkout. It is no proof of payment. The page shows a neutral processing message and refreshes the billing state every 2 seconds, at most 5 times, until the subscription synchronized by the webhook appears. After that it shows that the checkout is still being processed; a later reload shows the latest state.

Contextual upgrade entry points

A feature limit may point to Plan & Billing, but plan selection, prices, checkout, and subscription management stay there. The feature UI only explains why an action is unavailable.

The member limit is the first example: when a workspace has used all member slots, WorkspaceMemberSlots shows the limit, and users who manage billing get View plans, which opens /workspaces/:id/settings#plan-and-billing. Admins are asked to contact the workspace owner. The invite flow needs no billing knowledge, and the server keeps enforcing the limit. Invited users who cannot join because of the limit see the error and are asked to contact the person who invited them.

Subscription management

The workspace owner manages a current subscription in the same Plan & Billing section. Other members see its state without actions. The client only sends a planPriceId; the subscription and the provider IDs are always resolved on the server.

Text
PATCH /api/workspaces/:id/billing/subscription          { planPriceId } → { portalUrl }
POST  /api/workspaces/:id/billing/subscription/cancel   → 204
POST  /api/workspaces/:id/billing/subscription/resume   → 204
POST  /api/workspaces/:id/billing/portal                → { url }

The actions depend on the status (src/shared/domain/subscription/subscription-actions.ts, used by the API and the UI):

SubscriptionSwitch planCancelResumeManage billing
ACTIVE✓✓✓
ACTIVE, cancelled at period end✓✓
PAST_DUE, UNPAID, PAUSED✓

Other actions return 409, as does every action without a current subscription.

  • Switch plan changes the subscription to another active price of an active plan, including from monthly to yearly. The change takes effect immediately, and the provider applies its default proration; Vuesion calculates no amounts. Lemon Squeezy does not change PayPal subscriptions through the API but returns a signed portal URL instead (portalUrl). The browser opens it, the customer confirms the change there, and the webhook synchronizes it.
  • Cancel cancels at the end of the billing period. The subscription stays ACTIVE with cancelAtPeriodEnd, and the workspace keeps its plan until the provider expires the subscription.
  • Resume undoes the cancellation before the period ends. The provider decides whether that is still possible.
  • Manage billing opens the provider's customer portal with a freshly signed URL. It handles payment methods, billing details, invoices, and the recovery of payment problems, so it is available for every current subscription.

Every change is first made at the provider; the state it returns is then synchronized immediately, so the page shows the result without waiting for the webhook. Nothing changes locally when the provider rejects a request. If the local synchronization fails after the provider succeeded, the request fails without undoing the provider change, and the subscription_updated webhook repairs the local state.

Webhooks

POST /api/webhooks/lemonsqueezy keeps subscriptions in sync. It needs no session; it is authenticated by the X-Signature HMAC of the raw request body. It processes subscription_created and subscription_updated and acknowledges every other event without action.

For a relevant event, useSubscriptionService().syncProviderSubscription():

  • resolves the workspace: a known subscription keeps its workspace, and a new subscription needs the signed billing reference that the checkout passed to the provider,
  • ignores the event when the referenced workspace no longer exists, e.g. after a demo reset,
  • fetches the current subscription from the provider and synchronizes it with syncSubscription.

The subscription state in the payload is ignored. Webhooks can arrive late, twice, or out of order; because every event synchronizes the current provider state, an old event cannot roll a subscription back. An invalid signature returns 401, and a failed synchronization returns an error status, so the provider retries the delivery.

The billing reference (createBillingReference(workspaceId) and verifyBillingReference(reference) in src/server/domain/subscription/billing-reference.ts) is the workspace ID signed with NUXT_BILLING_REFERENCE_SECRET. It only proves which workspace a checkout belongs to; whether a subscription exists and what was purchased always comes from the provider API.

Reconciliation

Three paths keep local subscriptions in line with the provider, and all of them write through syncSubscription:

Text
direct management change  →  provider state ─────────┐
webhook                   →  provider GET   ─────────┼→  syncSubscription()
scheduled reconciliation  →  provider GET   ─────────┘

The hourly subscription-reconciliation task (useSubscriptionService().reconcileSubscriptions()) is the safety net for missed webhooks and for provider changes whose local synchronization failed, for example a subscription that expired at the provider while the webhook was lost.

  • It synchronizes only locally known current subscriptions (every status except EXPIRED), one after another in a stable order.
  • For each subscription, it fetches the provider state and synchronizes it with the subscription's own workspace. The provider state must belong to the requested subscription.
  • A failed subscription is logged with its subscription and workspace IDs and leaves its local state unchanged; the remaining subscriptions are still processed. A failed lookup never expires a subscription: only a provider state of EXPIRED does.
  • It does not retry within a run and never changes anything at the provider. The next run and the provider's webhook retries are the further attempts.
  • Afterwards, it detects unknown provider subscriptions (see below).
  • It returns { processed, succeeded, failed, unknown }. unknown is the number of detected unknown provider subscriptions, or null when the provider subscriptions could not be listed.

Unknown provider subscriptions

If every delivery of the subscription_created webhook fails, Vuesion never creates the local subscription, and the customer stays on the default plan although they paid. The provider API does not return the signed billing reference, so only the webhook can assign a subscription to a workspace.

After synchronizing the known subscriptions, the reconciliation therefore lists the provider subscriptions of the configured store and reports each one that

  • is not EXPIRED at the provider,
  • has no local subscription with its External ID, in any status, and
  • uses the External ID of a configured plan price, including inactive prices and plans.

Each one is logged as a warning that contains Unknown provider subscription detected and contains its External ID and status, so monitoring can alert on it, and is reported as an unknown_subscription incident with the customer email that the provider listed. The same subscription is reported again by every run until it is no longer unknown; repeated runs increment the occurrences of its incident.

Detection is read-only: it never creates subscriptions, changes workspace plans, assigns a workspace, or changes anything at the provider. A matching plan price does not prove that the subscription belongs to this installation, because several installations can share a store or its variants. Investigate every warning; if the subscription belongs to this installation, resend its original subscription_created webhook from the Lemon Squeezy dashboard (see Subscription recovery runbook). If the provider subscriptions cannot be listed, the failure is logged, the known subscriptions stay reconciled, and the next run tries again. The Lemon Squeezy API key belongs to either Test Mode or Live Mode and only lists subscriptions of its mode. The demo provider lists no subscriptions.

Synchronization warnings

Webhook failures only return an error status to the provider, so the synchronization logs the failures that need an operator as warnings that contain Billing subscription synchronization failed and contain a stable reason and the External ID of the provider subscription:

Text
reason=workspace_not_found     subscription="<externalId>" workspace="<workspaceId>"
reason=subscription_conflict   subscription="<externalId>" workspace="<workspaceId>" currentSubscription="<id>"
reason=unknown_price           subscription="<externalId>" price="<externalPriceId>"
  • workspace_not_found: the workspace of the subscription no longer exists. A new subscription of a deleted workspace is still acknowledged and ignored.
  • subscription_conflict: the workspace already has another current subscription. Nothing changes; the existing subscription and the workspace plan stay as they are.
  • unknown_price: no plan price uses the provider price. Nothing changes; configure the plan price or investigate the subscription.

The warnings never contain customer data, payment data, or the signed billing reference. Each failure is also reported as an incident of the same type. The synchronization does not recover these cases automatically; investigate each warning, together with the unknown provider subscriptions, at the provider. A failed reconciliation additionally logs its own error line.

Subscription recovery runbook

This runbook helps operators handle a customer who paid but whose subscription never reached Vuesion, and the related synchronization warnings. Vuesion has no administrative recovery action. The only supported recovery is to resend the original webhook from Lemon Squeezy.

Detection signals

The Incident Board (/admin/incidents, filter by the source billing) lists the billing incidents with their diagnostic context. The server logs contain these lines:

Text
⚠️ Unknown provider subscription detected: Provider subscription "<externalId>" (<status>) uses a configured plan price but is unknown locally and may require investigation.
⚠️ Billing subscription synchronization failed: reason=<reason> subscription="<externalId>" ...
❌ Reconciling subscription "<id>" of workspace "<workspaceId>" failed: <message>
❌ Detecting unknown provider subscriptions failed: <message>

An unknown provider subscription means:

  • The subscription exists in the configured Lemon Squeezy store and is not EXPIRED there. <status> is the mapped Vuesion status.
  • No local subscription has its External ID, in any status.
  • Its variant matches the External ID of a configured plan price.

It does not prove that a customer is missing access. The subscription may belong to another installation that uses the same store. Vuesion never assigns it to a workspace.

The synchronization reasons are described in Synchronization warnings.

Some webhook rejections are not logged, because they are expected HTTP errors. They are only visible as the response of the delivery in the Lemon Squeezy dashboard:

ResponseCause
400Invalid payload, or a new subscription without a valid billing reference
401Invalid webhook signature
409subscription_conflict (logged), or the subscription belongs to another workspace
422unknown_price (logged), or Lemon Squeezy rejected the subscription request
500Missing billing configuration, or Lemon Squeezy could not be reached or returned an invalid response

Investigate an unknown subscription

  • Copy the External ID of the provider subscription from the warning or the subscriptionId of the incident.
  • Open the Lemon Squeezy store that NUXT_LEMON_SQUEEZY_STORE_ID configures.
  • Switch to the mode of the deployment's API key: Test Mode or Live Mode. Each mode only contains its own subscriptions.
  • Find the subscription by its ID.
  • Check its current status. A subscription that has expired in the meantime is no longer reported and needs no recovery.
  • Check that its variant is the External ID of a plan price in Platform → Plans.
  • Check whether another installation, for example a staging deployment, uses the same store and may own the subscription.
  • Check that no local subscription exists for it, with a read-only query:
    SQL
    SELECT "id", "workspaceId", "status" FROM "Subscription" WHERE "externalId" = '<externalId>';
    
  • Search the logs for synchronization warnings with the same subscription="<externalId>". If there is one, continue with When recovery is not possible.

A matching variant, customer email, amount, or purchase time does not prove which workspace the subscription belongs to. Only the signed billing reference in the original webhook proves the workspace. The customer email in the incident only helps to find the subscription and contact the customer.

Resend the original webhook

In the Lemon Squeezy dashboard, find the original subscription_created event of the subscription in the webhook deliveries of this deployment's webhook and resend it. Do not use Simulate event: a simulated event carries no billing reference and is rejected with 400.

Vuesion processes the resent event like the first delivery:

  • The X-Signature must match NUXT_LEMON_SQUEEZY_WEBHOOK_SECRET. An event from another store or mode fails with 401.
  • The billing reference in meta.custom_data.reference must verify with the current NUXT_BILLING_REFERENCE_SECRET. A reference of another installation, or one created before the secret was changed, fails with 400.
  • The referenced workspace must exist. Otherwise, the event is acknowledged with 200 and ignored, and a workspace_not_found warning is logged.
  • Vuesion fetches the current subscription from the Lemon Squeezy API and synchronizes it. The state in the event payload is ignored, so resending an old event cannot restore an outdated status.
  • The variant must match a plan price (unknown_price), and the workspace must not have another current subscription (subscription_conflict).

If the subscription has expired at the provider in the meantime, it is stored as an EXPIRED subscription and the workspace plan stays unchanged.

Lemon Squeezy may not keep every past delivery available for resending. Webhooks of the public demo are ignored, so this runbook does not apply to demo mode.

Verify the recovery

A 200 response alone does not prove recovery: a deleted workspace is also acknowledged with 200. Check each layer:

  • Webhook: the resent delivery returned 200, and the logs contain no synchronization warning for the subscription.
  • Local subscription: the query above returns one row with the expected workspace.
  • Status: the local status matches the current status in Lemon Squeezy, mapped as in Status and access.
  • Workspace plan: Platform → Workspaces shows the purchased plan for ACTIVE and PAST_DUE, and the default plan for UNPAID and PAUSED.
  • Entitlements: the workspace's Plan & Billing section shows the subscription and the expected limits. Feature overrides still replace plan values.
  • Reconciliation: the next hourly run no longer reports the subscription as unknown. In development, run it with /_nitro/tasks/subscription-reconciliation.
  • Incident: resolve the incident on the Incident Board only after the recovery is verified. Resolving deletes the incident but repairs nothing; if the problem still exists, it is reported again as a new incident.

When recovery is not possible

The original webhook is no longer available. Vuesion cannot reconstruct the workspace of the subscription. The provider API does not return the billing reference, and Vuesion has no administrative recovery action. Do not create the subscription or change the workspace plan in the database. Recovery requires an explicit administrative mechanism that Vuesion does not provide; until then, resolve the customer's billing in Lemon Squeezy.

The workspace no longer exists. The subscription cannot be associated with it, and it must not be assigned to another workspace. Investigate the subscription in Lemon Squeezy and resolve any ongoing billing obligation with its administrative tools, for example by cancelling or refunding it.

The workspace has another current subscription. Vuesion refuses to replace it (subscription_conflict). Do not delete or expire local records. Investigate both provider subscriptions and their billing state in Lemon Squeezy before taking further action. Once the provider expires one of them and the change is synchronized, resend the event again if the other subscription should remain.

The price is unknown. No plan price uses the subscription's variant, so Vuesion cannot resolve the plan. Compare the variant with the External IDs in Platform → Plans and check that the deployment uses the correct mode. Add a plan price only if the variant is a real offer of this installation, not to silence the warning. Do not change the External ID of existing prices; create a new price instead (see Plan prices).

Lemon Squeezy is unavailable. The webhook fails with 500 and Lemon Squeezy retries it. Known subscriptions are synchronized again by the next reconciliation run. If the subscriptions cannot be listed, unknown subscriptions are not reported for that run. An unknown subscription still needs a successful webhook delivery; reconciliation never associates it with a workspace.

Safety rules

  • Never bypass the webhook signature or the billing reference verification, and never edit or construct webhook payloads.
  • Never associate a subscription with a workspace based on email, variant, price, amount, or timestamps.
  • Never change WorkspacePlan or Subscription rows manually to simulate a recovery.
  • Never assume that a successful payment means Vuesion received the webhook. Check the local subscription.
  • Never log or share API keys, webhook secrets, billing references, or customer payment data while investigating.

Replacing the billing provider

Only the provider boundary knows Lemon Squeezy:

Text
src/server/services/use-lemon-squeezy-billing-provider-service.ts   provider API
src/server/services/lemon-squeezy-webhook.ts                        signature check and event parsing
src/server/api/webhooks/lemonsqueezy.post.ts                        webhook route
nuxt.config.ts                                                      runtime config, webhook route rule
.env-example                                                        NUXT_LEMON_SQUEEZY_* variables

Plans, plan prices, subscriptions, workspace plans, entitlements, the billing API, the reconciliation, the Plan & Billing UI, and the demo provider stay unchanged. To use another provider, for example Stripe:

  • Implement the provider service, e.g. use-stripe-billing-provider-service.ts, with the seven operations and the types CheckoutRequest, SubscriptionPriceChange, and ProviderSubscription from use-billing-provider-service.ts. Map the provider subscription to SubscriptionState: the provider price ID becomes externalPriceId, every provider status maps to one SubscriptionStatus, and a subscription cancelled at the period end stays ACTIVE with cancelAtPeriodEnd = true until it ends. Attach checkoutReference to the checkout so that the provider returns it with the subscription events. Return PORTAL_REQUIRED only if the provider requires the customer to confirm a price change. listSubscriptions() returns the External ID, price ID, and status of every subscription of the configured account, without customer or payment data.
  • Select it in useBillingProviderService() instead of the Lemon Squeezy service. Keep the demo branch.
  • Parse the webhook. Verify the provider signature against the raw request body and return a BillingWebhookEvent (externalSubscriptionId, checkoutReference) for subscription events and null for every other event. Do not read the subscription state from the payload.
  • Add the webhook route, e.g. src/server/api/webhooks/stripe.post.ts, like lemonsqueezy.post.ts: ignore requests in demo mode, parse the event, and call useSubscriptionService().syncProviderSubscription(externalSubscriptionId, checkoutReference). Exclude the route from the rate limiter in nuxt.config.ts.
  • Replace the configuration: the lemonSqueezy* keys in the runtime config of nuxt.config.ts and the NUXT_LEMON_SQUEEZY_* variables in .env-example. Keep NUXT_BILLING_REFERENCE_SECRET.
  • Replace the External IDs of all plan prices with the provider's price IDs, including in your seed and the demo fixture (src/server/demo/seed-demo-data.ts).
  • Remove the Lemon Squeezy files and their specs, add specs for the new files, and update Development → Configuration.

The subscription service then handles checkout, webhooks, management, and reconciliation of the new provider without further changes.

Billing boundary

The billing concepts have separate responsibilities:

Text
Plan            → entitlement configuration
PlanPrice       → purchasable billing option of a plan
Subscription    → local projection of the provider subscription
WorkspacePlan   → effective plan of a workspace
Entitlements    → effective access of a workspace

Application code keeps resolving entitlements from the workspace plan. It never reads subscriptions to decide what a workspace may do.

What is intentionally not part of the system

Plans & Entitlements and billing do not model:

  • More than one billing provider per installation
  • Quantity or seat-based billing
  • Trials as a separate state; Lemon Squeezy trials (on_trial) count as ACTIVE
  • Invoices, payments, and taxes, which stay in the billing provider
  • Usage metering or credits
  • Reusable add-ons
  • Plan versioning

None of these are required to use the system. Workspace feature overrides already support custom configurations for individual workspaces.

Why this architecture?

Checking plan names spreads business configuration through the codebase. Every pricing change then becomes a code change.

Vuesion separates the two:

  • Code defines which features exist and enforces what they mean.
  • Plans and overrides configure which workspace receives which value.
  • A single service resolves the effective value.
  • Backend policies enforce product rules, while the frontend only improves the experience.

The result is a small, explicit system that lets product configuration change without changing product behavior.

Next steps

Continue with Internationalization to learn how Vuesion handles languages, translations, locale-aware routing, and user preferences.