Billing
Stripe subscriptions, plans, the customer portal, and the entitlement layer.
Last updated on
15 min readSyntaxKit ships a complete B2B subscription billing layer on top of Stripe, the most trusted payments platform for SaaS. Subscriptions are scoped to organizations, not users, so a Pro plan is a team-level entitlement. Checkout uses Stripe-hosted pages so card details never touch your domain. Every knob (tiers, intervals, trial length, feature flags, limits) lives in one config file and is yours to tune. Polar and LemonSqueezy adapters are on the roadmap; the kit's BillingState and entitlements layer is already provider-agnostic.
How a subscription comes to life
The synchronous checkout half and the asynchronous webhook half.
Pricing plans and catalog
One config object: baseline, plans, prices, features, and limits.
Subscription phases
The seven-phase state machine from free to entitled to lapsed.
Entitlements and gating
Read billing state, then gate features and limits from your code.
How A Subscription Comes To Life
The flow has two halves. The synchronous half (top of the diagram) creates a Stripe-hosted Checkout session and sends the user there; on success the dashboard simply navigates back with a query param so the page can show a toast. The asynchronous half (dotted edges) is Stripe's webhook telling the kit what actually happened. processWebhookEvent claims the event id (so retries don't double-process), upserts the Subscription row, calls syncCurrentOrganizationSubscription to update Organization.currentSubscriptionId, and dispatches transactional email and analytics through OutboundEffect so each side effect runs at most once. The next request that calls getBillingState reads the new row and resolves to the entitled phase.
The diagram source lives at apps/docs/diagrams/billing-checkout-flow.mmd. Rerun pnpm --filter @syntaxkit/docs diagrams:build after editing it to refresh both SVG variants.
Package Layout
What's Wired In
| Capability | How it's enabled |
|---|---|
| Org-scoped subscriptions | Organization.stripeCustomerId + currentSubscriptionId on the org row |
| Stripe-hosted checkout | createCheckoutSession in stripe/checkout.ts returns a hosted URL |
| Configurable trial | billingCatalogDeclaration.plans.<plan>.prices[].trialDays; defaults to 7 days, tunable per price |
| Stripe Customer Portal | createPortalSession; one button covers plan changes, payment method, invoices |
| Cancel + resume in-app | cancelSubscription and resumeSubscription toggle cancel_at_period_end |
| Monthly + yearly intervals | Each plan declares any subset of prices; quarterly or 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", ...) style on every protected action |
| Per-plan limits | maxMembers, monthlyAiResponses; add your own in BillingLimitConfig |
| Lifecycle emails | Welcome, payment failed, payment succeeded, payment action required, trial ending, canceled |
| Dashboard-driven payment methods | Checkout does not pin payment_method_types; cards, wallets (Apple Pay, Google Pay, Link), and regional methods (SEPA, iDEAL, Bancontact, etc.) appear automatically based on Settings -> Payment methods |
| Promotion codes | allow_promotion_codes: true on every session so Stripe coupons work without per-call wiring |
| Opt-in Stripe Tax | STRIPE_AUTOMATIC_TAX=true flips on automatic_tax, tax_id_collection, address collection, and writes the data back to the customer for renewals |
| Optional baseline / no free tier | Declare a baseline for a free or locked floor, or omit it for a hard paywall. One file, no code edits (see ADR 0002) |
| Optional live-Stripe tests | pnpm test:stripe runs *.live.test.ts against real test-mode keys |
Why These Choices
Stripe-only at launch
Battle-tested, B2B-native, every operator already knows it. Polar and LemonSqueezy adapters are planned next; the kit's BillingState and entitlements layer is already provider-agnostic, so adding a provider is swapping the Stripe wrapper, not rewriting consumers.
Hosted checkout over a custom page
Stripe absorbs PCI scope, 3DS / SCA, Apple Pay, Link, Cash App, and tax collection. Customers trust the Stripe domain. There is no card form to maintain, audit, or break.
Custom thin layer over the Better Auth Stripe plugin
The Better Auth Stripe plugin is in beta and user-scoped. Billing here is product-critical and org-scoped. A small in-repo wrapper means zero version-lock risk and exactly the entitlements model the kit needs.
Server-only price IDs
Stripe price IDs live in server-only STRIPE_PRICE_ID_* env vars and never reach the client bundle. The browser sends a plan slug + interval to checkout; the server resolves the price ID from the catalog. The marketing page and dashboard render display amounts (no price IDs) from the catalog.
Pricing Plans And Catalog
The catalog is one TypeScript object you declare with defineBillingCatalog in packages/payments/src/catalog/declaration.ts. It has two parts (see ADR 0002): an optional baseline (what an organization with no active subscription gets) and the plans map of purchasable tiers. Each price names a server-only env var (stripePriceEnv) that holds its Stripe price ID. The ID itself is never written in the file or shipped to the client. Everything below is a default; the next section is a tour of the knobs you can tune.
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 },
},
},
},
});Notice there is no features: string[] on a plan. The human-readable feature bullets shown on the marketing pricing page and the dashboard plan grid are a projection of entitlements (see packages/payments/src/catalog/feature-bullets.ts) and are localized through the BillingFeatures i18n namespace in apps/web/messages. This guarantees "advertise == enforce": a plan can only advertise what the server actually enforces, and the bullets can never drift from the entitlements. See ADR 0003.
Dropping the free tier (paid-only)
The baseline is what makes "no free tier" possible. Two paid-only shapes:
// (a) Paid-only with a usable-but-locked floor: unsubscribed orgs can sign in
// but everything is 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 the `paywalled`
// phase and the dashboard routes them to the billing page until they subscribe.
defineBillingCatalog({
plans: { pro: { /* ... */ } },
});For trial-only, omit (or lock) the baseline and give every paid plan a trialDays. New orgs must start a trial via checkout to use the product.
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 amounts are display-only. Stripe charges whatever the linked Price is configured to charge; the amount field in billingCatalogDeclaration is for the marketing page and dashboard plan grid. The "yearly discount" is whatever you set on the Stripe Price.
Configurability: What You Can Tune
Every value below is a default. Change the file, restart the app, and the catalog updates everywhere: marketing pricing page, dashboard plan grid, checkout, and entitlement checks.
Knobs in the kit
| Knob | Where | Examples |
|---|---|---|
| Trial length per price | billingCatalogDeclaration.plans.<plan>.prices[].trialDays | 0 (no trial), 7 (default), 14, 30 |
| Pricing intervals | billingCatalogDeclaration.plans.<plan>.prices[] | Drop yearly, add quarterly, year-only, etc. |
| Number of tiers | keys of billingCatalogDeclaration.plans | Pro today; add team, enterprise, etc. |
| Plan name + tagline | billingCatalogDeclaration.plans.<plan>.{ name, description } | Marketing copy on cards |
| Feature bullets | Derived from entitlements (feature-bullets.ts); copy in the BillingFeatures i18n namespace | Bullets project the entitlements and are localized; edit limits/flags to change which bullets appear, edit the messages to change wording |
| Recommended badge | billingCatalogDeclaration.plans.<plan>.recommended: true | Highlights one tier in the grid |
| Baseline (free / locked floor) | billingCatalogDeclaration.baseline | Edit limits, rename "Free" to "Starter", set visibleAsPlan: false, or omit for a paywall |
| Per-plan feature flags | billingCatalogDeclaration.plans.<plan>.entitlements.features | multiModelAccess, webSearch, billingPortal, your own keys |
| Per-plan limits | billingCatalogDeclaration.plans.<plan>.entitlements.limits | maxMembers, monthlyAiResponses; add your own |
| Default AI model | CHAT_DEFAULT_MODEL_ID in packages/shared/src/schemas/chat.ts | Which model free users get |
| Display amounts | billingCatalogDeclaration.plans.<plan>.prices[].amount | Marketing display only; Stripe charges the actual Price |
Knobs in the Stripe Dashboard
These do not require code changes. They live in your Stripe account.
| Knob | Where in Stripe |
|---|---|
| Payment methods (Apple Pay, Link, Cash App, SEPA, iDEAL, etc.) | Settings -> Payment methods. The kit does not pin payment_method_types, so toggles here apply to every Checkout session. |
| Currencies | On the Price itself (per Price, not global) |
| Tax / VAT collection | Settings -> Tax in Stripe + set STRIPE_AUTOMATIC_TAX=true (see Payment Methods And Tax) |
| Invoice branding (logo, colors, footer) | Settings -> Branding |
| Customer-facing email templates | Settings -> Customer emails |
| Webhook event subscriptions | Developers -> Webhooks |
Three concrete 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. Users picking yearly skip the trial and get charged 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 server-only env var (STRIPE_PRICE_ID_PRO_QUARTERLY) at it via the price's stripePriceEnv, and keep interval: "year" for the display toggle (Stripe charges per the linked Price's interval_count: 3). The dashboard toggle reflects the declared intervals automatically.
Catalog and Stripe can't silently disagree. The catalog stays the source of truth for the displayed amount/currency; to make sure those never drift from what Stripe charges, run pnpm billing:check-prices (also wired into the stripe-live CI job). It compares each declared paid price against its live Stripe Price and fails on amount/currency drift, archived, or missing Prices, handling zero-decimal currencies correctly. Because the "quarterly billed as yearly" pattern above intentionally diverges the displayed interval from Stripe's, an interval difference is an advisory warning unless you set BILLING_PRICE_SYNC_STRICT_INTERVAL=1. The guard is test/script-only and never runs on a request. Not to be confused with runtime drift.ts telemetry, which flags unmapped webhook price IDs.
Subscription Phases
A subscription's effective state is a BillingPhase, derived from the persisted Subscription row by resolveBillingState. The phases and the transitions between them:
| Phase | When assigned | Has access? | Blocking? | What buyers see |
|---|---|---|---|---|
free | No persisted Subscription row and the catalog declares a baseline | Baseline entitlements only | No | Baseline plan limits and feature flags. |
paywalled | No persisted Subscription row and the catalog declares no baseline (hard paywall) | Locked floor (everything off, limits 0) | No (fails closed) | The dashboard routes them to the billing page to subscribe. |
entitled | Status is active or trialing and the price is in the catalog | Yes (paid plan) | No | Full access; "Current Plan" badge. |
grace_period | Status is past_due; price still in catalog | Yes (paid plan) | No | Paid features keep working; banner asks for payment recovery. |
recoverable | Subscription exists but status is unpaid / incomplete / paused (slot still occupied) | Baseline floor only | No | Baseline behavior; the dashboard directs the user to recover the payment in Stripe (no in-app checkout or portal until resolved). |
lapsed | Subscription is terminal: status is canceled / incomplete_expired (slot free) | Baseline floor only | No | Baseline behavior; "Reactivate" CTA starts a new checkout. |
configuration_error | Active or past_due subscription whose price is not in billingCatalogDeclaration | Baseline floor only | Yes | "Billing Attention Needed" card; new checkout / feature gates throw CONFLICT. |
The "baseline floor" is the declared baseline when one exists, or the synthetic locked floor (everything off, limits 0) on a hard-paywall catalog.
Two callouts worth burning into memory:
grace_periodkeeps paid access. The kit does not abruptly downgrade on the first failed charge; Stripe drives recovery, the kit reflects it.configuration_erroris the only blocking phase. Every other phase makes its decision (free vs paid) cleanly and lets requests through.
Connecting Stripe
The first-time Stripe walkthrough (account, products, env vars, webhook endpoint, Stripe CLI for local dev) lives on Setup: Billing (Stripe). When pnpm setup:doctor reports Stripe billing: OK, head back here for the deeper coverage of how the subscription lifecycle, entitlements, and dashboard UI fit together.
For the test-to-live cutover (live keys, live price ids, live webhook endpoint), see Going To Production: Pricing And Stripe Live Mode.
Payment Methods And Tax
Two production concerns deserve a one-paragraph mental model before launch: which payment methods appear at checkout, and whether sales tax / VAT is collected on top of the price.
Payment methods are Dashboard-driven
createCheckoutSession does not set payment_method_types. Stripe takes the list from your Dashboard at Settings -> Payment methods and renders only what you've enabled. Cards are on by default; you can toggle Apple Pay, Google Pay, Link, Cash App, SEPA Direct Debit, iDEAL, Bancontact, and the rest without redeploying. Pinning the list in code (the older payment_method_types: ["card"] pattern) actively suppresses everything else, including wallets, so the kit deliberately leaves it off.
Tax is opt-in via one env var
Stripe Tax is the production answer to VAT (EU/UK), GST (AU/NZ/CA), and US sales tax. It's off by default in the kit because turning it on without a Stripe Tax registration causes Checkout to fail. Two steps to enable it:
Activate Stripe Tax in the Dashboard
Go to Settings -> Tax in your Stripe account, activate Stripe Tax, and add at least one tax registration for a jurisdiction where you have a tax obligation. Stripe will only attempt to compute tax for jurisdictions you've registered.
Set the env var
Add STRIPE_AUTOMATIC_TAX="true" to your runtime environment and redeploy. Every subsequent Checkout session enables automatic_tax, asks for a billing address, lets B2B buyers enter their VAT / tax ID via tax_id_collection, and writes the address + name back to the Stripe customer (customer_update) so the next renewal invoice computes tax correctly without re-asking.
When the flag is unset, Checkout still collects an address opportunistically (billing_address_collection: "auto") but does not enforce one or run tax calculation. If billing is not configured (isBillingEnabled is false), none of this applies.
Checkout And Portal
Two procedures cover everything users do on the billing page. Both are gated by withPermission(billing: ["manage"]) so non-admin members can read state but not start checkout or open the portal.
billing.createCheckout
Phases of the handler:
- The client sends
{ planSlug, interval }, never a Stripe price ID. assertBillingEnabled()throwsBAD_REQUESTif billing is not configured in this environment.getCurrentOrganizationBilling(headers)resolves the active org andBillingState.- Classify eligibility with
getCheckoutConflict(billing): one derived check on the resolvedphase(no raw status matching). - Reject with
CONFLICTwhen it returnsconfiguration_error(catalog mismatch),active(already subscribed, use the portal), orrecover(a non-terminal subscription must be recovered first);nulllets checkout proceed. resolveForward({ planSlug, interval })resolves the server-only Stripe price ID and trial from the catalog (BAD_REQUESTon an unknown plan/interval).getOrCreateCustomer(orgId)ensuresOrganization.stripeCustomerIdis set.createCheckoutSession({ priceId, customerId, trialDays, successUrl, cancelUrl })returns a Stripe-hosted URL.
The dashboard does window.location.assign(url). After payment, Stripe redirects back to getCheckoutSuccessUrl() (which appends ?success=true); on cancel, getCheckoutCancelUrl() appends ?canceled=true. The dashboard reads those query params to show toasts.
billing.createPortal
assertBillingEnabled().canOpenBillingPortal(billing)returns true only whenphaseisentitledorgrace_periodand the plan hasbillingPortal: true.createPortalSession({ customerId, returnUrl })returns a Stripe-hosted URL.
The portal is the universal "manage billing" surface: change plan, change payment method, view invoices, cancel. The kit also offers in-app cancel + resume (billing.cancel, billing.resume) that toggle cancel_at_period_end directly through the API for users who don't want to leave the dashboard.
The Webhook Path (Billing-Specific)
The webhook deep dive lives at Webhooks And Async Workflows: The Stripe Webhook. What follows is the billing-specific dispatch table: each row is one Stripe event the kit handles.
| 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 (status, periods, cancelAtPeriodEnd, trialEnd, planSlug) | None (status changes drive UI 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 |
The two-layer idempotency story: StripeWebhookEvent.eventId is claimed atomically before any handler runs (Stripe re-deliveries are no-ops); each email and analytics dispatch claims its own (kind, key) row in OutboundEffect so the same business action can never fire twice. See the linked Webhooks page for the failure-mode walkthrough.
Entitlements And Gating
How to actually use this from your code.
Read the state
Inside an oRPC procedure, use the helper:
import { getCurrentOrganizationBilling } from "@/lib/billing";
const { organization, billing } = await getCurrentOrganizationBilling(headers);Outside an oRPC procedure (e.g. a one-off script, a non-procedure server action), call getBillingState(orgId) from @syntaxkit/payments/server directly.
Gate a feature
import { assertBillingFeature } from "@/lib/billing";
assertBillingFeature(
billing,
"webSearch",
"Upgrade to Pro to enable web search."
);assertBillingFeature throws CONFLICT when there's a blocking billing issue (catalog mismatch) and FORBIDDEN when the feature isn't on the plan. The optional third argument is the upgrade copy that flows back to the user.
Gate a limit
For caps that gate a side-effecting action (an AI response, a billable API call), use a reservation: it counts the active window AND inserts the usage row inside one advisory-locked prisma.$transaction, so concurrent callers can't both pass a stale "under cap" check and then both write usage rows.
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: the monthly slot is freed for the next request.
await prisma.aiUsageEvent
.delete({ where: { id: usage.id } })
.catch(() => {});
throw error;
}For predicate-style checks that don't need atomicity (rendering an "Upgrade" banner, gating a member invitation form), keep using 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 with the same shape.
Surface upsells
The thrown FORBIDDEN message is the upgrade copy. On the client, useChat's onError (or any other error boundary) toasts it directly:
const { sendMessage } = useChat({
onError: (error) => toast.error(error.message),
// ...
});That gives you "Upgrade to Pro to enable web search." in the UI without any extra wiring.
The Dashboard Billing Page
/dashboard/billing has four UI blocks:
| Block | What it does |
|---|---|
<CurrentPlanCard> | Plan badge, status, trial-end line, cancel / resume button. Shows a "Billing Attention Needed" alert when hasBlockingBillingIssue is true and a "payment recovery" line for grace_period. |
| Monthly / yearly toggle | Two-button switch that drives which interval is sent to checkout (the server resolves the matching price). |
<PlanCard> grid | One column per plan: feature list, recommended badge, CTA. New subscribers click through to createCheckout; existing subscribers click through to createPortal for change / downgrade. |
| "Manage in Stripe" button | createPortal for the universal entry point, regardless of which plan. |
Marketing pricing (PricingSection on the homepage) renders the same billingCatalogDeclaration but only shows monthly amounts and CTAs go to signup, not checkout. Checkout requires an active org.
Customizing The Catalog (Worked Example: Adding A Team Tier)
The previous section listed every knob. This one walks through the most-asked extension end-to-end: a new "Team" tier between Free and Pro.
In Stripe
Create a new Product called "Team" and add two recurring Prices: monthly and yearly. Pick the amount you want; you can copy Pro's structure or charge any number you like. Copy both price ids.
Env vars
Add the new price ids to your environment:
STRIPE_PRICE_ID_TEAM_MONTHLY="price_..."
STRIPE_PRICE_ID_TEAM_YEARLY="price_..."Mirror the entries in apps/web/.env.example. The boot doctor derives its required billing env vars from the catalog declaration, so once the team prices below name these env vars they are validated automatically, with no list to edit by hand.
Catalog
Add a team entry under billingCatalogDeclaration.plans, alongside pro. Each price names its server-only stripePriceEnv. Pick a trialDays per price (7 days, 14 days, or 0 to disable). Optionally set recommended: true on Team and remove it from Pro:
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)
If Team unlocks a feature Pro doesn't (or vice versa), add a key to BillingFeatureFlags in packages/payments/src/types.ts, default it false in createBillingFeatureFlags, and set it true on the right plans. Then call assertBillingFeature(billing, "newFeature", "...upgrade copy") wherever it's enforced.
A new flag is deliberately not advertised until you opt it in. This is the "advertise == enforce" guard. To render it as a marketing bullet, in packages/payments/src/catalog/feature-bullets.ts add the key to ENFORCED_FEATURE_KEYS and ADVERTISABLE_FEATURE_KEYS, add its bullet key(s) to FeatureBullet/FeatureBulletKey, and handle it in the flagBullet switch (the compiler will require this once it is in the allowlist). Then add the bullet key(s) to the BillingFeatures namespace in apps/web/messages/en.json + de.json.
A new limit (optional)
If Team has a different ceiling on something the kit doesn't already track, 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 auto-renders the new entry; no UI code change needed. The marketing PricingSection may need a column tweak for three plans (the default grid is lg:grid-cols-3 so two-tier setups already leave room).
That's the entire change. Stripe holds the truth about money; the catalog file holds the truth about plans; entitlement helpers hold the truth about access. Add a tier in three of those layers and 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.
Organizations
Where assertWithinMemberLimit lives in the day-to-day flow: invitations, roles, seat checks.
API
How withPermission(billing:[manage]) is wired and where to add your own gated procedures.
