Plan entitlements as a derived view
Project Stripe subscription state into a local plan_entitlements table your app reads on every request, instead of calling Stripe on the hot path.
Every authenticated request your app serves makes the same decision: what is this organization allowed to do right now? Render the Pro-only analytics panel or the upgrade prompt; allow a sixth teammate or report that the org is out of seats. That decision runs on the hot path of every page render, thousands of times a day, and the answer lives in Stripe, a network call away and rate-limited. In the webhook ingestion chapter you built the handler that keeps your app in sync with Stripe; now you design what it writes: a small local table the app reads instead of calling Stripe, plus one rule that keeps the table trustworthy: only the webhook may write it.
Why not read plan state from Stripe directly?
Section titled “Why not read plan state from Stripe directly?”Two answers feel right and both fall apart in production. Seeing where each breaks is what makes the table’s design obvious.
The first is to call Stripe on every request: when a page needs the org’s plan, call stripe.subscriptions.retrieve(), read the status, and decide. It’s always correct, since you’re reading Stripe’s freshest value. But it fails for three reasons that stack. Latency: every render now waits on a round-trip to Stripe before it can show anything, adding tens to hundreds of milliseconds to the critical path. Rate limits: Stripe caps calls per second, and a call-per-render pattern blows through that ceiling the moment traffic spikes. Uptime, the most serious: you have made Stripe’s uptime your app’s uptime, so when Stripe blips, every dashboard goes blank on a call you can’t complete.
The second answer is to mirror the whole Stripe Subscription in your database and read the copy. That’s local and fast, but now you own a schema you didn’t design. Stripe adds fields, renames them, and moves data around. You saw this in the Stripe object graph lesson: current_period_end left the Subscription root for the subscription item. Mirror the full shape and that one move forces a migration and a code change, for a field you may never read.
The right answer sits between them: store only the handful of facts a request reads, and refresh them only when Stripe signals a change. That’s a derived view . One rule governs the design: it is read-shaped, not write-shaped, so every column must earn its place by being read on the hot path without a join.
To find that boundary, sort a pile of billing facts into two buckets. For each, ask: who owns this fact at read time?
Sort each billing fact by who owns it at read time — Stripe holds it (you fetch it over the network when you genuinely need it) or your app stores a local copy (read on every request). Drag each item into the bucket it belongs to, then press Check.
stripe_customer_id pointerThe source-of-truth split
Section titled “The source-of-truth split”A single line runs through this design. Stripe owns the billing facts and the full state machine; your app owns a small derived projection of just enough. Two systems, two responsibilities, two separate paths in and out.
The diagram’s left region is Stripe: the Customer, the Subscription with its status machine, the Price catalog, and the invoices. This is the authoritative world, and it changes when a customer pays, upgrades, or cancels. The right region is your database: one plan_entitlements row per org, plus the stripe_customer_id pointer on the organization that links the two worlds. A webhook event is the only thing that flows from Stripe into the projection, and it is the only write path. A read by a feature gate, on every request, is the only thing that flows out. Notice there is no arrow back from the gate to Stripe: Stripe never sits on the read path. Reads and writes travel entirely different routes.
The boundary rule governs everything your app may keep: store a pointer to Stripe (stripe_customer_id) and a projection of Stripe (plan_entitlements), never a copy of Stripe. The pointer tells you which Customer to call when you do need the network. The projection answers the hot-path question without it.
The plan_entitlements schema
Section titled “The plan_entitlements schema”This table is what every gate in your app reads, and we build it one column at a time under a single rule: no column gets added unless some request path reads it. Adding a field “just in case” is the full-mirror mistake creeping back in. So for each column, ask which gate needs it and whether that gate would have to join another table to get it. When the answer is “this gate, and no join,” the column earns its place.
The walkthrough below groups the columns by the question each one answers, so you read each as a consequence of something the app asks. Watch the step on lastEventAt, the one column no feature reads, which the highlight calls out.
export const planEntitlements = pgTable('plan_entitlements', { organizationId: uuid() .primaryKey() .references(() => organizations.id, { onDelete: 'cascade' }), plan: text({ enum: ['free', 'pro', 'team'] }).notNull(), status: text({ enum: ['trialing', 'active', 'past_due', 'canceled', 'incomplete'], }).notNull(), subscriptionId: text(), currentPeriodEnd: timestamp({ withTimezone: true }), cancelAtPeriodEnd: boolean().notNull().default(false), seats: integer().notNull().default(1), lastEventAt: timestamp({ withTimezone: true }), updatedAt: timestamp({ withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()),});
export type PlanEntitlement = typeof planEntitlements.$inferSelect;Identity. There’s one row per org, so organizationId is the projection’s primary key. It’s a cascading FK to organizations because the entitlement is an owned child of the org: onDelete: 'cascade' means deleting the org deletes its entitlement too.
export const planEntitlements = pgTable('plan_entitlements', { organizationId: uuid() .primaryKey() .references(() => organizations.id, { onDelete: 'cascade' }), plan: text({ enum: ['free', 'pro', 'team'] }).notNull(), status: text({ enum: ['trialing', 'active', 'past_due', 'canceled', 'incomplete'], }).notNull(), subscriptionId: text(), currentPeriodEnd: timestamp({ withTimezone: true }), cancelAtPeriodEnd: boolean().notNull().default(false), seats: integer().notNull().default(1), lastEventAt: timestamp({ withTimezone: true }), updatedAt: timestamp({ withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()),});
export type PlanEntitlement = typeof planEntitlements.$inferSelect;The two facts gates read most. plan answers “what tier are you on”; status is the access gate’s input. text({ enum: [...] }) narrows the row type to the literal union while keeping the SQL a checked string, never a Postgres enum, which is a migration headache the course avoids. status is stored as an opaque label; its meaning lands next lesson.
export const planEntitlements = pgTable('plan_entitlements', { organizationId: uuid() .primaryKey() .references(() => organizations.id, { onDelete: 'cascade' }), plan: text({ enum: ['free', 'pro', 'team'] }).notNull(), status: text({ enum: ['trialing', 'active', 'past_due', 'canceled', 'incomplete'], }).notNull(), subscriptionId: text(), currentPeriodEnd: timestamp({ withTimezone: true }), cancelAtPeriodEnd: boolean().notNull().default(false), seats: integer().notNull().default(1), lastEventAt: timestamp({ withTimezone: true }), updatedAt: timestamp({ withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()),});
export type PlanEntitlement = typeof planEntitlements.$inferSelect;The Stripe pointer and the period date. subscriptionId ties the row to its Subscription, read by the webhook and by support tooling; currentPeriodEnd drives the “renews on…” line and next lesson’s grace check. Both are nullable, because a free org has neither, which is the schema admitting that free is a real, first-class state.
export const planEntitlements = pgTable('plan_entitlements', { organizationId: uuid() .primaryKey() .references(() => organizations.id, { onDelete: 'cascade' }), plan: text({ enum: ['free', 'pro', 'team'] }).notNull(), status: text({ enum: ['trialing', 'active', 'past_due', 'canceled', 'incomplete'], }).notNull(), subscriptionId: text(), currentPeriodEnd: timestamp({ withTimezone: true }), cancelAtPeriodEnd: boolean().notNull().default(false), seats: integer().notNull().default(1), lastEventAt: timestamp({ withTimezone: true }), updatedAt: timestamp({ withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()),});
export type PlanEntitlement = typeof planEntitlements.$inferSelect;One banner, one gate. The winding-down banner reads cancelAtPeriodEnd; the invite gate reads seats, checking members.count <= seats before an org adds a teammate. Each column exists because a concrete request path reads it.
export const planEntitlements = pgTable('plan_entitlements', { organizationId: uuid() .primaryKey() .references(() => organizations.id, { onDelete: 'cascade' }), plan: text({ enum: ['free', 'pro', 'team'] }).notNull(), status: text({ enum: ['trialing', 'active', 'past_due', 'canceled', 'incomplete'], }).notNull(), subscriptionId: text(), currentPeriodEnd: timestamp({ withTimezone: true }), cancelAtPeriodEnd: boolean().notNull().default(false), seats: integer().notNull().default(1), lastEventAt: timestamp({ withTimezone: true }), updatedAt: timestamp({ withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()),});
export type PlanEntitlement = typeof planEntitlements.$inferSelect;The ordering guard, the one column no feature reads. The webhook compares an incoming event’s timestamp against lastEventAt and skips anything older, so an out-of-order Stripe delivery can’t drag the row backwards. It exists purely to keep the projection moving forward.
export const planEntitlements = pgTable('plan_entitlements', { organizationId: uuid() .primaryKey() .references(() => organizations.id, { onDelete: 'cascade' }), plan: text({ enum: ['free', 'pro', 'team'] }).notNull(), status: text({ enum: ['trialing', 'active', 'past_due', 'canceled', 'incomplete'], }).notNull(), subscriptionId: text(), currentPeriodEnd: timestamp({ withTimezone: true }), cancelAtPeriodEnd: boolean().notNull().default(false), seats: integer().notNull().default(1), lastEventAt: timestamp({ withTimezone: true }), updatedAt: timestamp({ withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()),});
export type PlanEntitlement = typeof planEntitlements.$inferSelect;Bookkeeping and the row type. updatedAt refreshes on every write, for audit and debugging. typeof planEntitlements.$inferSelect derives PlanEntitlement straight from the table, so the type can never drift from the schema. It’s the type the read helper returns and next lesson’s access check consumes.
Why one row per org, not one per subscription
Section titled “Why one row per org, not one per subscription”Modeling one row per Stripe Subscription feels natural, since Subscriptions are the things that come and go. Resist it. The hot-path question your app asks is “what plan is this org on right now,” and that question has exactly one answer, so it wants a table with exactly one row. Keying by org and storing the current state gives every gate a single-row lookup, with no “which subscription is the active one” logic to get wrong.
Subscription history, the log of past upgrades, downgrades, and cancellations, is a different concern with a different shape. When a feature actually needs it, a billing-history page or churn analytics, you derive it into its own table from Stripe’s events, or read it straight from Stripe for one-off support work. The heuristic is general: hot-path tables model current state, history goes in a separate table. Mix them and the entitlement row has to become append-only or carry effective-date ranges, and your cheap single-row read turns into ordering and date math, on the hot path, on every render.
The single-writer rule
Section titled “The single-writer rule”You met the single-writer rule in webhook ingestion, where it kept the ledger consistent. Here it does something specific: it makes the row trustworthy to read.
Only the webhook handler writes plan_entitlements. Not a Server Action, not the Checkout success page, not the Portal return handler. Every Stripe-side change, a new subscription from Checkout or a plan switch in the Portal, arrives as a webhook event, and the webhook is the one place that turns that event into a write.
That single writer is what makes reads safe. The row is always a faithful projection of the most recently processed Stripe event, so code reading it never has to wonder whether some Server Action half-wrote a value a moment ago. Two writers racing on the same row, plus events that can arrive out of order, would give you a row that flip-flops by who wrote last. One writer plus the lastEventAt guard gives you a row that only moves forward to the latest known state.
You get that guarantee structurally, not by remembering. The only functions that write plan_entitlements live in the webhook path; every other part of the app imports just the read helper, so there’s no write function for a Server Action to reach for. A later discipline lesson shows how a lint rule can enforce this mechanically.
The projection function
Section titled “The projection function”The webhook receives a Stripe Subscription and turns it into a row in your table. That translation, Stripe’s shape becoming your app’s shape, happens in exactly one pure function: a Stripe.Subscription goes in, a patch for the entitlement row comes out, no database and no network. Purity is deliberate, since a pure mapping is trivial to unit-test, which is why the testing unit later in the chapter hangs its tests on this seam. The surrounding plumbing (verifying the signature, claiming the event, running the UPSERT under the lastEventAt guard) you built in the webhook ingestion chapter and it ships in full in the chapter project. This function is the part specific to billing.
We’ll call it subscriptionToEntitlement and walk the mapping field by field.
type EntitlementPatch = { plan: PlanEntitlement['plan']; status: PlanEntitlement['status']; subscriptionId: string; currentPeriodEnd: Date; cancelAtPeriodEnd: boolean; seats: number;};
const subscriptionToEntitlement = ( subscription: Stripe.Subscription,): EntitlementPatch => { const item = subscription.items.data[0]; return { plan: resolvePlan(item.price.id), status: subscription.status, subscriptionId: subscription.id, currentPeriodEnd: new Date(item.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, seats: item.quantity ?? 1, };};The output is a patch, not the whole row, just the writable columns the webhook will UPSERT. It derives plan and status from PlanEntitlement, so the type tracks the table’s unions and can’t drift out of sync with the schema.
type EntitlementPatch = { plan: PlanEntitlement['plan']; status: PlanEntitlement['status']; subscriptionId: string; currentPeriodEnd: Date; cancelAtPeriodEnd: boolean; seats: number;};
const subscriptionToEntitlement = ( subscription: Stripe.Subscription,): EntitlementPatch => { const item = subscription.items.data[0]; return { plan: resolvePlan(item.price.id), status: subscription.status, subscriptionId: subscription.id, currentPeriodEnd: new Date(item.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, seats: item.quantity ?? 1, };};Pure: Stripe shape in, app patch out, no IO. This is the only place in the codebase that touches a raw Stripe.Subscription; later this chapter it moves behind the /lib/billing/ seam, so the shape never leaks past the boundary.
type EntitlementPatch = { plan: PlanEntitlement['plan']; status: PlanEntitlement['status']; subscriptionId: string; currentPeriodEnd: Date; cancelAtPeriodEnd: boolean; seats: number;};
const subscriptionToEntitlement = ( subscription: Stripe.Subscription,): EntitlementPatch => { const item = subscription.items.data[0]; return { plan: resolvePlan(item.price.id), status: subscription.status, subscriptionId: subscription.id, currentPeriodEnd: new Date(item.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, seats: item.quantity ?? 1, };};status is copied verbatim, an opaque label stored with no interpretation. Whether past_due means “warn” or canceled means “wind down” is the next lesson’s job.
type EntitlementPatch = { plan: PlanEntitlement['plan']; status: PlanEntitlement['status']; subscriptionId: string; currentPeriodEnd: Date; cancelAtPeriodEnd: boolean; seats: number;};
const subscriptionToEntitlement = ( subscription: Stripe.Subscription,): EntitlementPatch => { const item = subscription.items.data[0]; return { plan: resolvePlan(item.price.id), status: subscription.status, subscriptionId: subscription.id, currentPeriodEnd: new Date(item.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, seats: item.quantity ?? 1, };};plan resolves through the app’s own price-to-plan map, the stable handle at work. It never hardcodes a price id, since those differ between Stripe’s test and live modes; resolvePlan keys off the durable lookup key from the seeded catalog, so the projection is mode-independent and survives a re-seed unchanged.
type EntitlementPatch = { plan: PlanEntitlement['plan']; status: PlanEntitlement['status']; subscriptionId: string; currentPeriodEnd: Date; cancelAtPeriodEnd: boolean; seats: number;};
const subscriptionToEntitlement = ( subscription: Stripe.Subscription,): EntitlementPatch => { const item = subscription.items.data[0]; return { plan: resolvePlan(item.price.id), status: subscription.status, subscriptionId: subscription.id, currentPeriodEnd: new Date(item.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, seats: item.quantity ?? 1, };};The gotcha, and the highest-value line here. On API 2025-03-31.basil this date lives on the subscription item (item.current_period_end), not the Subscription root; read subscription.current_period_end and you get undefined, silently. That is the most common way a real projection ships broken, a null period date nobody notices until a renewal banner renders empty. The * 1000 converts Stripe’s epoch seconds to the milliseconds Date expects.
type EntitlementPatch = { plan: PlanEntitlement['plan']; status: PlanEntitlement['status']; subscriptionId: string; currentPeriodEnd: Date; cancelAtPeriodEnd: boolean; seats: number;};
const subscriptionToEntitlement = ( subscription: Stripe.Subscription,): EntitlementPatch => { const item = subscription.items.data[0]; return { plan: resolvePlan(item.price.id), status: subscription.status, subscriptionId: subscription.id, currentPeriodEnd: new Date(item.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, seats: item.quantity ?? 1, };};The direct copies. subscriptionId and cancelAtPeriodEnd map straight across with no transformation. seats reads the item’s quantity, defaulting to 1 when Stripe omits it; a Portal-driven seat change arrives as a customer.subscription.updated event, and the projection copies the new quantity into the row the invite gate reads.
Once the patch is built, the webhook UPSERTs it onto the org’s row under the lastEventAt guard, so a late-arriving older event is a harmless no-op. Here we just needed the function that feeds it.
The read side: getEntitlement
Section titled “The read side: getEntitlement”Now the payoff: the single function every gate calls to read the row back out. It’s deliberately small.
import { cache } from 'react';
export const getEntitlement = cache( async (orgId: string): Promise<PlanEntitlement> => { const entitlement = await tenantDb(orgId).query.planEntitlements.findFirst(); // Every org is provisioned a row at creation; a miss is impossible. if (!entitlement) throw new Error(`No entitlement for org ${orgId}`); return entitlement; },);The return type is Promise<PlanEntitlement>, not Promise<PlanEntitlement | null>. Every org gets an entitlement row the moment it’s created (the next section), so this function is guaranteed to return one. The line that looks like a null check isn’t: a missing row is impossible, so the throw is an invariant assertion that a broken provisioning step should fail loudly rather than limp along on a null. Because that guard lives in this one helper, no call site ever has to handle a missing row.
This is the only place the entitlement row gets read, on purpose. When the read lives in one helper, the decisions around it are made once: the caching policy, the tenancy scoping (it closes over tenantDb(orgId), so the read is org-scoped by construction), and the return shape are all settled here rather than at twenty call sites. It lives in db/queries/entitlements.ts alongside the app’s other tenant-scoped read helpers, one file per entity.
A Server Component reads the row and branches on the plan:
const entitlement = await getEntitlement(orgId);
if (entitlement.plan === 'free') { return <UpgradePrompt />;}Note what we are not doing: deciding whether status grants access. That decision (trialing and active allow, past_due warns, canceled is winding down) becomes a hasActiveAccess helper in the next lesson. Today we read the row and check the plan.
Caching the read
Section titled “Caching the read”getEntitlement is wrapped in React’s cache(). The read is already cheap, one small row fetched by primary key, but a single render might call it from several gates at once: the layout checks the plan, a panel checks it again, a button checks the seat count. Without cache(), that’s three identical queries in one render. With it, the first call runs the query and every later call in the same render gets the memoized result.
React’s cache() is request-scoped memoization : it dedupes calls within one render and throws the result away when the render finishes. It is not cross-request caching; the next request, user, and render all start fresh. If you needed the entitlement to persist across requests, that’s a different tool, 'use cache' with tags or Redis, and a different lesson, in per-request memoization with React cache. For the ordinary Server Component path, request-scoped cache() is enough.
A row for every org at creation
Section titled “A row for every org at creation”getEntitlement always returns a row, and no gate checks for null. Here is the move that makes that true.
When a new organization is created, the app inserts its plan_entitlements row in the same step: plan: 'free', status: 'active', seats: 1, with the Stripe-side columns (subscriptionId, currentPeriodEnd) left null. Every org has an entitlement row from the moment it exists, so the read side has nothing to branch on. There is no “row missing” case, because it was eliminated at creation instead of guarded against at every read.
The habit is bigger than this one table: design the data so the bug class can’t exist, rather than catch the bug at every call site. A missing-row null check is easy to add in nine places and forget in the tenth, and the tenth is the production incident. Seed the row at creation and there is nothing left to check.
The free plan: a full row in the app, no row in Stripe
Section titled “The free plan: a full row in the app, no row in Stripe”A free org has no Stripe Subscription: it never went through Checkout, so Stripe doesn’t know it as a customer. But it has a full plan_entitlements row: plan: 'free', status: 'active', no subscriptionId, no currentPeriodEnd. Free is not a missing subscription; it is a complete entitlement that happens to describe the free tier.
The payoff is uniformity. getEntitlement returns the same shape for everyone and only the column values differ, so your gates treat a free org exactly like a paid one, with no if (isFree) fork threaded through your feature code. This is why the Stripe-side columns are nullable.
Re-deriving when the projection logic changes
Section titled “Re-deriving when the projection logic changes”A projection is only as fresh as the last event that wrote it. The day you add a column to subscriptionToEntitlement or change how a field maps, every existing row goes stale, and it stays stale until that org’s next webhook fires. A healthy org on an annual plan might emit no event for weeks. So “I changed the mapping and ran a migration” is not the same as “the rows are correct.”
The fix is a one-shot backfill: a job that walks every org with a non-null stripe_customer_id, fetches its current Subscription from Stripe, re-runs subscriptionToEntitlement, and writes the result. It is the same pure projection function the webhook uses, run in a loop instead of one event at a time. The discipline: a change to projection logic needs a backfill, not just a schema migration (the same instinct behind the expand-backfill-contract pattern you’ll meet later). We won’t write the backfill here, since it’s operational territory the project owns.
Practice: build the entitlements schema
Section titled “Practice: build the entitlements schema”You’ll learn this schema better by building it than by reading it, because its constraints are the lesson: the one-row-per-org rule and the nullable Stripe columns only click once you watch the right inserts pass and the wrong ones fail. The organizations stub is given; complete plan_entitlements as a one-row-per-org projection whose Stripe-side fields are nullable, so a free org fits without a subscription. Then watch the probes: a free row inserts, a fully populated paid row inserts, and a second row for the same org is rejected by the primary key.
Complete plan_entitlements as a one-row-per-org projection of Stripe state: exactly one row per organization (primary key on organization_id), with the Stripe-side fields nullable so a free org needs no subscription. The organizations stub is given.
What your schema produced
Check your understanding
Section titled “Check your understanding”Two rules carry the whole design: a single writer, and a guaranteed row. The question below checks the reasoning behind them, not just the rules themselves.
A teammate worries getEntitlement is unsafe: it returns Promise<PlanEntitlement>, with no | null, yet it does a database read that could come back empty. What actually makes that signature sound?
getEntitlement the row already exists.$inferSelect is structurally incapable of widening to include null.cache() substitutes a default PlanEntitlement whenever the query returns no rows.'free' / 'active' row at the moment it’s created, the “no row” case is designed out of existence rather than handled — which is exactly why no gate downstream ever needs a null check. (The lone if (!entitlement) throw inside the helper guards a programmer error, an invariant that should never fire, not a case callers must handle.)External resources
Section titled “External resources”If you want the canonical field shapes behind the projection, or the precise semantics of the request-scoped read, these are the primary sources.
The authoritative field list the projection maps from — including where current_period_end now lives on the subscription item.
Stripe's end-to-end guide on the webhook events to handle and which subscription fields to store locally.
The request-scoped memoization primitive getEntitlement wraps, and the boundary on what it does and doesn't persist.