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
| Var | Purpose |
|---|---|
STRIPE_SECRET_KEY | Server-side Stripe API calls |
STRIPE_WEBHOOK_SECRET | Verifies webhook signatures |
STRIPE_PRO_MONTHLY_PRICE_ID / STRIPE_PRO_ANNUAL_PRICE_ID | Pro tier Stripe Price ids |
STRIPE_BUSINESS_MONTHLY_PRICE_ID / STRIPE_BUSINESS_ANNUAL_PRICE_ID | Business tier Stripe Price ids |
STRIPE_TRIAL_DAYS | Optional. 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_ENABLED | Optional. "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/webhookCopy 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,highlightedflag. Bothlib/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 anisStripeConfiguredflag. Used by bothlib/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— mapsPAID_PLANSinto the@better-auth/stripeplugin'ssubscription.plansconfig ({name, priceId, annualDiscountPriceId, freeTrial?}), and setsgetCheckoutSessionParamsto always passallow_promotion_codes: true(andautomatic_taxwhenSTRIPE_TAX_ENABLED). The plugin owns checkout session creation, the webhook handler, and writing thesubscriptiontable.lib/db/schema/auth.ts— thesubscriptiontable (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 intostripeEvents,onConflictDoNothing; returnstruethe first time an event id is seen,falseon every repeat.lib/auth.ts'sonSubscriptionComplete/onSubscriptionCancelhooks call this first and bail out (skip re-sending their email) when it returnsfalse— guards against Stripe retries or astripe listenredelivery double-sending a "you're subscribed"/"you're canceled" email. It does not guard the plugin's ownsubscriptionrow 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'spast_dueandunpaid—isSubscribedstaystruefor 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 overstripeClient.invoices.list, returns[](never throws) with no customer id, Stripe unconfigured, or a transient API error — same graceful-degradation contract aslib/email.ts.lib/dal.ts—requireAccess()callsgetAccessand redirects free-tier users to/subscribe. Never callrequireAccess()from/billingor/subscribethemselves — they must stay reachable for users to actually pay.components/billing/plan-checkout.tsx— client component drivingauthClient.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) andauthClient.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— rendersgetInvoices()'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 (rendersPlanCheckout).app/(admin)/admin/subscriptions/page.tsx— read-only table of allsubscriptionrows joined touser, for support/debugging.
How to extend it
- Add/remove/reprice a tier — edit the
PLANSarray inlib/billing/plans.tsonly; 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 returnstier; branch onaccess.tierwhere you call it instead of only checkingisPaid. - One-time payments alongside subscriptions — the plugin's
onEventhook (top-level in thestripe()plugin config) fires on any Stripe webhook event; you can addmode: "payment"Checkout Sessions elsewhere and record them there. Note that handlingcheckout.session.expiredorcharge.refundedneeds somewhere to write the result — there is noorderstable here, only subscriptions, so adding one-time purchases means adding that table first. - Per-plan trial length —
STRIPE_TRIAL_DAYSapplies uniformly to every paid plan; split thesubscriptionPlansmap inlib/auth.tsif 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: accepteventin the callback data, callclaimStripeEvent(event.id, event.type)first, bail onfalse. Only wire this where the callback actually receivesevent(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.