All pages

Add a plan

Files you will touch, in this order:

  1. the Stripe dashboard, for a product and a price
  2. packages/shared/src/catalog.ts, the slug, the feature and the price ids
  3. apps/web/src/lib/catalog/card-copy.ts, the card copy
  4. wherever the new feature gates something

Two of those steps are type errors until you do them, which is the design: adding a slug fails to compile until the catalog covers it, and the catalog fails to compile until the pricing card exists.

Create the price in Stripe

Create a product, then a price on it. One-off for a payment offer, recurring with a billing period for a subscription one. Copy the price id, which starts price_.

Do it in both the sandbox and the live account if you sell in both. They are separate accounts with separate ids, and the catalog holds one per environment.

Add the slug

// packages/shared/src/catalog.ts

export const SLUGS = ['pro', /* 'demo-analytics', */ 'demo-support', 'team'] as const

That line alone is a type error, because CATALOG is a Record<Slug, CatalogEntry> and now has a hole. Each edit below closes one hole and opens the next, so the compiler tells you what is still missing.

Add the feature it grants

export type Feature =
  | 'boilerplate'
  | 'lifetime-updates'
  // | 'demo-analytics-access'
  | 'demo-priority-support'
  | 'team-seats'

Features are machine keys your code gates on. They are not the bullets on the pricing card, and the two need not correspond.

Add the catalog entry

export const CATALOG: Readonly<Record<Slug, CatalogEntry>> = {
  // ...
  team: {
    mode: 'subscription',
    interval: 'month',
    features: ['boilerplate', 'lifetime-updates', 'team-seats'],
    unitAmount: 4_900,
    currency: 'usd',
    priceId: {
      live: 'price_...',
      test: 'price_...',
    },
  },
}

interval is required for subscription and forbidden for payment, so a one-time price with /mo beside it is a type error rather than a code review.

unitAmount is minor units and is what your storefront renders. It is not what Stripe charges: the charge is whatever the price says. Nothing reconciles the two, and a catalog that lies about a price is a storefront that lies about a price.

A feature can be granted by more than one offer. features on the entitlements response is deduplicated, so overlapping grants are one answer written once.

Set a priceId to null for an environment where the offer is not sold. The pricing page renders no card for it, which is better than a button that answers 503.

Warning

A price id may appear under exactly one slug. The reverse lookup is built at module load and asserts it, so a duplicate throws at startup rather than silently granting whichever slug happened to be enumerated first.

Offer a trial

  team: {
    mode: 'subscription',
    interval: 'month',
    trialDays: 14,
    // ...
  },

That one field is the whole trial. Checkout sends it to Stripe as subscription_data.trial_period_days, the subscription arrives trialing, and trialing is one of the statuses user_entitlements grants on, so the features are live before any money moves.

Three days before the trial converts, Stripe fires customer.subscription.trial_will_end and the api mails the subscriber what will be charged and when, linking the billing panel on the dashboard, which is where the reader opens the Stripe portal and cancels. That mail sends once per trial, claimed on the subscription id.

trialDays is forbidden on a payment offer, so a one-time price cannot advertise a trial it has no mechanism for.

Stripe requires at least one day, and a price can carry its own default trial. Keep the trial in the catalog so one file answers how long it is.

Add the card copy

// apps/web/src/lib/catalog/card-copy.ts

const CARD_COPY: Readonly<Record<Slug, CardCopy>> = {
  // ...
  team: {
    name: 'Team',
    tagline: 'Everything in Pro, for a group.',
    bullets: [
      'Everything the one-time offer grants',
      'Seats for your whole team',
      'Billed monthly, cancel any time',
    ],
    cta: 'Start a team plan',
    footnote: 'Holds alongside anything else you have bought.',
  },
}

const CARD_ORDER: readonly Slug[] = ['pro', 'team', 'demo-support']

CARD_COPY is exhaustive over Slug, so this is the second type error, and it is the one that stops a new offer going silently unrendered. An array of cards would have let that happen.

Bullets are prose. There is no crossed-out variant, because crossed-out rows only mean something across mutually exclusive tiers and these offers stack.

CARD_ORDER is presentation, so it lives here rather than in the catalog. A slug missing from it still renders, last, rather than disappearing.

This file sits beside the catalog rather than inside Pricing because the checkout step names the offer being bought with the same words the card that sold it used.

Gate something on the feature

Server side, which is the only side that counts:

const { features } = await readEntitlements(res.locals.user.id)

if (!features.includes('team-seats')) {
  throw new AppError(403, 'forbidden', 'That is included with the Team plan.')
}

Client side, which is a loading state:

<FeatureGate feature="team-seats" fallback={<UpgradePrompt />}>
  <SeatManager />
</FeatureGate>

Whatever is behind the client gate has to be worthless to somebody who forces it open, because the gate only decides what to paint while the reader waits for the api to answer.

Verify

pnpm typecheck
pnpm check
pnpm dev

The pricing block is at /#pricing. A card appears there only if the slug has a non-null price id for the environment VITE_STRIPE_ENV names, so if it is missing, check that value before checking the catalog.

Then buy it in the sandbox with 4242 4242 4242 4242, and watch the webhook land:

stripe listen --forward-to localhost:5001/stripe/webhook

GET /user/entitlements should come back carrying the new feature.

Removing an offer

Deleting a catalog entry leaves everyone who paid that price unentitled, because slugForPriceId returns null and the row is skipped. That is the designed behaviour rather than a bug.

If access has to survive, the entry stays and keeps its price id, because slugForPriceId() is built from exactly those ids and a null removes the row from the reverse lookup along with the card.

That leaves the price id doing two jobs at once, and the shipped code cannot separate them: Pricing renders sellableSlugs(), which is "has a non-null price id", so a dormant entry still draws a card. Stopping the sale means archiving the price in Stripe, which makes the checkout call fail, or adding a sellable: false field to CatalogEntry and filtering on it. The second is a few lines and is the one to write if you expect to retire more than one offer.

  • Pricing catalog, every field
  • Entitlements, what a feature buys
  • docs/adr/0012-slug-major-catalog-carries-prices.md
  • docs/adr/0003-independent-subscriptions-per-customer.md, why offers stack

Updated