The boilerplate includes a complete payment system powered by Stripe, supporting subscriptions, one-time credit pack purchases, and one-shot licenses. The source of truth is config/pricing.ts: Stripe Dashboard creates the prices, then you paste the resulting price_... IDs into the matching config slots.

Stripe Setup

Follow these steps to connect your Stripe account and configure payment processing:

1. Create Stripe Account

  1. Sign up at dashboard.stripe.com
  2. Complete your business profile
  3. Get your API keys from Developers → API Keys

2. Environment Variables

Add your Stripe keys to .env.local. You need three values: STRIPE_SECRET_KEY (server-side API key from Developers → API Keys), NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY (client-side key for Checkout), and STRIPE_WEBHOOK_SECRET (signing secret generated when you create your webhook endpoint). Use test mode keys during development and switch to live keys for production.

3. Create Products in Stripe

Create your subscription plans, credit packs, and license products in Stripe Dashboard. Product names are for Stripe dashboard clarity; the app uses the copied Stripe Price IDs.

Product Type Stripe Settings Example
Subscription Plan Recurring price, monthly/yearly interval Pro Plan - €29/month
Credit Pack One-time price 2,000,000 credits (≈ 2M tokens) — €18 (1 credit = 1 LLM token)
Include license products when using license or hybrid billing

If billingModel is 'license' or 'hybrid', also create one-time Stripe prices for the entries in pricingConfig.licenses such as pro-lifetime, pro-yearly, business-lifetime, and business-yearly. Paste those Price IDs into the matching stripePriceIds['license-id'] slots.

4. Configure Webhook

Set up a webhook endpoint in Stripe Dashboard → Developers → Webhooks:

Endpoint URL: https://yourdomain.com/api/stripe/webhook

Events to listen for:
# Checkout Events
├── checkout.session.completed              # Process purchases (subscriptions, credit packs, licenses)
├── checkout.session.async_payment_succeeded # Async payment completed (SEPA, bank transfer)
├── checkout.session.async_payment_failed   # Async payment failed - revoke credits
├── checkout.session.expired                # Abandoned checkout tracking
# Subscription Events
├── customer.subscription.created           # New subscription created
├── customer.subscription.updated           # Plan changes, status updates
├── customer.subscription.deleted           # Subscription canceled
├── customer.subscription.paused            # Subscription paused
├── customer.subscription.resumed           # Subscription resumed after pause
├── customer.subscription.trial_will_end    # Trial ending notification (3 days before)
# Invoice Events
├── invoice.paid                            # Successful payment / monthly credit refill
├── invoice.payment_failed                  # Failed payment (triggers grace period)
├── invoice.payment_action_required         # SCA/3D Secure authentication required
# Charge & Dispute Events
├── charge.refunded                         # Refund processed (deducts credits/revokes licenses)
├── charge.dispute.created                  # Chargeback created (CRITICAL - immediate action)
├── charge.dispute.closed                   # Dispute resolved (won/lost)
# Customer Events
├── customer.created                        # Link Stripe customer to account
├── customer.updated                        # Sync customer data changes
├── customer.deleted                        # Clean up Stripe references
# Payment Method Events
├── payment_method.attached                 # New payment method added
└── payment_method.detached                 # Payment method removed (churn signal)
Local Development: Use Stripe CLI to forward webhooks locally: stripe listen --forward-to localhost:3777/api/stripe/webhook. The command prints a whsec_… signing secret — copy it into STRIPE_WEBHOOK_SECRET (it differs from the Dashboard webhook secret used in production).

Testing with Stripe test cards

In test mode, use these card numbers with any future expiry, any CVC, and any postal code:

ScenarioCard number
Successful payment4242 4242 4242 4242
Requires 3D Secure / SCA4000 0027 6000 3184
Generic decline4000 0000 0000 0002
Insufficient funds4000 0000 0000 9995

Drive webhook handlers directly without going through Checkout:

stripe trigger checkout.session.completed
stripe trigger customer.subscription.created
stripe trigger invoice.paid
stripe trigger charge.refunded

5. Configure Price IDs

Add your Stripe Price IDs to the stripePriceIds object at the top of config/pricing.ts. With multi-currency support, you need separate Price IDs for each currency. With test/live separation, you need separate IDs for each Stripe environment.

// config/pricing.ts
const stripePriceIds = {
  pro: {
    monthly: {
      EUR: { dev: 'price_test_pro_monthly_eur', prod: 'price_live_pro_monthly_eur' },
      USD: { dev: 'price_test_pro_monthly_usd', prod: 'price_live_pro_monthly_usd' },
      GBP: { dev: '', prod: '' },
    },
    yearly: {
      EUR: { dev: 'price_test_pro_yearly_eur', prod: 'price_live_pro_yearly_eur' },
      USD: { dev: '', prod: '' },
      GBP: { dev: '', prod: '' },
    },
  },
  'pack-2000': {
    EUR: { dev: 'price_test_pack_2000_eur', prod: 'price_live_pack_2000_eur' },
    USD: { dev: '', prod: '' },
    GBP: { dev: '', prod: '' },
  },
  'pro-lifetime': {
    EUR: { dev: 'price_test_license_pro_lifetime_eur', prod: 'price_live_license_pro_lifetime_eur' },
    USD: { dev: '', prod: '' },
    GBP: { dev: '', prod: '' },
  },
}
  1. In Stripe Dashboard, switch to Test mode.
  2. Create every recurring price, one-time credit-pack price, and one-time license price you want to test.
  3. Copy each price_... value into the matching dev slot in config/pricing.ts.
  4. Run npm run dev, complete a test checkout, and confirm the webhook grants credits.
  5. Before production, switch Stripe Dashboard to Live mode, create the same prices again, and paste those IDs into the matching prod slots.
Multi-Currency Setup

For each plan/credit pack, create separate Stripe prices for each currency you support. The localeCurrencyMap in your config determines which currency to use based on the user's locale.

Common Stripe configuration mistakes
  • Do not paste prod_... product IDs; Checkout needs price_... IDs.
  • Do not paste test Price IDs into prod slots or live Price IDs into dev slots.
  • A paid plan with an empty stripePriceId cannot create a paid Checkout Session. Free plans intentionally use amount 0 and no Stripe Price ID.
  • If PayPal or another method does not appear in Checkout, activate it in Stripe Dashboard and check pricingConfig.checkoutPaymentMethods.

Subscriptions

Subscriptions are managed through Stripe with automatic synchronization to your database.

Subscription Flow

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  User clicks    │     │  Stripe hosted  │     │  Webhook fires  │
│  "Subscribe"    │────▶│  checkout page  │────▶│  on completion  │
└─────────────────┘     └─────────────────┘     └─────────────────┘
                                                         │
                                                         ▼
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  User gets      │     │  Credits added  │     │  Subscription   │
│  access         │◀────│  to account     │◀────│  saved to DB    │
└─────────────────┘     └─────────────────┘     └─────────────────┘

Creating a Checkout Session

POST /api/billing/checkout validates the requested priceId against pricingConfig via isKnownStripePriceId (tampered cookies are inert), creates a locale-aware Stripe Checkout Session in subscription mode (or payment mode for credit packs) with payment_method_types from pricingConfig.checkoutPaymentMethods, clears the pending-checkout cookie via a Server Action, then redirects to Stripe. The subscription itself is created on the customer.subscription.created webhook, not inline.

Promotion Codes

Stripe promotion/coupon codes can be enabled or disabled at checkout via the allowPromotionCodes flag in config/pricing.ts. When set to true, Stripe displays a "Add promotion code" input field on the checkout page for both subscription and one-time payment (license/credit pack) sessions. Promotion codes must be created in the Stripe Dashboard. Set to false to hide the field.

Webhook Handler

The webhook entrypoint at app/api/stripe/webhook/route.ts receives Stripe events, enforces the raw-body size guard, verifies the signature using STRIPE_WEBHOOK_SECRET, and delegates dispatch to core/billing/stripe-webhook.ts. Event effects are split by concern: core/billing/mutations.ts owns subscription, invoice, checkout, refund, license, trial, and async-payment-success effects; core/billing/customer-events.ts owns customer/payment-method audit events, payment-action-required, and checkout-expired notifications; core/billing/risk-events.ts owns dispute/lost-payment clawbacks; core/billing/free-plan.ts owns direct free-plan subscription.

Plan Configuration (Multi-Currency)

Plans are defined in config/pricing.ts with multi-currency and translation support. Each plan specifies an ID, translation keys for the name and description, included credits, feature list, limits, optional trial settings, and pricing tiers with Stripe Price IDs for each currency and billing interval (monthly/yearly). The resolvePricingConfig() function translates plan copy and derives pricing-table values from config, including credits.included, limits.projects, trialDays, and defaultTrialCredits.

Free Plan Subscription

Plans without a Stripe price ID bypass the Stripe checkout entirely. When a user selects a free plan, the /api/billing/subscribe-free endpoint delegates to core/billing/free-plan.ts, validates the selected plan against config/pricing.ts, resolves the correct personal/workspace account, creates or updates the free subscription row, and grants included credits through the add_credits RPC. This avoids unnecessary Stripe API calls for zero-cost plans while keeping the same account-centric billing rules as paid plans.

Stripe Customer Portal

The Stripe Customer Portal lets users manage their subscription, update payment methods, view invoices, and cancel or upgrade their plan. The /api/billing/portal endpoint creates a portal session with the user's Stripe customer ID and a return URL pointing back to the dashboard. The portal is fully hosted by Stripe, requiring no custom UI for billing management.

Checkout-before-onboarding workflow

The pricing-to-dashboard funnel runs payment before profile collection — same flow whether the visitor is signing up or already authenticated, B2B or B2C, paid or free.

/pricing → click plan
   │  └─ bsk_pending_checkout cookie set (httpOnly, Secure, SameSite=Lax, 30min, Zod-validated)
   ▼
/login (skipped if already authed)
   │  └─ DB trigger creates personal account
   │  └─ B2B: ensureWorkspaceForUser auto-creates workspace
   ▼
/checkout
   │  └─ Server Component reads the cookie
   │  └─ Client posts to /api/billing/checkout (or /api/billing/subscribe-free for free plans)
   ▼
Stripe payment (or direct insert for free plans)
   ▼
/checkout/success
   │  └─ Server Component reads profile.onboarding_completed
   │  ├─ !onboarded → /onboarding → dashboard
   │  └─ onboarded  → dashboard (org or private per workspace-manager rule)

Key invariants:

  • bsk_pending_checkout is httpOnly + Secure + SameSite=Lax + 30min TTL. Its payload (plan type, planId, productId, interval, priceId, currency, creditsAmount, locale, isFree) is Zod-validated server-side via pendingCheckoutCookieSchema in lib/checkout/pending-checkout-cookie.ts.
  • Tampering is inert — /api/billing/checkout validates the priceId against pricingConfig via isKnownStripePriceId; /api/billing/subscribe-free validates planId exists in config AND price.amount === 0. The cookie only hints which plan to resume — it never grants entitlements.
  • /checkout is intentionally not in proxy.ts:protectedRoutes so the middleware onboarding gate does not fire for it. Un-onboarded users can complete payment.
  • /api/billing/subscribe-free redirects through /checkout/success (not directly to the dashboard), so free plans hit the same onboarding gate as paid plans.
Single onboarding gate — do not duplicate (anti-pattern A13)

The one place that decides "onboarding or dashboard?" is app/[locale]/(auth)/checkout/success/page.tsx. Re-introducing an onboarding_completed check in middleware, Client Components, or any other Route Handler breaks OAuth signups (loops them back to /onboarding before reaching Stripe). Trust the single gate.

End trial early

Trialing subscriptions can be converted to paid mid-trial via POST /api/billing/end-trial (Zod body { accountId }, CSRF + rate limit via apiSecurity.authenticated(), billing-manager role gate). The route delegates to endTrialEarly(accountId) in core/billing/mutations.ts, which executes in two steps:

  1. Stripe API: stripe.subscriptions.update(subId, { trial_end: 'now' }). This ends the trial and triggers a conversion invoice.
  2. Synchronous credit grant: the mutation immediately writes a sentinel payments row with stripe_session_id = end_trial_${stripeSubId} and calls add_credits for creditsIncluded - trialAmountAlreadyGranted — the same math applyInvoicePaid uses for the first paid invoice. The user sees their credits the moment the dialog closes, no waiting on the webhook.
Do not pass proration_behavior: 'none'

That option suppresses the conversion invoice entirely — which means invoice.paid never fires and credits never arrive. The original implementation had this bug; it is now fenced off with an inline comment in endTrialEarly.

Idempotency between the sync grant and the webhook: applyInvoicePaid looks for the end_trial_${stripeSubId} sentinel row when a first invoice arrives. If it exists AND priorRefillCount <= 1 (this is the first invoice for the subscription), the webhook sets refillAmount = 0 and skips the RPC — the sync grant + the eventual webhook never double-credit. Subsequent monthly invoices (count > 1) ignore the sentinel and refill normally.

Why the sync grant matters: it covers local dev without stripe listen, customers without a saved payment method (Stripe creates the invoice but invoice.paid never fires), and removes the multi-second delay between the click and visible credits even when everything works.

Typed result codes returned by the mutation: NO_SUBSCRIPTION (404), NO_TRIAL (409), STRIPE_ERROR (502 — typically a missing payment method).

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, custom)
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 } and PayPal allowed via pricingConfig.checkoutPaymentMethods.payment. On checkout.session.completed the webhook creates the licenses row, 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 }
Revoke licensePATCH /api/admin/licenses{ action: 'revoke' }

Credits System

Credits are the internal currency for AI usage with a deliberately simple model — 1 credit = 1 LLM token. Balances live on accounts.credits_balance with a full audit trail in credit_transactions; every change goes through the atomic add_credits / decrement_credits RPCs (never direct UPDATE). A pre-flight check rejects requests below aiConfig.minCreditsRequired before the LLM call, and the DB CHECK (credits_balance >= 0) constraint is the last-resort safety net for concurrent races.

For the full architecture (sources/usage diagram, atomic RPC contract, credit-pack purchase flow, transaction history), see Credits System.

Referral System

Account-centric referral program — each account owns one active short code and both sides earn credits when the configured trigger fires. Refunds and lost chargebacks automatically claw credits back via decrement_credits. The system is feature-gated via REFERRAL_ENABLED (server-only) and surfaces as 404 when disabled. Configuration lives in config/referral.ts; all writes go through SECURITY DEFINER RPCs.

For the full configuration table, attribution flow, RPC contracts, webhook hooks, API surface, and security invariants, see Referral System.

Affiliation Program

Account-centric partner program for marketers and content creators. Distinct from referrals — cash commissions paid via Stripe Connect (not in-app credits), application + admin approval required, and tier-based commission models (recurring monthly or one-time upfront). Feature-gated via AFFILIATES_ENABLED (server-only) with AFFILIATES_SALT for IP/UA hashing; surfaces as 404 when disabled. Configuration lives in config/affiliates.ts; all writes go through SECURITY DEFINER RPCs and affiliate failures never break billing.

For the tiers/commission table, attribution flow, database schema, RPC catalog, webhook hooks, admin console, jobs, API surface, and security invariants, see Affiliation Program.