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
subscriptionTraditional recurring payments via Stripe subscriptions only
licenseOne-time license payments 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.licenses (the source of truth — never in the database). Each product sets a type (lifetime | yearly | monthly | custom), per-currency pricesByCurrency with a Stripe Price ID, the one-time credits.oneTime granted on purchase, and optional limits / feature keys. Non-lifetime types use validityDays to compute expiration.

licenses: [
  {
    id: 'pro-lifetime',
    nameKey: 'pricing.licenses.proLifetime.name',
    descriptionKey: 'pricing.licenses.proLifetime.description',
    type: 'lifetime',
    validityDays: null,
    pricesByCurrency: {
      EUR: { price: 299, stripePriceId: getPriceId('pro-lifetime', 'EUR') },
      USD: { price: 329, stripePriceId: getPriceId('pro-lifetime', 'USD') },
    },
    credits: { oneTime: 2500000 },
    limits: { projects: 10 },
    featureKeys: [{ nameKey: 'pricing.features.apiAccess', included: true }],
  },
  { id: 'pro-yearly', type: 'yearly', validityDays: 365, /* ... */ },
]

License Checkout Flow

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  User clicks    │     │  Stripe 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 creates a Stripe Checkout Session in payment mode (one-shot, not recurring) with metadata { type: 'license', product_id, account_id, user_id } and PayPal allowed via pricingConfig.checkoutPaymentMethods.payment. The user_id is required — the checkout.session.completed handler ignores any session missing it. On checkout.session.completed the webhook creates the licenses row (recording purchased_by from user_id), grants the product's one-time credits via grant_license_credits, and writes a payments record (the stripe_session_id UNIQUE constraint is the idempotency gate).

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 npm run init when billingModel is license 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 Stripe fires charge.refunded for a license payment, applyChargeRefunded in core/billing/mutations.ts (license branch) runs the clawback:

  1. Look up the license by Stripe session / payment intent id (UNIQUE on licenses.stripe_session_id is the idempotency gate).
  2. Revoke the license — status moves to 'revoked'.
  3. Deduct the previously granted credits_included via decrement_credits with source 'refund' and metadata linking back to the license id.
  4. Fire affiliate and referral reversal hooks (try/catch-wrapped — failures here never block the billing path).

The same path runs on a lost charge.dispute.closed event. Chargeback responses should be authored quickly — once Stripe flips the dispute to lost, the license is revoked and the credits are reversed.