GenerateSaaS

Billing & Payments

Sell subscriptions, one-time products, and credits through one provider-agnostic billing layer where every balance and plan belongs to a user.

Billing lives in @repo/payments, is gated by config.payment in packages/config/src/index.ts, and is wired into auth through the Better Auth billing plugin. One provider-agnostic layer covers recurring subscriptions, one-time products, and prepaid credits, so checkout, webhooks, and entitlement checks stay identical whether you run Stripe or Polar.

Money always lives on a user. There is no organization balance, plan, or customer record. When an organization is active its owner is the payer, so members share one funded workspace; otherwise the session user pays for themselves. Billing(session) resolves it - see Organizations.

The flag

config.payment is a discriminated union: { enabled: false } or { enabled: true; provider; bannedCountries? }.

KeyTypeDefaultDescription
enabledbooleantrueMaster switch for the billing surface.
provider"stripe" | "polar""stripe"Selected at build time; inactive provider code is bundler-pruned.
bannedCountriesstring[]["BY","CU","IR","KP","RU","SY"]ISO codes blocked from checkout.

With enabled: false, createBillingPlugins() returns [], the provider barrel resolves to a no-op that throws on any charge, and checkout plus the dashboard credit UI disappear.

Disabling billing does not hide the landing page's pricing section - plan cards always render from pricingConfig. Drop <Pricing> from app/[locale]/(marketing)/page.tsx if you sell nothing.

Choosing a provider

  • Stripe for the broadest ecosystem and the lowest fees when you handle your own tax. Auto top-up and chargeback suspension are Stripe-only.
  • Polar when you want a merchant of record to take on VAT/sales-tax remittance and disputes for you.

Capabilities

Webhooks and checkout guards

Webhooks reconcile subscription, payment, and credit state: the active provider owns one endpoint and one signing secret, and nothing reconciles until you register it. authorizeReference runs server-side before any charge:

GuardBehavior
Banned countriesReject checkout when the CDN country header (cf-ipcountry, x-vercel-ip-country, cloudfront-viewer-country) or the user-profile country is in bannedCountries.
Lifetime plansBlock new subscription checkouts for anyone already on a lifetime plan.
Payer identityThe billing reference must equal the caller's own user id (canUserPurchase). Inside an organization the reference is the owner, so only the owner can buy.

Read entitlements server-side through Billing(...) and the Better Auth billing plugin - the active subscription, owned products, or credit balance - never from client state.

On localhost the provider cannot reach your webhook endpoint, so a test checkout reaches the success page but grants nothing until the event arrives. Forward webhooks in dev with stripe listen --forward-to localhost:3000/api/auth/stripe/webhook (set STRIPE_WEBHOOK_SECRET to the secret it prints). Polar has an equivalent CLI forwarder.

There is no organization-billing mode to turn on: funding follows the owner, so transferring the organization is the only way to change who pays. Providers retry webhooks for days, so a checkout started before per-organization billing was removed can still deliver an event carrying organization metadata; getBillingFromMetadata resolves that organization to its owner and credits the payment there, so no in-flight payment is lost.

Next steps

On this page