Billing & Subscriptions

Plan prices, subscriptions and the billing provider assign plans to workspaces. Entitlements stay in Plans & Entitlements.

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,
  • expires an ended trial, converts an active trial when the subscription grants a paid plan, and assigns the workspace plan,
  • handles the change of the effective plan (see Effective plan changes) and, after the commit, sends the subscription emails.

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 the effective plan (see Effective entitlements), no matter how the plan was assigned. Billing only decides which plan is assigned, 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
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.

Billing provider

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:

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 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:

  • 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.

The Stripe implementation calls the Stripe API with fetch (no SDK) and:

  • creates a Checkout Session in subscription mode with the checkout reference as subscription metadata (reference); every checkout expires after 23 hours,
  • maps the statuses: 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,
  • reads currentPeriodEnd from the first subscription item and uses ended_at for ended subscriptions,
  • changes plans with create_prorations; a failed payment rejects the change,
  • cancels and resumes through cancel_at_period_end,
  • creates a customer portal session on demand,
  • lists all subscriptions of the Stripe account, 100 per page,
  • never returns PORTAL_REQUIRED; only Lemon Squeezy does.

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 workspace owner.

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 }
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):

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 provider's update webhook repairs the local state.

Webhooks

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():

  • 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; if the provider no longer has it (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.
  • 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 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

  • 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 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.

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. 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.

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 at the provider (the configured Lemon Squeezy store or the Stripe account) and is not EXPIRED there. <status> is the mapped Vuesion status.
  • No local subscription has its External ID, in any status.
  • Its variant (Stripe: price) 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 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:

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 the provider rejected the subscription request
500Missing billing configuration, or the provider 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, or the Stripe account of NUXT_STRIPE_SECRET_KEY.
  • Switch to the mode of the deployment's API key: Test Mode or Live Mode (Stripe: test 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 (Stripe: price) is the External ID of a plan price in Platform → Plans.
  • Check whether another installation, for example a staging deployment, uses the same store or Stripe account 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

Find the original creation event of the subscription in the webhook deliveries of this deployment's webhook and resend it:

  • Lemon Squeezy: resend subscription_created in the Lemon Squeezy dashboard.
  • Stripe: resend 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:

  • The signature must match the webhook secret (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.
  • The billing reference (Lemon Squeezy: 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.
  • 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 provider API and synchronizes it. The state in the event payload is ignored, so resending an old event cannot restore an outdated status.
  • The variant (Stripe: price) 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.

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.

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 at the provider, 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 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.

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.

Adding a billing provider

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_*):

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 add another provider, follow the Stripe files (use-stripe-billing-provider-service.ts, stripe-webhook.ts, stripe.post.ts):

  • Implement the provider service, e.g. 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.
  • Select it in useBillingProviderService() next to the existing providers. 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/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.
  • Add the configuration: the keys in the runtime config of nuxt.config.ts and the 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).
  • Add specs for the new files and update Development → Configuration. To drop a provider, see Remove one provider.

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   → 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.

Next steps

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