Plan prices, subscriptions and the billing provider assign plans to workspaces. Entitlements stay in Plans & Entitlements.
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 the effective plan (see Effective entitlements), no matter how the plan was assigned. Billing only decides which plan is assigned, 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
trial owner starts a trial → trial plan is effective → expires at endsAt, or converts on subscription
Every step that changes the workspace plan goes through syncSubscription. The following sections describe each part.
Stripe and Lemon Squeezy are the included billing providers. Vuesion supports one billing provider per installation, selected with NUXT_BILLING_PROVIDER (stripe, otherwise Lemon Squeezy; default lemonsqueezy), so neither plans, prices, nor subscriptions store which provider they belong to. For Stripe taxes and invoices, see Stripe products and plan prices.
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 implementation of the selected provider (use-stripe-billing-provider-service.ts or 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 a provider; 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 Stripe implementation calls the Stripe API with fetch (no SDK) and:
reference); every checkout expires after 23 hours,active and trialing become ACTIVE, past_due becomes PAST_DUE, unpaid and incomplete become UNPAID, paused becomes PAUSED, and canceled and incomplete_expired become EXPIRED,currentPeriodEnd from the first subscription item and uses ended_at for ended subscriptions,create_prorations; a failed payment rejects the change,cancel_at_period_end,PORTAL_REQUIRED; only Lemon Squeezy does.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 workspace owner.
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 }
POST /api/workspaces/:id/billing/trial → 204 (body: { planId })
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 provider's update webhook repairs the local state.
The webhook of the selected provider keeps subscriptions in sync. It needs no session; it is authenticated by a signature of the raw request body. Every other event is acknowledged without action.
POST /api/webhooks/lemonsqueezy: header X-Signature (HMAC); processes subscription_created and subscription_updated.POST /api/webhooks/stripe: header Stripe-Signature with a 5-minute tolerance; processes customer.subscription.created, customer.subscription.updated and customer.subscription.deleted.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.HTTP 404), a missing_subscription incident is reported as well; the remaining subscriptions are still processed. A failed lookup never expires a subscription: only a provider state of 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 provider's creation 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 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 creation webhook from the provider 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. Lemon Squeezy lists the subscriptions of the configured store; its API key belongs to either Test Mode or Live Mode and only lists subscriptions of its mode. Stripe has no store: every subscription of the account with a configured plan price is checked, and its key belongs to one mode (see Billing test and live 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. The only supported recovery of a missing subscription is to resend the original webhook from the billing provider. The steps apply to both providers and name Stripe where it differs from Lemon Squeezy. Vuesion's only administrative action ends a local subscription that no longer exists at the provider.
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 or Stripe account. 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 provider 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 the provider rejected the subscription request |
500 | Missing billing configuration, or the provider could not be reached or returned an invalid response |
subscriptionId of the incident.NUXT_LEMON_SQUEEZY_STORE_ID configures, or the Stripe account of NUXT_STRIPE_SECRET_KEY.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.
Find the original creation event of the subscription in the webhook deliveries of this deployment's webhook and resend it:
subscription_created in the Lemon Squeezy dashboard.customer.subscription.created in the Stripe Dashboard under Developers → Webhooks, on this deployment's endpoint in the mode of the API key.Do not use Simulate event or a test event: it carries no billing reference and is rejected with 400.
Vuesion processes the resent event like the first delivery:
X-Signature and NUXT_LEMON_SQUEEZY_WEBHOOK_SECRET, or Stripe-Signature and NUXT_STRIPE_WEBHOOK_SECRET). An event from another store, endpoint or mode fails with 401.meta.custom_data.reference; Stripe: the reference metadata of the subscription) 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.
The provider 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 at the provider, 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 provides no administrative action to create the subscription. Do not create it or change the workspace plan in the database; resolve the customer's billing at the provider.
The workspace no longer exists. The subscription cannot be associated with it, and it must not be assigned to another workspace. Investigate the subscription at the provider and resolve any ongoing billing obligation with its administrative tools, for example by cancelling or refunding it.
The subscription no longer exists at the provider. Reconciliation reports it as a missing_subscription incident, and the local subscription keeps its state. An admin ends it with End subscription in the incident details, which calls PATCH /api/admin/subscriptions/:id/end. The request re-checks the provider and returns 409 while the subscription still exists there. Otherwise the subscription becomes EXPIRED, the workspace falls back to the default plan, and the related incident is deleted.
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 at the provider 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).
The provider is unavailable. The webhook fails with 500 and the provider 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 a provider. Lemon Squeezy and Stripe each have the same set of files (Lemon Squeezy shown; Stripe uses use-stripe-billing-provider-service.ts, stripe-webhook.ts, stripe.post.ts and NUXT_STRIPE_*):
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 add another provider, follow the Stripe files (use-stripe-billing-provider-service.ts, stripe-webhook.ts, stripe.post.ts):
use-example-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() next to the existing providers. 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/example.post.ts, like stripe.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.nuxt.config.ts and the 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 → plan assigned to a workspace
WorkspaceTrial → temporary plan of a workspace
Entitlements → effective access of a workspace
Application code resolves entitlements through the Entitlement Service. It never reads subscriptions, trials or the assigned plan to decide what a workspace may do.
Continue with Internationalization to learn how Vuesion handles languages, translations, locale-aware routing, and user preferences.