Plan entitlements as a derived view
Project Stripe subscription state into a small local plan_entitlements table your app reads on every request, instead of calling Stripe on the hot path.
Every authenticated request your app serves has to make the same decision: what is this organization allowed to do right now? Render the Pro-only analytics panel or the upgrade prompt. Allow the CSV export or block it. Let the org invite a sixth teammate or tell them they’re out of seats. That question gets asked thousands of times a day, on the hot path of every page render, and the answer lives in Stripe, which is 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. The answer is a small local table the app reads instead of calling Stripe, plus one rule that keeps the table trustworthy: only the webhook is allowed to write it.
Why not just ask Stripe?
Section titled “Why not just ask Stripe?”Before we design the table, it helps to look at the two answers that feel right and see exactly where each one falls apart. The shape of the solution only makes sense once you understand the problem it’s solving.
The first tempting answer is to call Stripe on every request. When a page needs to know the org’s plan, call stripe.subscriptions.retrieve(), read the status, and decide. It’s always correct, because Stripe is the source of truth and you’re reading the freshest possible value. But in production it fails for three reasons that stack.
The first is latency. Every page render now waits on a round-trip to Stripe’s API before it can decide what to show. That round-trip sits directly on the critical path of your dashboard, adding tens to hundreds of milliseconds to a render that should have been instant. The second is rate limits: Stripe caps how many API calls you can make per second, and a request-per-render pattern blows through that ceiling the moment traffic spikes, exactly when you can least afford to start failing. The third is the most serious: you have just made Stripe’s uptime your app’s uptime. When Stripe has a blip, every dashboard in your product goes blank, because every render is gated on a call you can’t complete. A billing provider’s hiccup should never take down a customer’s ability to see their own data. A third party on your read path is a single point of failure you don’t control.
The second tempting answer is to mirror the whole Stripe Subscription in your database. If calling Stripe per request is too slow, copy the Subscription object into your own table and read the copy. That’s faster and local, but now you own a schema you didn’t design. Stripe adds fields, renames things, and moves data around. You saw exactly this in the Stripe object graph lesson: current_period_end is no longer on the Subscription root, it moved onto the subscription item. If you had mirrored the full shape, that move forces a migration and a code change you have to chase, for a field you might not even read. You’d be tracking dozens of columns just to keep parity with a schema that changes on Stripe’s schedule, not yours. Don’t mirror a schema you don’t own.
The right answer sits between the two. Don’t ask every time, and don’t copy everything. Store only the handful of facts a request actually reads, and refresh them only when Stripe tells you something changed. That’s a derived view : a projection of Stripe’s state into a shape your app can read locally and instantly. One rule governs the entire design: a derived view is read-shaped, not write-shaped, so every column has to earn its place by being read on the hot path without a join.
Before we build the table, let’s pin down the boundary it lives on. The exercise below sorts a pile of billing facts into two buckets: things Stripe owns and you read over the network when you truly need them, and things your app stores locally and reads on every request. For each item, 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 clean line runs through this whole design, and naming it now makes every later decision feel obvious. Stripe owns the billing facts and the full state machine. Your app owns a small derived projection of just enough. Two systems, two responsibilities, and two separate paths in and out.
In the diagram below, the left region is Stripe: the Customer, the Subscription with its status machine, the Price catalog, and the invoices. That’s the authoritative world, and it changes when a customer pays, upgrades, or cancels. The right region is your database: a single plan_entitlements row per org, plus the stripe_customer_id pointer on the organization that links the two worlds. The only thing that flows from Stripe into your projection is a webhook event, and that is the only write path. The only thing that flows out of the projection is a read by a feature gate, on every request. Notice what’s missing: there is no arrow from a feature gate back to Stripe. Stripe never sits on the read path. Reads and writes flow through entirely different routes, and that separation is the whole point.
The boundary rule governs everything your app is allowed to keep, so state it plainly: the app stores 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 talk to 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 the center of the lesson, the thing every gate in your app reads. We’ll build it one column at a time, under a single rule: no column gets added unless some request path reads it. If you find yourself adding a field “just in case,” that’s the full-mirror mistake creeping back in. So for each column, ask two questions: which gate needs this, and would it have to join another table to get it? When the answer is “this gate, and no join,” the column earns its place.
Start with identity. The hot-path question is “what is this org allowed to do,” so the projection is keyed by org:
organizationIdis the primary key, and a foreign key toorganizations. There’s one row per org, and this column is the identity of the projection. (Why one row and not one per subscription is a real decision, justified in the next section.)
Now the two facts almost every gate reads:
planis which tier the org is on:'free' | 'pro' | 'team'. Every dashboard and every paywall reads it to answer “what tier are you on.” We model it as a string-literal union so the row type narrows to those three values, and at the database layer it’s a checked string, never a Postgresenum(enums are a migration headache when the set changes, and the course avoids them entirely).statusis the Stripe-derived status string:trialing,active,past_due,canceled, and so on. The access gate reads it. The important part: this lesson stores the status label, but it does not assign it meaning. Whetherpast_duemeans “let them in with a warning” orcanceledmeans “downgrade at period end” is the access decision, and that is the entire subject of the next lesson, on subscription status as first-class state. For now, treat every status value as an opaque label you faithfully store. We type it as a string-literal union of Stripe’s values, but we attach no behavior to those values yet.
A pointer and the dates the billing UI surfaces:
subscriptionIdis the Stripe Subscription’s id. It’s nullable, because a free org has no Subscription at all. The webhook reads it to figure out which org an event belongs to, as does any “open this customer in Stripe” support tooling.currentPeriodEndis atimestamptz, nullable. The “renews on…” or “ends on…” line in the billing screen reads it, and (next lesson) so does the grace-period check when a subscription is winding down.cancelAtPeriodEndis a boolean. The banner that warns a user their subscription is scheduled to end reads it. You met this flag in the Portal lesson; here it gets a home.seatsis an integer, for seat-based plans. The invite gate reads it: when someone adds a teammate, the app checksmembers.count <= seats. The membership model and that gate were built back in the organizations chapter, and this column is just the number that gate reads.
And two bookkeeping columns that the app writes but no gate reads directly:
lastEventAtis atimestamptz. It’s the ordering guard from the single-writer lesson: the webhook only applies an event whose timestamp is newer than the one already stored, so a Stripe delivery that arrives out of order can’t drag the row backwards. It’s the same predicate you already know, and it just needs a column to compare against.updatedAtis atimestamptzthat defaults to now and refreshes on every write, for audit and debugging.
That’s the whole table. The walkthrough below groups the fields by the question each one answers, so you see each field as a consequence of something the app asks rather than a flat list of ten columns. Watch the step on lastEventAt, the one column no feature reads at all, 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: [...] }) keeps the SQL a checked string while narrowing the row type to the literal union, never a Postgres enum. Here 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 is the Subscription’s id; currentPeriodEnd drives the “renews on…” line. 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 letting an org add 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. That’s the type the read helper returns and next lesson’s access check consumes, so schema and type can never drift.
A note on the row type. We never hand-write an interface for this row; typeof planEntitlements.$inferSelect derives it straight from the table, so the type and the schema can never drift. We export it as PlanEntitlement because it’s about to become the return type of the one function every gate calls.
Why one row per org, not one per subscription
Section titled “Why one row per org, not one per subscription”It’s tempting to model this as one row per Stripe Subscription, since Subscriptions are the things that come and go, so a row each feels natural. 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. A question with one answer wants a table with 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 subscriptions, upgrades, downgrades, and cancellations, is a genuinely different concern with a different shape. When a feature actually needs it (a billing-history page, churn analytics), you derive it into its own table from Stripe’s events, or you read it straight from Stripe for one-off support work. You never cram it into the entitlement row. The general heuristic is worth keeping: hot-path tables model current state, and history goes in a separate table. Mix the two and the entitlement row has to become append-only or carry effective-date ranges, and the moment it does, your cheap single-row “what plan is this org on” read turns into a query with ordering and date math, on the hot path, on every render. Keep current state small and current.
The single-writer rule
Section titled “The single-writer rule”You met this rule in the single-writer lesson, where it kept the ledger consistent. It’s worth restating here because in this table it does something specific: it’s what makes the row trustworthy to read.
The rule is blunt: 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, whether a new subscription from Checkout or a plan switch in the Portal, eventually arrives as a webhook event, and the webhook is the single place that turns that event into a write.
Here’s why that single writer matters so much on the read side. Because there is exactly one writer, the row is always a faithful projection of the most-recently-processed Stripe event. Code reading the row never has to wonder “is this value stale because some Server Action half-wrote it a moment ago?” Compare that to the dual-writer race from the single-writer lesson: two writers racing on the same row, plus events that can arrive out of order, give you a row that flip-flops between states depending on who wrote last. One writer, plus the lastEventAt guard that ignores stale events, gives you a row that only ever moves forward to the latest known state. Reads are trustworthy precisely because writes are funneled.
And you get that guarantee structurally, not by remembering. You don’t keep a mental note to never write the table from the wrong place; you arrange the code so the wrong place can’t. 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 even reach for. (The discipline lessons later in the course show how a lint rule can mechanically enforce “no Server Action writes this table,” the same flavor of audit you may have seen guard the authedAction wrapper. That tooling isn’t our concern today; the point is that the constraint is enforceable, not aspirational.)
The projection function
Section titled “The projection function”The webhook receives a Stripe Subscription object and has to turn it into a row in your table. That translation, Stripe’s shape becoming your app’s shape, happens in exactly one function, and keeping it in one place is the whole reason the rest of the system can stay simple. We’ll call it subscriptionToEntitlement, and we’ll look at it at the level of its shape: the field-by-field mapping. The surrounding plumbing (verifying the webhook signature, claiming the event in the processed_events ledger, running the UPSERT, applying the lastEventAt guard) you built in the webhook ingestion chapter, and it ships in full in the chapter project. This function is the part that’s specific to billing.
It’s a pure function: a Stripe.Subscription goes in, a patch for the entitlement row comes out, and it touches no database and no network. That purity is deliberate, because a pure mapping is trivial to unit-test, which is why the testing unit later in the chapter hangs its tests on this seam. Walk the mapping field by field below.
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 onto the org’s entitlement. It derives plan and status from PlanEntitlement, so this type tracks the table’s unions automatically and can never 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. That purity is what makes it trivially unit-testable. It’s also the only place in the codebase that touches a raw Stripe.Subscription; later this chapter it moves behind the /lib/billing/ seam, so this 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. The raw Stripe status string goes straight onto the patch with no interpretation. Whether past_due means “warn” or canceled means “wind down” is the next lesson’s job; here it’s an opaque label you faithfully store.
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 map, the stable handle at work. It never hardcodes a price id (those differ between Stripe’s test and live modes). resolvePlan looks up a price the app already knows from its seeded catalog, keyed off the durable lookup key, so the projection is mode-independent and survives a re-seed with no code change.
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. 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 remaining direct copies. subscriptionId and cancelAtPeriodEnd map straight across from the Subscription with no transformation, the boring fields that round out the patch the webhook is about to write. seats reads the item’s quantity, the seat count for a seat-based plan, defaulting to 1 when Stripe omits it.
Two lines are worth slowing down on, plan and currentPeriodEnd, for opposite reasons.
plan is where the stable handle idea from the object-graph lesson earns its keep. The function doesn’t compare against a hardcoded price id, because those differ between Stripe’s test and live modes, so hardcoding them would tie your projection to one mode. Instead it resolves the plan through the app’s own price-to-plan map, the one built from the catalog you seeded around stable lookup keys. The lookup key is the durable name, the map turns a price the app already knows into a plan slug, and the projection comes out mode-independent.
currentPeriodEnd is the correctness trap. Reach for subscription.current_period_end, the obvious place, and you get undefined, silently, on the current Stripe API version. The value moved onto the subscription item, so it’s item.current_period_end. This single line is the most common way a real entitlement projection ships broken, with a null period date nobody notices until a renewal banner renders empty. The * 1000 is the unit conversion: Stripe speaks epoch seconds, and JavaScript’s Date wants milliseconds.
seats is the other field that reads off the item, not the Subscription root: a single-item subscription carries the seat count as item.quantity, and ?? 1 covers the plans that never set a quantity at all. This is the line that keeps the seats column current: 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, guarded by that lastEventAt comparison so a late-arriving older event is a harmless no-op. You’ve seen that machinery; here we just needed the function that feeds it.
The read side: getEntitlement
Section titled “The read side: getEntitlement”Everything so far has been about getting the right values into the row. Here’s the payoff: the single function every gate calls to read them 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; },);Look at the return type: Promise<PlanEntitlement>, not Promise<PlanEntitlement | null>. That’s not an oversight; this function is guaranteed to return a row, because every org gets an entitlement row the moment it’s created (the next section). The one line that looks like a null check isn’t one: a missing row here is impossible, so it’s an invariant assertion that signals a programmer error, not a case any caller has to handle. It throws because if it ever fired, your provisioning is broken and you want to know loudly rather than limp along on a null. And because the guard lives inside this single helper, it deletes the null branch from every call site in the app. No gate ever has to ask “what if the row is missing,” because the one place that could ask already answered.
This is the only place the entitlement row gets read, on purpose. One well-named helper, called from every gate and never replaced by an ad-hoc db.select at the call site, because when the read lives in one place, the decisions that surround it are made once. The caching policy, the tenancy scoping (notice it closes over tenantDb(orgId), so the read is org-scoped by construction), and the return shape are all decided here, not re-decided at twenty call sites. It lives in db/queries/entitlements.ts alongside the app’s other tenant-scoped read helpers, one file per entity, matching the layout the rest of the project follows.
A Server Component uses it the way you’d hope, reading the row and branching on the plan:
const entitlement = await getEntitlement(orgId);
if (entitlement.plan === 'free') { return <UpgradePrompt />;}Note what we are not doing here: deciding whether status grants access. That decision (trialing and active allow, past_due warns, canceled is winding down) gets wrapped into a hasActiveAccess helper in the next lesson, on subscription status as first-class state. Today we read the row and check the plan, and the status decision waits for the next lesson.
Caching the read
Section titled “Caching the read”You’ll notice getEntitlement is wrapped in React’s cache(). The read itself is already cheap, since plan_entitlements is one small row fetched by primary key, but a single page 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. Wrapped in cache(), the first call runs the query and every later call in the same render gets the memoized result. Three gates, one query.
Be precise about what cache() does, because this trips people up. React’s cache() is request-scoped memoization : it dedupes calls within one request’s render and then throws the result away. It is not cross-request caching. The next request, the next user, and the next render all start fresh. If you ever needed the entitlement to persist across requests (it’s in a tight loop, or you’re under a strict per-request query budget), that calls for 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 exactly enough, and reaching past it is premature.
Every org gets a row at creation
Section titled “Every org gets a row at creation”Twice now we’ve leaned on a claim: getEntitlement always returns a row, and no gate ever checks for null. Here’s the move that makes it true. It’s small enough to miss and important enough to underline.
When a new organization is created, the app inserts its plan_entitlements row right then, in the same step: plan: 'free', status: 'active', seats: 1, and the Stripe-side columns (subscriptionId, currentPeriodEnd) left null. Every org therefore has an entitlement row from the very first moment it exists, which means the read side has nothing to branch on. There is no “row missing” case to handle, because the case was eliminated at creation time rather than guarded against at every read.
This is a habit worth internalizing, and it’s bigger than this one table: design the data so the bug class can’t exist, instead of handling the bug at every call site. A missing-row null check is the kind of thing that’s 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’s no check to forget, because there’s nothing 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”This seeding move and the free-plan model are two faces of one idea, so look at them together. A free org has no Stripe Subscription: it never went through Checkout, so Stripe doesn’t know it exists as a paying customer. But it has a full plan_entitlements row: plan: 'free', status: 'active', no subscriptionId, no currentPeriodEnd. From the app’s point of view, free is not a missing subscription, it’s a complete entitlement that just happens to describe the free tier.
The payoff is uniformity. Your gates treat a free org exactly like a paid one, because getEntitlement hands back the same shape for everyone, and only the column values differ. There’s no if (isFree) … else … fork threaded through your feature code. The principle: the same code path serves free and paid, because free isn’t a special case, just a particular set of column values. This is why the Stripe-side columns are nullable, and it’s what lets getEntitlement stay a single uniform read.
Re-deriving when the projection changes
Section titled “Re-deriving when the projection changes”One reality to name before you ship this, because it’s a problem that only surfaces weeks later. 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 is stale, still holding the old shape, and it stays stale until the next webhook fires for that org. A healthy paid org humming along on an annual plan might not emit a single 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 on it, and writes the result. It’s the same projection function the webhook uses, run in a loop instead of one event at a time, which is exactly why keeping that logic in a single pure function pays off. The discipline to remember: a change to projection logic needs a backfill, not just a schema migration. (This is the same instinct behind the expand-migrate-contract pattern you’ll meet later in the course, where you evolve a live schema safely without an outage.) We won’t write the backfill here, since it’s operational territory the project owns, but knowing it has to exist is the part that saves you.
Practice: build the entitlements schema
Section titled “Practice: build the entitlements schema”You’ll understand this schema far better by building it than by reading it, and the constraints are the lesson: the one-row-per-org rule and the nullable Stripe columns only become real when you watch the right inserts pass and the wrong ones fail. In the exercise below, the organizations stub is given; your job is to complete plan_entitlements as a one-row-per-org projection whose Stripe-side fields are nullable so a free org fits without them. Build the columns, then watch the probes: a free row with no subscription should insert cleanly, a fully populated paid row should insert, and a second row for the same org should be 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: the single writer and the guaranteed row. The question below checks that the why behind each landed, not just the what.
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.