Skip to content

Billing

Billing uses Stripe through @better-auth/stripe, with the organization as the customer. Everything is in place but dormant until STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET (and the STRIPE_PRICE_* ids) are set — without them nothing is capped and /app/billing says so.

packages/billing/src/catalog.ts is browser-safe: plan names, advertised prices, features and limits. The API, /app/billing and the public site’s /pricing page all render from it, so every surface advertises the same plans. The server half, @repo/billing, maps plans to Stripe Price ids from env and holds the SDK client; frontends never import it.

Plan Price Workspaces Members AI credits / month Storage / workspace
Free 1 3 50 100 MB
Pro $12 / month, 14-day trial 10 10 2,000 5 GB
Team $29 / month + $8 per member / month 100 100 20,000 50 GB

Keep the amounts in sync with the Stripe dashboard; prices are advertised from code. Storage is the one per-workspace limit (storageBytes); the others are per organization.

The catalogue’s English strings are what the API and the Astro pricing page render. The app does not use them: planLabel, planDescription and planFeatures in apps/web/src/lib/i18n.ts render a plan’s name, blurb and feature list from Paraglide messages in English and German, so nobody reads English copy on the page that takes their money. Prices and limits live in the catalogue alone — but plan copy lives in two places, and editing one is how the two drift.

The shared roles carry a billing statement: read for every member (see the plan), manage for owners and admins (upgrade, cancel, restore, customer portal). The plugin’s authorizeReference and the UI use the same statement, so the buttons a user sees match what the API allows.

planForOrganization reads the subscription table the plugin maintains from webhooks. Statuses active, trialing and past_due entitle the paid plan: Stripe retries a failed card for days, so access continues while the app shows a “payment failed” banner; unpaid and canceled fall back to Free. billing.status (GET /api/v1/organizations/{organizationId}/billing) returns { enabled, plan, limits, subscription, paymentFailed, overLimit } including the non-entitling states the plugin’s own subscription/list hides. overLimit is [{ kind: 'workspaces' | 'members' | 'aiCredits', current, limit }] — the plan limits this organization already exceeds, which is what a downgrade leaves behind: everything already there stays readable, and writes that would add to those counters are refused. It is empty while billing is off, and storageBytes never appears in it, because that cap is per workspace and has no organization total. /app/billing renders it as a warning listing each overage.

Status alone is only as fresh as the last webhook that arrived, so entitlement is also bounded in time: active and trialing stop entitling three days past periodEnd, and past_due stops BILLING_DUNNING_DAYS (14 by default; 0 revokes at period end) past it. The bound matters because what Stripe does once its retries run out — cancel, mark unpaid, or leave the subscription past_due for ever — is a setting in your dashboard, and the last of those would otherwise entitle the paid plan indefinitely while /pricing publicly promises a fallback to Free.

A daily job at 04:47 UTC asks Stripe what is true and repairs the local row — status, period and seat count. One lost customer.subscription.updated used to freeze a row for good (a cancelled organization keeping the paid plan, or a paying one stuck on Free), because the plugin’s handlers log the failure and still answer 200, so Stripe never retries. Deliveries are recorded in webhook_event and deduplicated, so a “resend” from the dashboard does not produce a second dunning email or a second audit row.

Limits are enforced in the router (workspaces, AI credits, storage) and in organization hooks (seats) — see Organizations and Documents, jobs and the AI service (credits) and its storage quota. Credits per job kind (jobCredits) live next to the plans in the catalog.

Checkout, the customer portal and cancel/restore are the plugin’s endpoints under /api/auth/subscription/*, called from /app/billing:

  • Checkout creates the organization’s Stripe customer lazily at first checkout (none is created at sign-up) and allows promotion codes. Trials collect a card by default.
  • The plan a visitor picked survives the funnel. /pricing links to /signup?plan=pro, sign-up carries it to /app/billing?plan=pro, and the billing page preselects that card and says so. It starts no checkout by itself, and because the destination is baked into the verification link’s callbackURL, it survives the round trip through the inbox. An unknown plan id falls back to /app.
  • Per-seat plans keep the seat quantity equal to the member count as people join and leave.
  • Plan changes between paid tiers go through subscription/upgrade (Stripe prorates); downgrading to Free is “cancel at period end”, which can be restored until then.
  • A downgrade below current usage is refused. Before a plan change the API compares the organization’s real usage with the target plan’s limits and answers PLAN_LIMIT_EXCEEDED_BY_CHANGE when it would not fit — an organization with 60 members and 40 workspaces cannot self-serve from Team to Pro and keep all of them.
  • Emails for dunning (invoice.payment_failed), cancellation and trial ending go to the organization’s owners and admins. Every subscription change lands in the audit log (without an actor when it comes from a webhook).
  • The sole owner of an organization cannot delete their account while that organization still has other members, nor while either it or a solo organization has a live subscription — see Authentication. Deleting an organization deletes its stored documents with it.

Billing emails link to /app/billing?organization=<id>, which switches the active organization before rendering.

POST ${API_URL}/api/auth/stripe/webhook. Events: checkout.session.completed, customer.subscription.created|updated|deleted|trial_will_end, invoice.payment_failed.

Locally:

Terminal window
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook # prints the signing secret
  1. Create the Products and recurring Prices in your Stripe account so the amounts match the catalog (Pro flat; Team flat + per-seat).
  2. Set STRIPE_SECRET_KEY, STRIPE_PRICE_PRO_MONTHLY, STRIPE_PRICE_TEAM_MONTHLY, STRIPE_PRICE_TEAM_SEAT in the root .env.
  3. Register the webhook endpoint with the events above and set STRIPE_WEBHOOK_SECRET.
  4. Restart the API. /app/billing now sells plans and /admin/system reports billing as enabled (and lists any plan without a configured price).
  5. Decide what Stripe should do when its retries run out (Settings → Billing → Subscriptions and emails): cancel, mark unpaid, or leave the subscription past_due. Whichever you choose, BILLING_DUNNING_DAYS bounds how long a past_due subscription keeps its plan here.

bun run auth:schema passes placeholder STRIPE_* values so the generated Drizzle schema contains the subscription table on every machine; the plugin itself is only active when the real env is present.

Annual prices and a second currency are one field each in the catalog and annualDiscountPriceId in the server mapping. Tax is your responsibility with Stripe (Stripe Tax helps); if you sell worldwide as a solo operator, the decision log suggests looking at a merchant of record such as Polar before launch.