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
- Sign up at dashboard.stripe.com
- Complete your business profile
- 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) |
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)
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:
| Scenario | Card number |
|---|---|
| Successful payment | 4242 4242 4242 4242 |
| Requires 3D Secure / SCA | 4000 0027 6000 3184 |
| Generic decline | 4000 0000 0000 0002 |
| Insufficient funds | 4000 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.refunded5. 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: '' },
},
}- In Stripe Dashboard, switch to Test mode.
- Create every recurring price, one-time credit-pack price, and one-time license price you want to test.
- Copy each
price_...value into the matchingdevslot inconfig/pricing.ts. - Run
npm run dev, complete a test checkout, and confirm the webhook grants credits. - Before production, switch Stripe Dashboard to Live mode, create the same prices again, and paste those IDs into the matching
prodslots.
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.
- Do not paste
prod_...product IDs; Checkout needsprice_...IDs. - Do not paste test Price IDs into
prodslots or live Price IDs intodevslots. - A paid plan with an empty
stripePriceIdcannot create a paid Checkout Session. Free plans intentionally use amount0and 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_checkoutis httpOnly + Secure + SameSite=Lax + 30min TTL. Its payload (plan type, planId, productId, interval, priceId, currency, creditsAmount, locale, isFree) is Zod-validated server-side viapendingCheckoutCookieSchemainlib/checkout/pending-checkout-cookie.ts.- Tampering is inert —
/api/billing/checkoutvalidates thepriceIdagainstpricingConfigviaisKnownStripePriceId;/api/billing/subscribe-freevalidatesplanIdexists in config ANDprice.amount === 0. The cookie only hints which plan to resume — it never grants entitlements. /checkoutis intentionally not inproxy.ts:protectedRoutesso the middleware onboarding gate does not fire for it. Un-onboarded users can complete payment./api/billing/subscribe-freeredirects through/checkout/success(not directly to the dashboard), so free plans hit the same onboarding gate as paid plans.
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:
- Stripe API:
stripe.subscriptions.update(subId, { trial_end: 'now' }). This ends the trial and triggers a conversion invoice. - Synchronous credit grant: the mutation immediately writes a sentinel
paymentsrow withstripe_session_id = end_trial_${stripeSubId}and callsadd_creditsforcreditsIncluded - trialAmountAlreadyGranted— the same mathapplyInvoicePaiduses for the first paid invoice. The user sees their credits the moment the dialog closes, no waiting on the webhook.
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 |
|---|---|
subscription | Traditional recurring payments via Stripe subscriptions only |
license | One-time license payments only (lifetime, yearly, monthly, custom) |
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 } 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 licenses | GET /api/admin/licenses | Get all licenses with account info |
| Extend license | PATCH /api/admin/licenses | { action: 'extend', days: 30 } |
| Revoke license | PATCH /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.