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:
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.
Product code depends on features, not on plan names or keys.
Instead of checking the plan:
// Don't do this
if (plan.key === 'pro') {
// ...
}
Vuesion resolves the effective entitlement of a workspace and makes decisions based on it:
PlanFeature
│
▼
WorkspaceFeatureOverride
│
▼
effective entitlement
│
▼
policy / application behavior
This keeps plans pure business configuration. Plans can be renamed, replaced, or reconfigured without touching product code.
Feature Catalog (code)
│
▼
Feature (database projection)
│
▼
Plan ── PlanFeature
│
▼
WorkspacePlan
│
▼
WorkspaceFeatureOverride
│
▼
Entitlement Service
│
▼
Policies / Product API
│
▼
Product UI
The relevant code lives in:
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
Every feature has one of three types.
A capability that is granted or not.
apiKeys = true
The default catalog currently contains no BOOLEAN feature, but the type is fully supported. The guide below adds one as an example.
A numeric limit, such as the maximum number of workspace members:
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:
export type NumberEntitlement = { value: number; unlimited: false } | { value: null; unlimited: true };
One value out of a fixed set defined in the Feature Catalog:
supportLevel = COMMUNITY
The current support levels are COMMUNITY, EMAIL, PRIORITY, and DEDICATED.
Features are defined in code:
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:
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:
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.
The seed (prisma/seed.ts) initializes the plan and entitlement data in two steps:
sync Feature Catalog
│
▼
bootstrap initial plan if necessary
Run it after applying migrations, locally and on every deployment:
npm run db:migrate-deploy
npm run db:seed
Feature synchronization:
Feature rowsFeature rows against the catalogFeature rows that are no longer part of the catalogChanging the type of an existing feature is therefore not a normal deployment operation. See Changing a feature type below.
A fresh installation must be able to create workspaces immediately.
If no plan exists at all, the seed creates the initial plan:
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:
key === 'free'A plan has the following properties.
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.
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 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 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.
Platform Admins can make an active plan the default:
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.
Changing the default does not migrate existing workspaces:
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 changes the configured entitlements of every workspace currently assigned to it.
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:
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.
Every workspace is assigned exactly one plan:
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.
An override replaces the plan value of one feature for one specific workspace.
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:
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.
The Entitlement Service resolves the effective entitlements of a workspace:
const entitlements = await useEntitlementService().getWorkspaceEntitlements(workspaceId);
Resolution follows a single precedence rule:
PlanFeature
│
▼
WorkspaceFeatureOverride (replaces the plan value if present)
│
▼
effective value
The result only contains configured features:
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 system intentionally preserves the difference between these states:
apiKeys = false configured, not granted
apiKeys missing not configured
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.
Entitlements and policies answer different questions.
An entitlement describes what a workspace is entitled to:
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:
For example, "Can this user invite another member to this workspace?" depends on:
the user's workspace role
+
the maxMembers entitlement
+
the current members and pending invitations
Entitlements describe product capability.
Policies and domain logic enforce it.
See Authorization for how policies work in general.
Frontend entitlement checks improve the user experience.
Backend policies enforce product rules.
maxMembers is the real example in Vuesion.
Member slots are counted as:
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:
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:
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.
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.
The product frontend reads the effective entitlements of a workspace through a scoped API:
GET /api/workspaces/:id/entitlements
The endpoint:
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 Admins configure plans and workspaces in the Platform section of the sidebar:
Platform
├── Plans
└── Workspaces
Plans (/admin/plans)
Workspaces (/admin/workspaces)
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 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:
UPDATE "User" SET "isAdmin" = true WHERE "email" = 'admin@example.com';
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 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:
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.
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:
Feature rowAdding 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.
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.
Add the key and its definition to src/shared/domain/feature/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:
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:
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.
Once the feature is synchronized, Platform Admins configure it on the plan detail pages:
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.
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.Then identify the authoritative backend operation, here: creating an API key.
Express the rule as a policy of the domain:
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:
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.
If the product UI should react proactively, load the effective entitlements where they are needed:
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.
The generic entitlement infrastructure is already tested. Focus on the business behavior of the new feature:
feature-catalog.spec.tsAssign 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.
A capacity feature like maxMembers differs from a BOOLEAN capability in two ways.
It has more meaningful states:
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.
Features are defined in code, because only code can give them meaning. Plans and prices are database data, because they are business configuration:
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:
key and a name.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).
A plan price is one purchasable billing option of a plan:
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:
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.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.
A subscription is the local projection of a subscription in the billing provider. It belongs to a workspace, never to the user who paid:
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 | Meaning | Workspace plan |
|---|---|---|
ACTIVE | paid, including when cancelled at the period end | the purchased plan |
PAST_DUE | a renewal failed and the provider retries it | the purchased plan |
UNPAID | the retries failed | the default plan |
PAUSED | payment collection is paused | the default plan |
EXPIRED | the subscription has ended | the 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.
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.409). They can still configure feature overrides, which survive every billing change.409).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.
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:
externalPriceId and fails with 422 for an unknown price, without changing the workspace plan,externalId; a subscription of another workspace is rejected with 409 and never moved,409 while the workspace already has one, instead of choosing a winner,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.
WorkspacePlan answers "which configuration does this workspace use?". Subscription answers "what did the provider sell to this workspace?". The answers differ often:
UNPAID, PAUSED, or EXPIRED subscription still points to the purchased price, but the workspace uses the default 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.
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.
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:
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:
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:
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,on_trial becomes ACTIVE, cancelled becomes ACTIVE with cancelAtPeriodEnd = true until the provider reports expired, and an unknown status fails with 500,ends_at as currentPeriodEnd for cancelled and expired subscriptions and renews_at otherwise,filter[store_id]) page by page, 100 per page, one request after another,422 when the provider rejects a request and with 500 otherwise. Requests time out after 10 seconds and are never retried.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:
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.
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.
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.
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):
| Subscription | Switch plan | Cancel | Resume | Manage billing |
|---|---|---|---|---|
ACTIVE | ✓ | ✓ | ✓ | |
ACTIVE, cancelled at period end | ✓ | ✓ | ||
PAST_DUE, UNPAID, PAUSED | ✓ |
Other actions return 409, as does every action without a current subscription.
portalUrl). The browser opens it, the customer confirms the change there, and the webhook synchronizes it.ACTIVE with cancelAtPeriodEnd, and the workspace keeps its plan until the provider expires the 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.
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():
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.
Three paths keep local subscriptions in line with the provider, and all of them write through syncSubscription:
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.
EXPIRED), one after another in a stable order.EXPIRED does.{ processed, succeeded, failed, unknown }. unknown is the number of detected unknown provider subscriptions, or null when the provider subscriptions could not be listed.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
EXPIRED at the provider,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.
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:
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.
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.
The Incident Board (/admin/incidents, filter by the source billing) lists the billing incidents with their diagnostic context. The server logs contain these lines:
⚠️ 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:
EXPIRED there. <status> is the mapped Vuesion status.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:
| Response | Cause |
|---|---|
400 | Invalid payload, or a new subscription without a valid billing reference |
401 | Invalid webhook signature |
409 | subscription_conflict (logged), or the subscription belongs to another workspace |
422 | unknown_price (logged), or Lemon Squeezy rejected the subscription request |
500 | Missing billing configuration, or Lemon Squeezy could not be reached or returned an invalid response |
subscriptionId of the incident.NUXT_LEMON_SQUEEZY_STORE_ID configures.SELECT "id", "workspaceId", "status" FROM "Subscription" WHERE "externalId" = '<externalId>';
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.
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:
X-Signature must match NUXT_LEMON_SQUEEZY_WEBHOOK_SECRET. An event from another store or mode fails with 401.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.200 and ignored, and a workspace_not_found warning is logged.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.
A 200 response alone does not prove recovery: a deleted workspace is also acknowledged with 200. Check each layer:
200, and the logs contain no synchronization warning for the subscription.status matches the current status in Lemon Squeezy, mapped as in Status and access.ACTIVE and PAST_DUE, and the default plan for UNPAID and PAUSED./_nitro/tasks/subscription-reconciliation.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.
WorkspacePlan or Subscription rows manually to simulate a recovery.Only the provider boundary knows Lemon Squeezy:
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:
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.useBillingProviderService() instead of the Lemon Squeezy service. Keep the demo branch.BillingWebhookEvent (externalSubscriptionId, checkoutReference) for subscription events and null for every other event. Do not read the subscription state from the payload.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.lemonSqueezy* keys in the runtime config of nuxt.config.ts and the NUXT_LEMON_SQUEEZY_* variables in .env-example. Keep NUXT_BILLING_REFERENCE_SECRET.src/server/demo/seed-demo-data.ts).The subscription service then handles checkout, webhooks, management, and reconciliation of the new provider without further changes.
The billing concepts have separate responsibilities:
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.
Plans & Entitlements and billing do not model:
on_trial) count as ACTIVENone of these are required to use the system. Workspace feature overrides already support custom configurations for individual workspaces.
Checking plan names spreads business configuration through the codebase. Every pricing change then becomes a code change.
Vuesion separates the two:
The result is a small, explicit system that lets product configuration change without changing product behavior.
Continue with Internationalization to learn how Vuesion handles languages, translations, locale-aware routing, and user preferences.