Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

Billing

Billing

What it does

Three-tier recurring subscriptions (Free / Pro / Business, monthly or annual) via Stripe, driven entirely by @better-auth/stripe: checkout, a self-serve billing portal, invoices, promo codes, optional free trials, optional automatic tax, webhook idempotency, and a payment-failure (dunning) banner.

How to configure it

VarPurpose
STRIPE_SECRET_KEYServer-side Stripe API calls
STRIPE_WEBHOOK_SECRETVerifies webhook signatures
STRIPE_PRO_MONTHLY_PRICE_ID / STRIPE_PRO_ANNUAL_PRICE_IDPro tier Stripe Price ids
STRIPE_BUSINESS_MONTHLY_PRICE_ID / STRIPE_BUSINESS_ANNUAL_PRICE_IDBusiness tier Stripe Price ids
STRIPE_TRIAL_DAYSOptional. Days of free trial before the first charge, applied to every paid plan. Unset = no trial (charges immediately), matching pre-4.2 behavior.
STRIPE_TAX_ENABLEDOptional. "true" turns on Stripe Tax (automatic_tax) at checkout. Needs a Stripe Tax configuration in the dashboard to actually collect correctly — leave unset until you've set that up.

Create the Products/Prices in the Stripe dashboard first, then paste the Price ids in. For local webhook delivery:

stripe listen --forward-to localhost:3000/api/auth/stripe/webhook

Copy the printed signing secret into STRIPE_WEBHOOK_SECRET. Test card: 4242 4242 4242 4242, any future expiry/CVC. To exercise the dunning banner locally, use Stripe's 4000000000000341 test card (attaches successfully, then fails on the first charge) or trigger customer.subscription.updated with status: "past_due" via stripe trigger.

Without real keys, the app still builds and runs — lib/stripe.ts falls back to a placeholder Stripe key, and checkout/portal/invoice actions will just error at click-time instead of at boot (lib/billing/invoices.ts in particular returns an empty list rather than throwing — see its isStripeConfigured check).

How it works

  • lib/billing/plans.ts — the single source of truth for tiers: id, display price, Stripe price ids, feature list, highlighted flag. Both lib/auth.ts (Stripe plugin config) and the UI read from here — never hardcode a plan elsewhere.
  • lib/stripe.ts — the shared Stripe SDK client (stripeClient) and an isStripeConfigured flag. Used by both lib/auth.ts (the plugin config) and anything calling the Stripe API directly outside a plugin hook, so there's exactly one place that constructs the client.
  • lib/auth.ts — maps PAID_PLANS into the @better-auth/stripe plugin's subscription.plans config ({name, priceId, annualDiscountPriceId, freeTrial?}), and sets getCheckoutSessionParams to always pass allow_promotion_codes: true (and automatic_tax when STRIPE_TAX_ENABLED). The plugin owns checkout session creation, the webhook handler, and writing the subscription table.
  • lib/db/schema/auth.ts — the subscription table (plugin-managed: referenceId = user id, plan = plan id, status, periodEnd, trialEnd, etc.). Don't write to it directly outside the plugin.
  • lib/db/schema/app.ts — stripeEvents (id = Stripe event id, type, processedAt), the webhook idempotency ledger (see below).
  • lib/billing/idempotency.ts — claimStripeEvent(eventId, eventType): inserts into stripeEvents, onConflictDoNothing; returns true the first time an event id is seen, false on every repeat. lib/auth.ts's onSubscriptionComplete/onSubscriptionCancel hooks call this first and bail out (skip re-sending their email) when it returns false — guards against Stripe retries or a stripe listen redelivery double-sending a "you're subscribed"/"you're canceled" email. It does not guard the plugin's own subscription row sync — that's already a safe upsert.
  • lib/billing/access.ts — getAccess(userId) reads the most recent active/trialing/past_due/unpaid subscription row and returns {status, tier, plan, isSubscribed, isPaid, trialEndsAt}, falling back to the free tier if there's no row (or only canceled/incomplete ones). status: "past_due" covers both Stripe's past_due and unpaid — isSubscribed stays true for it (Stripe hasn't actually revoked the subscription, it's mid-retry), so the billing page can show a dunning banner instead of silently downgrading the user to free. trialDaysLeft() is a small display helper.
  • lib/billing/invoices.ts — getInvoices(stripeCustomerId): thin wrapper over stripeClient.invoices.list, returns [] (never throws) with no customer id, Stripe unconfigured, or a transient API error — same graceful-degradation contract as lib/email.ts.
  • lib/dal.ts — requireAccess() calls getAccess and redirects free-tier users to /subscribe. Never call requireAccess() from /billing or /subscribe themselves — they must stay reachable for users to actually pay.
  • components/billing/plan-checkout.tsx — client component driving authClient.subscription.upgrade({plan, annual, successUrl, cancelUrl}); used on /subscribe.
  • components/billing/billing-actions.tsx — client component with two distinct Stripe Billing Portal entry points: authClient.subscription .billingPortal({returnUrl}) ("Manage billing" — the portal's default landing page: payment method, invoices) and authClient.subscription .cancel({returnUrl}) ("Cancel subscription" — same portal, pre-routed into its cancel flow). Shows a "Choose a plan" link for free-tier users.
  • components/billing/invoices-list.tsx — renders getInvoices()'s result: amount, date, status badge, a "View" link to Stripe's hosted invoice page. Empty state when there are none yet.
  • app/(app)/billing/page.tsx — plan status, the past_due dunning banner, BillingActions, InvoicesList.
  • app/(marketing)/pricing/page.tsx — public pricing cards (components/marketing/pricing-cards.tsx), no auth required.
  • app/(app)/subscribe/page.tsx — the authenticated plan picker (renders PlanCheckout).
  • app/(admin)/admin/subscriptions/page.tsx — read-only table of all subscription rows joined to user, for support/debugging.

How to extend it

  • Add/remove/reprice a tier — edit the PLANS array in lib/billing/plans.ts only; the Stripe plugin config and both pricing UIs pick it up automatically. Add the new Price id env vars to .env.example.
  • Gate a feature by tier, not just "any paid plan" — getAccess() already returns tier; branch on access.tier where you call it instead of only checking isPaid.
  • One-time payments alongside subscriptions — the plugin's onEvent hook (top-level in the stripe() plugin config) fires on any Stripe webhook event; you can add mode: "payment" Checkout Sessions elsewhere and record them there. Note that handling checkout.session.expired or charge.refunded needs somewhere to write the result — there is no orders table here, only subscriptions, so adding one-time purchases means adding that table first.
  • Per-plan trial length — STRIPE_TRIAL_DAYS applies uniformly to every paid plan; split the subscriptionPlans map in lib/auth.ts if a specific plan needs a different length (or none at all).
  • A general idempotent-webhook-handler pattern for a new event type — follow onSubscriptionComplete's shape: accept event in the callback data, call claimStripeEvent(event.id, event.type) first, bail on false. Only wire this where the callback actually receives event (not every plugin hook does — check the type before assuming).

How to remove it

Delete lib/billing/, lib/stripe.ts, the subscription/stripeEvents tables + their exports from lib/db/schema/ (regenerate a migration), the stripe plugin block in lib/auth.ts, PAID_PLANS import, app/(marketing)/pricing/, app/(app)/subscribe/, components/billing/, components/marketing/pricing-cards.tsx, requireAccess from lib/dal.ts, and the /subscribe//billing entries from PROTECTED_PREFIXES in proxy.ts. Everything else (auth, admin, contact form) works standalone without billing.