Billing
Billing uses Stripe through @better-auth/stripe, with the organization as the customer.
Everything is in place but dormant until STRIPE_SECRET_KEY, 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 | 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
renders a plan’s name, blurb and feature list from Paraglide messages in English and German
(planLabel, planDescription and planFeatures in apps/web/src/lib/i18n.ts). Prices and limits
live in the catalogue alone. Plan copy lives in both places, so edit both.
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 after a downgrade. Everything already there stays readable. 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. /app/billing renders it as a warning listing
each overage.
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. What Stripe does once its retries run out (cancel, mark unpaid, or leave the subscription
past_due) is a setting in your dashboard, and this bound covers the last case.
A daily job at 04:47 UTC asks Stripe what is true and repairs the local row: status, period and
seat count. The plugin’s webhook handlers log a failure and still answer 200, so Stripe never
retries; the daily job covers a lost delivery. 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,
AI credits and the
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 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. It starts no checkout by itself. The destination is part of the verification link’scallbackURL, so 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 usage with the target plan’s limits and answers
PLAN_LIMIT_EXCEEDED_BY_CHANGEwhen it would not fit. - 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_MONTHLYandSTRIPE_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. SetBILLING_DUNNING_DAYSto match.
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, consider a merchant of record such as Polar before launch.