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 | Traditional recurring payments via Stripe subscriptions only |
license | One-time license payments 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.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 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 Stripe fires charge.refunded for a license payment, applyChargeRefunded in core/billing/mutations.ts (license branch) runs the clawback:
- Look up the license by Stripe session / payment intent id (UNIQUE on
licenses.stripe_session_idis the idempotency gate). - Revoke the license —
statusmoves to'revoked'. - Deduct the previously granted
credits_includedviadecrement_creditswith source'refund'and metadata linking back to the license id. - 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.