Billing
Org-scoped Stripe subscriptions with hosted Checkout and Customer Portal. One catalog file defines tiers, prices, features, and limits. Gate access from oRPC with BillingState phases and entitlements.
Last updated on
13 min readOn This Page
How A Subscription Comes To Life
Two halves:
- Sync. The dashboard calls
billing.createCheckout. The user pays on Stripe-hosted Checkout. On return, a query param drives a success/cancel toast. - Async. Stripe webhooks report what actually happened.
processWebhookEventclaims the event id (retries are no-ops), upsertsSubscription, syncsOrganization.currentSubscriptionId, and sends email/analytics throughOutboundEffect(each side effect at most once). The nextgetBillingStateread resolves toentitled.
Subscriptions are org-scoped, not user-scoped. Card details never touch your domain. Polar and LemonSqueezy adapters are on the roadmap; BillingState and entitlements are already provider-agnostic.
Package Layout
What's Wired In
| Capability | How it's enabled |
|---|---|
| Org-scoped subscriptions | Organization.stripeCustomerId + currentSubscriptionId |
| Stripe-hosted checkout | createCheckoutSession returns a hosted URL |
| Configurable trial | prices[].trialDays per plan price (default 7) |
| Stripe Customer Portal | createPortalSession for plan, payment method, invoices |
| Cancel + resume in-app | cancelSubscription / resumeSubscription toggle cancel_at_period_end |
| Monthly + yearly intervals | Each plan declares any subset of prices; other Stripe intervals work too |
| Idempotent webhook | StripeWebhookEvent.eventId claim + OutboundEffect.(kind, key) per dispatch |
| Seven-phase state machine | free / paywalled / entitled / grace_period / recoverable / lapsed / configuration_error |
| Per-feature gates | assertBillingFeature(billing, "webSearch", ...) on protected actions |
| Per-plan limits | maxMembers, monthlyAiResponses; add your own in BillingLimitConfig |
| Lifecycle emails | Welcome, payment failed/succeeded, action required, trial ending, canceled |
| Dashboard-driven payment methods | Checkout does not pin payment_method_types; enable methods in Stripe Settings |
| Promotion codes | allow_promotion_codes: true on every session |
| Opt-in Stripe Tax | STRIPE_AUTOMATIC_TAX=true enables tax, tax ID collection, and address write-back |
| Optional baseline / no free tier | Declare a baseline, or omit it for a hard paywall (ADR 0002) |
| Optional live-Stripe tests | pnpm test:stripe runs *.live.test.ts against test-mode keys |
Pricing Plans And Catalog
One TypeScript object in packages/payments/src/catalog/declaration.ts, built with defineBillingCatalog. Two parts (ADR 0002):
baseline(optional). What an org with no active subscription gets.plans. Purchasable tiers.
Each price points at a server-only env var (stripePriceEnv) that holds the Stripe price ID. The ID is never written in the catalog file or shipped to the client.
The catalog shape
export const billingCatalogDeclaration = defineBillingCatalog({
// The unsubscribed floor. `visibleAsPlan: true` renders it as a card.
// Omit `baseline` entirely to run a hard paywall (see "Paid-only" below).
baseline: {
name: "Free",
description: "Get started without a credit card",
visibleAsPlan: true,
entitlements: {
features: createBillingFeatureFlags(),
limits: { maxMembers: 3, monthlyAiResponses: 100 },
},
},
// The purchasable tiers. `PlanSlug` is derived from these keys.
plans: {
pro: {
name: "Pro",
description: "For teams shipping AI features",
recommended: true,
prices: [
{
interval: "month",
amount: 29, currency: "USD", trialDays: 7,
stripePriceEnv: "STRIPE_PRICE_ID_PRO_MONTHLY",
},
{
interval: "year",
amount: 290, currency: "USD", trialDays: 7,
stripePriceEnv: "STRIPE_PRICE_ID_PRO_YEARLY",
},
],
entitlements: {
features: createBillingFeatureFlags({
billingPortal: true, multiModelAccess: true, webSearch: true,
}),
limits: { maxMembers: null, monthlyAiResponses: null },
},
},
},
});Plans have no features: string[]. Marketing and dashboard bullets are a projection of entitlements (packages/payments/src/catalog/feature-bullets.ts), localized under BillingFeatures in apps/web/messages. Advertise equals enforce: a plan can only show what the server gates. See ADR 0003.
Dropping the free tier (paid-only)
Two shapes:
// (a) Locked floor: orgs can sign in, but everything stays gated until they subscribe.
defineBillingCatalog({
baseline: {
name: "Inactive",
description: "Subscribe to unlock the product.",
visibleAsPlan: false, // no "Free" card on the pricing grid
entitlements: {
features: createBillingFeatureFlags(),
limits: { maxMembers: 1, monthlyAiResponses: 0 },
},
},
plans: { pro: { /* ... */ } },
});
// (b) Hard paywall: omit `baseline`. Unsubscribed orgs land in `paywalled`
// and the dashboard sends them to billing until they subscribe.
defineBillingCatalog({
plans: { pro: { /* ... */ } },
});For trial-only, omit (or lock) the baseline and set trialDays on every paid plan. New orgs must start a trial via checkout.
What ships today
| Free | Pro | |
|---|---|---|
| Members | Up to 3 | Unlimited |
| AI responses / month | 100 | Unlimited |
| Multi-model access | No | Yes |
| Web search | No | Yes |
| Billing portal | N/A | Yes |
| Trial | N/A | 7 days (per price, configurable) |
Yearly amount values are display-only. Stripe charges the linked Price. The catalog amount is for marketing and the dashboard grid only.
Knobs in the kit
Change the catalog file, restart, and marketing, dashboard, checkout, and entitlement checks all update.
| Knob | Where | Examples |
|---|---|---|
| Trial length per price | plans.<plan>.prices[].trialDays | 0, 7 (default), 14, 30 |
| Pricing intervals | plans.<plan>.prices[] | Drop yearly, add quarterly, year-only |
| Number of tiers | keys of plans | Add team, enterprise |
| Plan name + tagline | plans.<plan>.{ name, description } | Card copy |
| Feature bullets | Derived from entitlements (feature-bullets.ts); copy in BillingFeatures | Edit flags/limits to change which bullets appear; edit messages for wording |
| Recommended badge | plans.<plan>.recommended: true | Highlight one tier |
| Baseline (free / locked floor) | baseline | Edit limits, rename Free, set visibleAsPlan: false, or omit for a paywall |
| Per-plan feature flags | plans.<plan>.entitlements.features | multiModelAccess, webSearch, billingPortal, your keys |
| Per-plan limits | plans.<plan>.entitlements.limits | maxMembers, monthlyAiResponses; add your own |
| Default AI model | CHAT_DEFAULT_MODEL_ID in packages/shared/src/schemas/chat.ts | Model free users get |
| Display amounts | plans.<plan>.prices[].amount | Marketing only; Stripe charges the Price |
Paths above are under billingCatalogDeclaration.
Knobs in the Stripe Dashboard
No code changes. These live in your Stripe account.
| Knob | Where in Stripe |
|---|---|
| Payment methods (Apple Pay, Link, Cash App, SEPA, iDEAL, …) | Settings → Payment methods. The kit does not pin payment_method_types. |
| Currencies | On each Price |
| Tax / VAT collection | Settings → Tax, then set STRIPE_AUTOMATIC_TAX=true (see Payment Methods And Tax) |
| Invoice branding | Settings → Branding |
| Customer-facing email templates | Settings → Customer emails |
| Webhook event subscriptions | Developers → Webhooks |
Recipes
Want a 14-day trial on monthly only and no trial on yearly?
Set trialDays: 14 on the monthly price and trialDays: 0 on the yearly price in billingCatalogDeclaration.plans.pro.prices. Yearly checkout charges immediately.
Want a Team tier between Free and Pro?
Add a team: entry to billingCatalogDeclaration with its own prices and entitlements. Full walk-through in Customizing The Catalog.
Want quarterly billing instead of yearly?
Create a quarterly recurring Price in Stripe. Point a new env var (STRIPE_PRICE_ID_PRO_QUARTERLY) at it via stripePriceEnv. Keep interval: "year" for the display toggle if you want; Stripe still charges every three months (interval_count: 3). The dashboard toggle follows the declared intervals.
Catalog and Stripe must stay aligned. Run pnpm billing:check-prices (also in the stripe-live CI job). It compares each declared paid price to the live Stripe Price and fails on amount/currency drift, archived Prices, or missing Prices. Interval mismatches (for example quarterly billed as yearly) warn by default; set BILLING_PRICE_SYNC_STRICT_INTERVAL=1 to fail. This is test/script-only, not request-path. Runtime drift.ts is separate: it flags webhook price IDs that are not in the catalog.
Subscription Phases
resolveBillingState turns the persisted Subscription row into a BillingPhase. That phase answers three questions: does the org have access, is billing blocked, and what should the UI show?
| Phase | When | Access | Blocking? | UI |
|---|---|---|---|---|
free | No Subscription row, catalog has a baseline | Baseline entitlements | No | Baseline limits and flags |
paywalled | No Subscription row, catalog has no baseline | Locked floor (all off, limits 0) | No (fails closed) | Dashboard routes to billing |
entitled | Status active or trialing, price in catalog | Paid plan | No | Full access; Current Plan badge |
grace_period | Status past_due, price in catalog | Paid plan still on | No | Paid features keep working; recover-payment banner |
recoverable | Status unpaid / incomplete / paused (slot occupied) | Baseline floor | No | Recover payment in Stripe; no in-app checkout/portal until resolved |
lapsed | Status canceled / incomplete_expired (slot free) | Baseline floor | No | Reactivate CTA starts a new checkout |
configuration_error | Active or past_due, but price missing from the catalog | Baseline floor | Yes | Billing Attention Needed; checkout and feature gates throw CONFLICT |
Baseline floor means: the declared baseline if present, otherwise the synthetic locked floor (everything off, limits 0).
Remember two rules:
grace_periodkeeps paid access. The first failed charge does not downgrade; Stripe drives recovery and the kit reflects it.configuration_erroris the only blocking phase. Every other phase decides free vs paid and lets the request through.
Checkout And Portal
First-time Stripe setup (account, products, env vars, webhook, Stripe CLI) lives on Setup: Billing (Stripe). When pnpm setup:doctor reports Stripe billing: OK, come back here. For test-to-live cutover, see Going To Production: Pricing And Stripe Live Mode.
Two procedures cover the billing page. Both require withPermission(billing: ["manage"]): members can read state; only admins start checkout or open the portal.
billing.createCheckout
- Client sends
{ planSlug, interval }(never a Stripe price ID). assertBillingEnabled()throwsBAD_REQUESTif billing is not configured.getCurrentOrganizationBilling(headers)resolves the active org andBillingState.getCheckoutConflict(billing)checks eligibility fromphase(not raw Stripe status).- Reject with
CONFLICTwhen the result isconfiguration_error,active(already subscribed; use the portal), orrecover(fix the existing subscription first).nullmeans proceed. resolveForward({ planSlug, interval })maps to the server-only price ID and trial (BAD_REQUESTon unknown plan/interval).getOrCreateCustomer(orgId)ensuresOrganization.stripeCustomerId.createCheckoutSession(...)returns the hosted Checkout URL.
The dashboard does window.location.assign(url). Success returns to getCheckoutSuccessUrl() (?success=true); cancel returns to getCheckoutCancelUrl() (?canceled=true). The page reads those params for toasts.
billing.createPortal
assertBillingEnabled().canOpenBillingPortal(billing)is true only whenphaseisentitledorgrace_periodand the plan hasbillingPortal: true.createPortalSession({ customerId, returnUrl })returns the hosted portal URL.
The portal covers plan changes, payment method, invoices, and cancel. The kit also exposes in-app cancel/resume (billing.cancel, billing.resume) that toggle cancel_at_period_end without leaving the dashboard.
The dashboard billing page
/dashboard/billing has four blocks:
| Block | What it does |
|---|---|
<CurrentPlanCard> | Plan badge, status, trial-end line, cancel/resume. Shows Billing Attention Needed when hasBlockingBillingIssue is true, and a recover-payment line for grace_period. |
| Monthly / yearly toggle | Sets which interval checkout receives; the server picks the matching price. |
<PlanCard> grid | One column per plan: features, recommended badge, CTA. New subscribers go to createCheckout; existing subscribers go to createPortal. |
| Manage in Stripe | Always opens createPortal. |
Marketing pricing (PricingSection) reads the same catalog but shows monthly amounts and sends CTAs to signup. Checkout needs an active org.
Payment Methods And Tax
Payment methods are Dashboard-driven
createCheckoutSession does not set payment_method_types. Stripe uses Settings → Payment methods and renders what you enable. Cards are on by default; toggle Apple Pay, Google Pay, Link, Cash App, SEPA, iDEAL, Bancontact, and others without redeploying. Pinning payment_method_types: ["card"] in code suppresses wallets, so the kit leaves the list unset.
Tax is opt-in via one env var
Stripe Tax covers VAT, GST, and US sales tax. It is off by default: enabling it without a Stripe Tax registration makes Checkout fail. Two steps:
Activate Stripe Tax in the Dashboard
Go to Settings → Tax, activate Stripe Tax, and add at least one tax registration for a jurisdiction where you have a tax obligation. Stripe only computes tax where you are registered.
Set the env var
Set STRIPE_AUTOMATIC_TAX="true" and redeploy. Checkout then enables automatic_tax, collects a billing address, lets B2B buyers enter a VAT/tax ID (tax_id_collection), and writes address + name back to the Stripe customer so renewals compute tax without asking again.
When the flag is unset, Checkout may still collect an address (billing_address_collection: "auto") but does not enforce one or run tax. If billing is off (isBillingEnabled is false), none of this applies.
The Webhook Path
Deep dive: Webhooks And Async Workflows: The Stripe Webhook. Below is the billing dispatch table.
| Event | Persists | Side effects |
|---|---|---|
checkout.session.completed | Organization.stripeCustomerId (when missing) | None |
customer.subscription.created | Subscription upsert + Organization.currentSubscriptionId | Welcome email + subscription_started analytics |
customer.subscription.updated | Subscription fields (status, periods, cancel flag, trial, plan) | None (UI updates on next read) |
customer.subscription.deleted | Subscription.status = "canceled" + sync org pointer | Cancellation email |
customer.subscription.trial_will_end | None | Trial-ending email |
invoice.payment_action_required | None | Action-required email with hosted invoice URL |
invoice.payment_failed | None (status arrives via subscription.updated) | Payment-failed email |
invoice.payment_succeeded | None | Receipt email |
invoice.finalized | None | Log only |
Idempotency has two layers:
StripeWebhookEvent.eventIdis claimed before any handler runs. Re-deliveries are no-ops.- Each email/analytics dispatch claims
(kind, key)inOutboundEffect, so the same business action cannot fire twice.
See the linked Webhooks page for failure modes.
Entitlements And Gating
Read the state
Inside an oRPC procedure:
import { getCurrentOrganizationBilling } from "@/lib/billing";
const { organization, billing } = await getCurrentOrganizationBilling(headers);Outside a procedure (scripts, non-procedure server actions), call getBillingState(orgId) from @syntaxkit/payments/server.
Gate a feature
import { assertBillingFeature } from "@/lib/billing";
assertBillingFeature(
billing,
"webSearch",
"Upgrade to Pro to enable web search."
);Throws CONFLICT on a blocking billing issue (catalog mismatch) and FORBIDDEN when the feature is off the plan. The optional third argument is the upgrade copy shown to the user.
Gate a limit
For caps on side-effecting work (AI response, billable API call), use a reservation. It counts the active window and inserts the usage row in one advisory-locked prisma.$transaction, so two concurrent callers cannot both pass a stale under-cap check.
import { reserveAiUsageEvent } from "@/lib/billing";
const usage = await reserveAiUsageEvent(billing, organization.id, {
kind: "chat_send",
chatId: null,
createdByUserId: user.id,
});
try {
// ... call the model / do the billable work ...
} catch (error) {
// Best-effort refund: free the monthly slot for the next request.
await prisma.aiUsageEvent
.delete({ where: { id: usage.id } })
.catch(() => {});
throw error;
}For checks that do not need atomicity (Upgrade banner, member-invite form), use the assert helpers:
import { assertWithinAiResponseLimit, assertWithinMemberLimit } from "@/lib/billing";
await assertWithinAiResponseLimit(billing, organization.id);
await assertWithinMemberLimit(billing, organization.id);All three throw CONFLICT on a blocking issue or when the cap is reached. Add your own helpers with the same shape.
Surface upsells
The thrown FORBIDDEN message is the upgrade copy. On the client, toast it from useChat's onError (or any error boundary):
const { sendMessage } = useChat({
onError: (error) => toast.error(error.message),
// ...
});That surfaces "Upgrade to Pro to enable web search." with no extra wiring.
Customizing The Catalog
Worked example: add a Team tier between Free and Pro.
In Stripe
Create a Product named Team with monthly and yearly recurring Prices. Copy both price ids.
Env vars
STRIPE_PRICE_ID_TEAM_MONTHLY="price_..."
STRIPE_PRICE_ID_TEAM_YEARLY="price_..."Mirror them in apps/web/.env.example. The boot doctor derives required billing env vars from the catalog, so naming these in stripePriceEnv is enough. No hand-maintained list.
Catalog
Add team under billingCatalogDeclaration.plans next to pro. Each price names its stripePriceEnv. Set trialDays per price (7, 14, or 0). Optionally move recommended: true from Pro to Team:
team: {
name: "Team",
description: "For growing teams",
recommended: true,
prices: [
{ interval: "month", amount: 99, currency: "USD", trialDays: 7, stripePriceEnv: "STRIPE_PRICE_ID_TEAM_MONTHLY" },
{ interval: "year", amount: 990, currency: "USD", trialDays: 7, stripePriceEnv: "STRIPE_PRICE_ID_TEAM_YEARLY" },
],
entitlements: {
features: createBillingFeatureFlags({ billingPortal: true, webSearch: true }),
limits: { maxMembers: 10, monthlyAiResponses: 1000 },
},
},A new feature flag (optional)
Add a key to BillingFeatureFlags in packages/payments/src/types.ts, default it false in createBillingFeatureFlags, set it true on the right plans, then call assertBillingFeature(billing, "newFeature", "...upgrade copy") where you enforce it.
A new flag is not advertised until you opt it in (advertise equals enforce). To show a marketing bullet: add the key to ENFORCED_FEATURE_KEYS and ADVERTISABLE_FEATURE_KEYS in feature-bullets.ts, extend FeatureBullet / FeatureBulletKey, handle it in the flagBullet switch, and add copy under BillingFeatures in en.json and de.json.
A new limit (optional)
Add a key to BillingLimitConfig, set it on each plan, and write an assertWithin*Limit helper modeled on assertWithinMemberLimit in packages/api/src/lib/billing.ts.
UI
The dashboard <PlanCard> grid picks up the new plan automatically. Marketing PricingSection may need a column tweak for three plans (default grid is lg:grid-cols-3).
Stripe owns money. The catalog owns plans. Entitlement helpers own access. Add the tier in those three places; the rest follows.
Where To Go Next
Webhooks And Async Workflows
The full Stripe webhook story: signature verification, two-layer idempotency, stale processing recovery.
Going To Production
Switch from Stripe test mode to live mode safely: keys, price ids, webhook endpoint, smoke tests.
Also useful: Organizations, API, and Setup: Billing (Stripe).
Organizations
Multi-tenancy via Better Auth organizations in packages/auth. Personal orgs on sign-up, invites with owner / admin / member roles, the active org on the session, and how to scope product data to one org.
AI
Org-scoped streaming chat at /dashboard/ai-chat. Built on the Vercel AI SDK and AI Gateway, with billing gates, abuse limits, and a reusable pattern for new AI features.
