All pages

Entitlements

There is no entitlements table, no plan column, and no field anywhere in this model that says what somebody bought. What a user may do is computed, every time it is asked for, from the Stripe price ids on the rows they paid for.

The derivation runs in two halves, and the split is deliberate.

The user_entitlements view is a UNION ALL over purchases and subscriptions, filtered to rows that have a user and, for subscriptions, to the active, trialing and past_due statuses. It yields a user id, a stripe_price_id, a source of purchase or subscription, a timestamp, and the refund state of the purchase behind the row. It stops there.

The price id to offer mapping happens in TypeScript, in the catalog. That is the second half, and it is not in SQL because the catalog is a TypeScript file: putting the mapping in the view would mean a catalog edit needed a migration.

for (const row of rows) {
  const slug = slugForPriceId(row.stripePriceId)
  if (slug === null) continue

  offers.push({ slug, source: row.source, since: row.since.toISOString() })

  for (const feature of CATALOG[slug].features) features.add(feature)
}

Changing what an offer grants is one literal in packages/shared/src/catalog.ts, and it applies retroactively to everyone who paid that price.

Features and offers are two lists

GET /user/entitlements answers with both:

{
  "features": ["boilerplate", "lifetime-updates"],
  "offers": [
    { "slug": "pro", "source": "purchase", "since": "2026-08-14T09:12:44.000Z" }
  ]
}

features is deduplicated, because two paid objects can grant the same feature and features.includes('boilerplate') twice over is the same answer written twice. It is what your code gates on.

offers has one entry per paid Stripe object rather than one per slug. A buyer who bought the same offer twice paid twice, and since is what makes the two rows distinguishable.

Features are machine keys, not the bullets on the pricing card. CARD_COPY in apps/web/src/components/Pricing/Pricing.tsx holds prose that needs no correspondence to a Feature, and the two are kept apart so marketing copy never ends up in the file the api reads.

The api is the only thing that answers

There is no Clerk publicMetadata mirror and no entitlement claim in the session token.

A mirror is written after the payment lands, read from a token minted before it, and refreshed only when the session is. So a reader who just paid fails the check, and a reader whose subscription just ended passes it. Both windows are invisible at the call site, which is what makes them expensive to debug.

The price of not having them is a request. useEntitlements() fetches, so there is a spinner, which is what asking a question over the network looks like.

Careful

A client-side entitlement check is a loading state, not a security boundary. data.features arrived over the wire and can be edited in a devtools console. Whatever is behind <FeatureGate> must be worthless to someone who forces it open: a heading, a link, or a button that calls an api route the api itself checks. Never a download URL, a license key, or a secret.

FeatureGate's error branch renders the api's message and does not fall through to the fallback. "You have not bought this" and "the api could not be reached" are two different sentences, and showing the first when the second is true tells a paying reader that they did not pay.

Read the component at apps/web/src/components/Dashboard/FeatureGate/FeatureGate.tsx for its loading, error and denied-access branches.

The cases that bite

A price that leaves the catalog leaves its holders unentitled. slugForPriceId returns null and the loop skips the row. That is the designed behaviour rather than a hole: it must not crash, and it must not fall back to "some offer", which would be a client-supplied product name under a new name.

If access has to survive a price change, keep the old price id in the catalog as a dormant entry. FFD needs zero of those today, because it has never taken a live payment.

A purchase entitles from the moment it is recorded. Access is the grant, and nothing else has to happen first.

A subscription entitles while it is active, trialing or past_due. unpaid, canceled, incomplete, incomplete_expired and paused all fall out of the view.

past_due grants because it is a warning window rather than an outcome: Stripe retries a failed renewal for up to two weeks, and revoking on the first failed charge would end access while the payment is still being recovered. Revocation lands on canceled or unpaid, once the retries are done. A product that wants strict revocation instead removes past_due from the inArray in the view and generates a migration.

past_due is also what the warning mail is for. invoice.payment_failed re-fetches the invoice's subscription and, when it reads past_due, sends one mail per failed invoice saying that access continues while Stripe retries, that it ends if the retries fail, and linking the dashboard where the card is updated. The mail claims a row in email_deliveries keyed on the invoice id, so the four Smart Retry deliveries produce one warning.

The revocation mail closes that window. syncSubscription re-fetches the subscription on every customer.subscription.* event, and when the status it writes is canceled or unpaid, it mails the owner the date access ended and a link back through checkout. It claims email_deliveries on the subscription id, so one subscription produces one revocation mail whichever party ended it, and a redelivered event finds the row taken instead of reading the previous status. paused still withdraws access silently, which docs/adr/0032-mail-follows-entitlement-not-invoices.md records as a known gap rather than a decision.

A full refund revokes. A partial refund does not. charge.refunded is handled. The handler re-fetches the Charge, sets purchases.refunded_at when Stripe's own refunded flag reports a full refund, and records amount_refunded either way. The entitlement read then skips any row whose refunded_at is set, so the offer and the features it granted are gone from the next answer.

The switch is REFUND_REVOKES_ACCESS in apps/api/src/lib/entitlements/read-entitlements.ts, and it ships enabled. A product that keeps access after a refund sets it to false. It is a constant rather than an environment variable, so changing the policy is a commit.

Both columns are rewritten from the re-fetched Charge on every delivery, so a reversed or failed refund clears them and access returns with no manual repair. The view selects refunded_at and does not filter on it; the api's single read applies the policy.

Revocation stops at the entitlement. A refund removes what the api grants and nothing outside it. Both billing surfaces show refunded_at and amount_refunded on the row instead, so support and the refunded buyer read the same reason access ended, and refunding in Stripe is the revoke control.

A refunded charge that matches no purchase is logged and ignored. Subscription access stays governed by status alone, so cancelling in Stripe is what removes a subscriber's access, and the subscriber does that in the Customer Portal that POST /user/billing-portal opens. charge.dispute.* is not handled, and docs/adr/0030-refund-revokes-a-derived-grant.md records why each of those lines falls where it does.

What this costs you

One query per check. Against a view keyed by user, on the row counts a single product produces, that is cheap. It is still a query, and a page that gates six things either asks once and passes the answer down or asks six times.

user_entitlements alone no longer answers who has access. The api's read applies the refund policy over the view, so a query you run by hand in psql has to apply the same predicate to agree with the app.

The browser caches display state. TanStack Query inherits a 60-second stale time from apps/web/src/providers.tsx. The api remains the enforcement authority on every protected request. A cached UI and a payment record waiting for its webhook can both lag Stripe; removing the Clerk mirror does not eliminate those delays.

Complexity beyond a union and a filter outgrows the view. Seat counts, usage limits and per-organization entitlement do not fit in this shape. When they arrive you materialize the derivation into a table; the rule that entitlement comes from the price actually paid survives that change, and the view is what gets replaced.

Where the decisions live

  • docs/adr/0005-entitlement-derived-from-price-paid.md, why there is no plan field
  • docs/adr/0006-api-is-the-sole-entitlement-authority.md, why nothing else holds a copy
  • docs/adr/0012-slug-major-catalog-carries-prices.md, how a price id maps back to an offer
  • docs/adr/0030-refund-revokes-a-derived-grant.md, why a refund revokes and where the switch lives
  • docs/adr/0032-mail-follows-entitlement-not-invoices.md, why past_due grants for the length of Stripe's retry window

How the payment records get written in the first place is Webhooks.

Updated