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.
One subscriptionA new Stripe subscription checkout is refused while the customer holds any subscription that can still bill, under one lock per customer. Also turn on Limit customers to one subscription in Stripe's Checkout settings (Dashboard > Settings > Checkout and Payment Links) as a Stripe-side backstop.

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

Every grant is idempotent per event: withWebhookClaim takes a short lease on the delivery and completes it in the same database transaction as the grant's ledger row. A process killed in between (a redeploy, an OOM) leaves the lease to expire after 2 minutes, and the provider's retry grants exactly once; a duplicate that arrives while the lease is live gets a non-2xx so the provider retries it later.

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.

Stripe plan changes

Plan changes start only on your in-app pricing page, and the app applies them itself - never through a portal confirm page, which the customer could complete later with a stale price.

ChangeWhat happens
UpgradeThe shipped UI first shows an in-app dialog with the exact amount due now (Stripe's own invoice preview) and the new recurring price. On confirm, the switch is re-checked under a per-customer lock and invoiced immediately at the previewed proration date, so the charge matches the preview. The server does not require that preview: a POST /auth/subscription/upgrade without prorationDate (a custom client, a direct call) is charged the proration as of the moment it applies. A downgrade the app scheduled earlier is released once the upgrade is paid, so the period end never moves the customer back.
3-D Secure or declined cardThe plan stays unchanged and the customer is sent to Stripe's hosted invoice page to finish the payment. The upgrade applies when that invoice is paid; unpaid, Stripe discards it after about 23 hours. The billing card shows "Upgrade awaiting payment" with a link to finish it, and no other plan change is accepted meanwhile. With a downgrade already scheduled, the upgrade is refused instead, keeping that downgrade: the customer keeps their current plan first, then upgrades.
Downgrade, shorter interval, or any switch Stripe's prices cannot confirmThe dialog says when it takes effect; nothing is charged. It waits for the period end through a Stripe subscription schedule, so it works across products.

Portal sessions open on a configuration the app creates in your Stripe account (metadata.managedBy: app-billing-portal) with plan switching off (any plan-switching configuration an earlier version made is deactivated); your dashboard default stays untouched, and if Stripe refuses to create it (a restricted API key) the portal stays closed. Upgrade credits follow only the paid invoice, so enable invoice.paid in your Stripe webhook - see Plan grants.

Account deletion

Deleting an account cancels its billing BEFORE any data goes, through ops.cancelSubscriptionsForAccountDeletion on the active provider.

RuleBehavior
Cancel nowEvery subscription that can still bill - the user's own, those of the organizations the deletion removes, and any the user's card pays for an organization other owners keep - is cancelled immediately, with no refund of the current period (Stripe cancel without proration, Polar revoke).
Unpaid balance refusesA subscription that is past due or unpaid, or (Stripe) an open invoice with money still owed, refuses the deletion with ACCOUNT_DELETION_OUTSTANDING_BALANCE before anything is cancelled - an immediate cancel would stop the provider collecting it. The user settles it in Billing or contacts support. Polar exposes no open-invoice balance, so its check is the subscription status alone.
Refuse on failureIf any cancellation or lookup fails, the deletion is refused with ACCOUNT_DELETION_BILLING and nothing is deleted; the same link retries.
IdempotentA refused cancel of a subscription the provider already ended - or no longer knows - counts as ended, and an abandoned checkout (a local row with no subscription id) is settled by its customer's list, so neither blocks a retry. Any other error still refuses.
CreditsThe balance is deleted with the account, never refunded.
Late webhooksEvents for the deleted customer are no-ops: getBillingFromMetadata answers null for an account that is gone, so no plan is cleared, nothing is recreated and no delivery fails.

App Store and Google Play subscriptions cannot be cancelled from the server; the user cancels them in their store settings, and the deletion copy says so in a build with store billing. The provider's customer record itself is kept, for tax and accounting. A member joining a sole-member organization between the cancel and the delete keeps an organization whose plan has ended - the buyer loses nothing, and the owner subscribes again.

Next steps

On this page