| Slug | Mode | Amount | Interval | Features | Live price | Test price |
|---|---|---|---|---|---|---|
pro |
payment |
16900 usd | none | boilerplate, lifetime-updates |
price_1PED7uP4McnT55wKre6G3Rjd |
price_1SahiD1DJWXEccvOkfp8zjKQ |
demo-support |
subscription |
4900 usd | month |
demo-priority-support |
not sold | price_1UBAPn1DJWXEccvOw3Z1dOjV |
Amounts are minor units, mirroring Stripe's unit_amount. The catalog is packages/shared/src/catalog.ts, it is a TypeScript file rather than a database table, and there is no admin UI for editing it.
The demo offer is a separate product, not a tier: it stacks with pro instead of replacing it, so a sandbox user holding both sees three features derived from two separately-paid Stripe objects. A second demo subscription, demo-analytics, is left commented out in the catalog and in CARD_COPY; uncomment it to sell two subscriptions at once.
pro's live Stripe product is named Premium. That is a dead name, kept only because the product already exists in the live account. The slug is pro.
The shape of an entry
type CatalogEntry =
| (CatalogEntryCommon & {
mode: 'payment'
interval?: never
trialDays?: never
})
| (CatalogEntryCommon & {
mode: 'subscription'
interval: 'month' | 'year'
trialDays?: number
})
type CatalogEntryCommon = {
features: readonly Feature[]
unitAmount: number
currency: Currency
priceId: Readonly<Record<StripeEnv, string | null>>
}
interval is required when the mode is subscription and forbidden when it is payment, so a one-time price with /mo beside it is a type error rather than a code review.
trialDays is the free days before the first charge, and it is optional on a subscription offer and forbidden on a payment one. No shipped offer sets it. Setting it is the whole trial: checkout passes it to Stripe as subscription_data.trial_period_days, trialing entitles for free, and customer.subscription.trial_will_end mails the warning three days before the charge. See Add a plan.
The entry is slug-major: mode, features and the amount belong to the offer, so they are declared once. Only priceId varies by Stripe environment, because live and sandbox prices live in two genuinely different accounts whose ids are indistinguishable to the type system.
priceId[env] of null means this offer is not sold in that environment. It fails loudly rather than falling back.
Features
| Feature | Granted by |
|---|---|
boilerplate |
pro |
lifetime-updates |
pro |
demo-priority-support |
demo-support |
These are machine keys an entitlement is expressed in. They are not the marketing bullets on the pricing card, and the two need not correspond. Card copy lives in CARD_COPY in apps/web/src/lib/catalog/card-copy.ts.
The functions
| Function | Direction | Returns |
|---|---|---|
resolveSlug(slug, env) |
checkout | { slug, priceId, entry }, or throws CatalogError |
slugForPriceId(priceId) |
webhook | a Slug, or null |
sellableSlugs(env) |
pricing page | every slug with a price in that environment |
isSlug(value) |
narrowing | a type predicate |
stripeEnvFromSecretKey(key) |
the api's boot | live or test |
resolveSlug is the only way in. A client sends a slug, and the api resolves it to a price server-side. A caller who could name a price could pay the cheapest one in your Stripe account and ask for the most expensive offer, which is a live exploit the reference implementation shipped.
CatalogError carries two codes, and collapsing them would hide the one that is yours:
| Code | HTTP | Means |
|---|---|---|
unknown_slug |
400 | no such offer, which is the caller's problem |
slug_not_sold_here |
503 stripe_env_mismatch |
the offer exists and has no price in this api's account |
The second means the web app and the api are pointed at different Stripe environments, which is a deploy-time configuration problem readable in one log line rather than a broken button on the money page.
slugForPriceId is derived at module load rather than authored, so the two directions cannot drift. Building it asserts that no price id appears under two slugs, and that assertion throws at startup: an ambiguous price would otherwise silently grant whichever slug happened to be enumerated first.
Which Stripe environment is active
Derived from the secret key's prefix, never from NODE_ENV:
export function stripeEnvFromSecretKey(secretKey: string): StripeEnv {
return secretKey.startsWith('sk_live_') || secretKey.startsWith('rk_live_')
? 'live'
: 'test'
}
The web build mode answers a different question, which is how the process was built. A production Vite build can still point at Stripe's test account, so collapsing the two axes would render live prices merely because somebody ran pnpm build.
apps/web holds no Stripe secret, so it cannot derive the billing environment from
a key. It is told, through VITE_STRIPE_ENV, which is a Zod enum with no default. GET /health reports the api's answer, which is how you check the two agree.
Adding a slug
SLUGS is a const array and CATALOG is a Record<Slug, CatalogEntry>, so adding a name to SLUGS is a type error until an entry exists for it. CARD_COPY in the pricing component is exhaustive in the same way, so it is a second type error until copy exists too.
That pair of errors is the point. An array of cards would let a new offer go silently unrendered, and a card that vanishes from the money page is the failure worth compiling against.
The full recipe is Add a plan.
Single currency, on purpose
Currency is the union 'usd', and amounts are minor units. Dividing by 100 to display is wrong for zero-decimal currencies, which is the first thing multi-currency breaks. Widening that union is the deliberate edit that starts that work.
Related
docs/adr/0012-slug-major-catalog-carries-prices.md, the shape and why prices live in codedocs/adr/0002-express-api-owns-every-secret.md, why a client sends a slugdocs/adr/0005-entitlement-derived-from-price-paid.md, the reverse lookup