License System (One-Time Payments)
As an alternative to subscriptions, you can sell licenses for one-time payment access. Configure via billingModel in config/app.ts.
Billing Models
| Model | Description |
|---|---|
subscription | Recurring payments through the provider selected during initialization |
one_time | One-time product purchases only (lifetime, yearly, monthly) |
hybrid | Both subscriptions and licenses available to users |
License Types
| Type | Description | Expires |
|---|---|---|
lifetime | Perpetual access, never expires | Never |
yearly | 365-day access from purchase | 1 year |
monthly | 30-day access from purchase | 30 days |
custom | Custom duration defined per product | Configurable |
License Configuration
License products are declared in config/pricing.ts under pricingConfig.products (the source of truth — never in the database). Each product sets a licenseType (lifetime | yearly | monthly | custom), per-currency pricesByCurrency with provider catalogue bindings, the one-time credits.oneTime granted on purchase, and optional limits / feature keys. Non-lifetime types use validityDays to compute expiration.
products: [
{
id: 'pro-lifetime',
nameKey: 'pricing.products.proLifetime.name',
descriptionKey: 'pricing.products.proLifetime.description',
licenseType: 'lifetime',
validityDays: null,
pricesByCurrency: {
EUR: { price: 299, providers: getProviderCatalogBindings('pro-lifetime', 'EUR') },
USD: { price: 329, providers: getProviderCatalogBindings('pro-lifetime', 'USD') },
},
credits: { oneTime: 2500000 },
limits: { projects: 10 },
featureKeys: [{ nameKey: 'pricing.features.apiAccess', included: true }],
},
{ id: 'pro-yearly', licenseType: 'yearly', validityDays: 365, /* ... */ },
]License Checkout Flow
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ User clicks │ │ Provider-hosted │ │ Webhook fires │
│ "Buy License" │────▶│ checkout page │────▶│ on completion │
└─────────────────┘ └─────────────────┘ └─────────────────┘
(mode: 'payment') │
▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ User gets │ │ Credits added │ │ License record │
│ access │◀────│ to account │◀────│ created in DB │
└─────────────────┘ └─────────────────┘ └─────────────────┘Creating License Checkout
POST /api/billing/license-checkout accepts only internal offer, currency, locale and legal-consent fields. The server selects PAYMENTS_PROVIDER, resolves the external catalogue binding and sends a signed immutable quote to its adapter; neither the UI nor the request body can choose the provider. A signed paid webhook creates the normalized payment, license and one-time credit grant. Provider + environment checkout uniqueness and payment_proof_key gate payment replay; the license grant retains its own semantic idempotency key.
Guest License Checkout
When GUEST_CHECKOUT_ENABLED is on, POST /api/billing/guest-checkout opens a one-time license checkout without creating a local Account or customer first. Stripe and Lemon Squeezy are currently guest-capable; Paddle stays unavailable until its checkout can enforce the same expiry as the signed proof. After the signed paid event is durably journaled, the worker retrieves the canonical transaction and an email matching the proof's keyed commitment. One database transaction then creates the guest Account, payment, license, credits, single-use claim and idempotent email outbox row. An exact webhook replay reuses the same purchase; conflicting payment, legal or entitlement facts fail closed.
The email link first exchanges its one-time bearer for a short-lived, path-scoped HttpOnly cookie and redirects to a clean localized URL. Claim and order-history tokens are never serialized into page HTML, React props or JSON action bodies. Delivery success, terminal failure, claim, replacement and expiry remove the raw token from the durable outbox; expired order-lookup rows are purged in bounded batches.
Access Control
Use the unified checkAccountAccess() function to check both subscriptions and licenses in one call. In hybrid billing mode an active subscription wins over a license:
import { checkAccountAccess } from '@/lib/billing/access'
const access = await checkAccountAccess(accountId)
// {
// hasAccess: boolean,
// source: 'subscription' | 'license',
// plan?: PlanConfig,
// license?: LicenseWithProduct,
// expires_at?: string,
// daysRemaining?: number,
// }License Expiration Job
The check-license-expiration job handler batch-marks expired licenses (markExpiredLicenses()) and sends warning emails at 7, 3, and 1 days before expiry. It is seeded by pnpm run init when billingModel is one_time or hybrid; register it with a daily cron (e.g. 0 2 * * *) from /admin-dashboard/jobs if you enable licensing after init.
Admin License Management
Admins can view, extend, and revoke licenses from /admin-dashboard/licenses:
| Action | Endpoint | Description |
|---|---|---|
| List licenses | GET /api/admin/licenses | Get all licenses with account info |
| Extend license | PATCH /api/admin/licenses | { action: 'extend', days: 30 } — not allowed for lifetime. extendLicense() in core/licenses/mutations.ts throws CANNOT_EXTEND_LIFETIME when expires_at IS NULL. |
| Revoke license | PATCH /api/admin/licenses | { action: 'revoke' } |
Refund Flow
When the configured provider confirms a full refund, its adapter sends canonical adjustment facts to apply_provider_adjustment_state. For Stripe, charge.refunded first causes the worker to retrieve the current Charge and use its cumulative amount_refunded.
- Validate the claimed webhook journal identity, provider environment, Account, payment transaction, currency, and adjustment cursor.
- Lock the Account, payment, license, and reversal rows in a stable order.
- Atomically mark the payment refunded, revoke the license, and recover the exact persisted credit grant without making the balance negative.
- Reverse the payment-backed referral and unpaid affiliate conversion in the same transaction; paid commissions become an operator-visible reconciliation issue.
The same atomic path runs when a dispute is confirmed lost. A won or prevented dispute reverses the risk state, compensates only credits actually recovered, and restores the license when no refund or other active risk still requires revocation.