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.
The catalog
Section titled “The catalog”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.
Who may pay
Section titled “Who may pay”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.
What a plan grants
Section titled “What a plan grants”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.
/pricinglinks 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’scallbackURL, 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_CHANGEwhen 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.
Webhook
Section titled “Webhook”POST ${API_URL}/api/auth/stripe/webhook. Events: checkout.session.completed,
customer.subscription.created|updated|deleted|trial_will_end, invoice.payment_failed.
Locally:
stripe listen --forward-to localhost:3000/api/auth/stripe/webhook # prints the signing secretGoing live
Section titled “Going live”- Create the Products and recurring Prices in your Stripe account so the amounts match the catalog (Pro flat; Team flat + per-seat).
- Set
STRIPE_SECRET_KEY,STRIPE_PRICE_PRO_MONTHLY,STRIPE_PRICE_TEAM_MONTHLY,STRIPE_PRICE_TEAM_SEATin the root.env. - Register the webhook endpoint with the events above and set
STRIPE_WEBHOOK_SECRET. - Restart the API.
/app/billingnow sells plans and/admin/systemreports billing as enabled (and lists any plan without a configured price). - 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_DAYSbounds how long apast_duesubscription keeps its plan here.
Schema independence
Section titled “Schema independence”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.
Not wired yet
Section titled “Not wired yet”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.