Site

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
subscriptionRecurring payments through the provider selected during initialization
one_timeOne-time product purchases only (lifetime, yearly, monthly)
hybridBoth subscriptions and licenses available to users

License Types

Type Description Expires
lifetimePerpetual access, never expiresNever
yearly365-day access from purchase1 year
monthly30-day access from purchase30 days
customCustom duration defined per productConfigurable

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 licensesGET /api/admin/licensesGet all licenses with account info
Extend licensePATCH /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 licensePATCH /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.

  1. Validate the claimed webhook journal identity, provider environment, Account, payment transaction, currency, and adjustment cursor.
  2. Lock the Account, payment, license, and reversal rows in a stable order.
  3. Atomically mark the payment refunded, revoke the license, and recover the exact persisted credit grant without making the balance negative.
  4. 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.