Skip to content
Chapter 64Lesson 6

The thin billing interface

Collapse your scattered Stripe code into one module behind a deliberately tiny three-method interface, fronted by the requirePlan paywall gate that guards every paid surface.

Take stock of what the last five lessons left you with. You wrote a configured Stripe client. You wrote an upgrade action that mints a Checkout URL, and an openPortal action that mints a Portal URL. You wrote getEntitlement, the one read that pulls an org’s plan row out of the database, and hasActiveAccess, the predicate that turns its status into a yes-or-no. That is five useful pieces, and they sit in five different files, with nothing tying them together except that they all happen to be about billing.

By itself that is not a problem. Five related functions in five files is just a codebase. The question to ask isn’t “should I group these because they feel related,” because grouping things by how related they feel is how you end up with a utils/ folder nobody can navigate. The real question is whether something concrete is about to make the scatter cost you. Something is.

Your app is about to grow a paywall: a Pro-only export page, a Team-only seats screen, a paid API route. Each one needs the same gate at the very top, asking is this org allowed to be here, on this tier, right now? before a single byte of paid content renders. That check is identical at every privileged surface, it’s about money, and when someone forgets it a free user walks straight into a paid feature. That is what makes the scatter expensive, and it is what this lesson builds: a directory, /lib/billing/, that becomes the one place the Stripe SDK lives; a deliberately tiny billing.* interface in front of it; and the gate, requirePlan, that earns the whole arrangement.

That shape should sound familiar, because you built it once already for authorization. Back in the organizations work you wrapped every privileged Server Action in authedAction because the role check was identical boilerplate everywhere, forgetting it was a security hole, and a missing check needed to be something you could find. Billing is the same structural move against the same kind of bug, with revenue on the line instead of permissions. By the end of this lesson you’ll understand why billing is one of only two places this course ever wraps a vendor’s SDK.

Start with the rule, because everything else hangs off it:

The Stripe SDK is imported in exactly one directory, /lib/billing/. Everywhere else in the app imports from billing.

Said another way, the stripe client is a transitive dependency ((A dependency your code reaches only through another module, never by importing it directly. Your route imports billing; billing imports stripe; the route never names stripe itself.)) of your application. A route handler, a Server Action body, a Server Component: none of them ever import Stripe or import { stripe }. They import requirePlan, or upgrade, or openPortal, and those functions reach the SDK on their behalf. The moment a file outside /lib/billing/ imports the Stripe client directly, the seam is gone and so is everything it buys you.

You were told this rule already. Back when you wrote openPortal, the note said the stripe client is only ever imported inside /lib/billing/, and left the why for later. This is later.

Most of what goes in that directory already exists. You’re not writing five new files; you’re collecting four you already have and adding one. Here’s where each piece lands.

  • Directorylib/
    • Directorybilling/
      • stripe.ts import 'server-only': the only file that imports the Stripe SDK
      • upgrade.ts 'use server': billing.upgrade (Checkout)
      • portal.ts 'use server': billing.openPortal (Portal)
      • require-plan.ts import 'server-only': the paywall gate (this lesson)
      • billing-error.ts BillingError: the domain error these throw
      • index.ts the re-export surface, this is billing.*
    • result.ts Result<T>, ok, err
  • Directorydb/
    • Directoryqueries/
      • entitlements.ts getEntitlement(orgId): tenant-scoped read, not a Stripe call
One seam: `/lib/billing/` owns the Stripe SDK and the gate; the entitlement read stays in `db/queries/`, across the line.

A couple of things in that tree are worth saying out loud.

The stripe.ts file is not new. You wrote it in the first lesson of this chapter as lib/stripe.ts: a client instantiated once with the secret key, pinned to a fixed apiVersion, fronted by import 'server-only'. This lesson moves it into /lib/billing/, its real home, where the Stripe adapter belongs alongside the actions and the gate that use it. It’s the same client, relocated, not a second one. Like the gate require-plan.ts, it opens with import 'server-only', so a stray import from a Client Component becomes a build error instead of a leaked secret. The two session files are the exception: upgrade.ts and portal.ts carry 'use server' instead, because they’re Server Actions a client does call. Even so, they never expose the SDK, only the URLs it mints.

The read helper, by contrast, deliberately stays out of the directory. getEntitlement lives in db/queries/entitlements.ts, where you wrote it, because it is a tenant-scoped database read, not a Stripe call. This is the honest line of the seam: /lib/billing/ owns the Stripe SDK and the gate that fronts it; db/queries/ owns the projection read. require-plan.ts will import getEntitlement across that line, which is exactly right. The temptation to drag getEntitlement into the billing folder “to keep all the billing stuff together” is the same instinct as the utils/ folder, so resist it. The directory’s job is Stripe calls and the gate, not everything with the word billing in it.

That leaves index.ts, and it needs a note, because the project’s conventions forbid barrel files in lib/, and this looks like one.

It isn’t. A barrel that defeats tree-shaking is an export * over a pile of loosely related modules, dragging unrelated code into every bundle that touches any of it. What index.ts does here is narrower and deliberate: it is the public surface of the billing module, re-exporting exactly the three methods callers are allowed to use, each one named explicitly.

lib/billing/index.ts
// The public surface of the billing module. Import 'billing' from here;
// the Stripe client and BillingError stay internal to this directory.
export { upgrade } from './upgrade';
export { openPortal } from './portal';
export { requirePlan } from './require-plan';

Three named re-exports, a comment saying why the file exists, and nothing else: not the stripe client, not BillingError. Those stay internal to the directory. This is the sanctioned exception to “no barrels”: a small module with a stable, named public API gets one explicit door. The rule it’s an exception to exists to stop unrelated code from leaking through accidental re-exports, and a hand-curated three-line door is the opposite of that.

With the directory settled, the only piece missing is the one new method, the one that pays for all of this.

requirePlan: the gate that earns the wrapper

Section titled “requirePlan: the gate that earns the wrapper”

Here is the situation the gate exists for. A privileged surface, say a Pro-only export page, needs to answer a question before it renders anything: is this org entitled to this tier, right now? That question is really two questions stacked on top of each other.

The first is access: can this org get into paid features at all? You already have the answer in hasActiveAccess(entitlement), the predicate from the last lesson that reads the subscription status and returns true for trialing, active, and past_due, false for the rest. The second is tier: even if they’re in, are they on a high enough plan for this particular feature? A healthy Pro org has access, but the Team-only seats screen still isn’t theirs. Tier is an ordering, where team outranks pro outranks free, and access alone can’t tell you where on that ladder an org sits.

A paywall ((A gate that blocks a feature behind a paid plan tier. Below the line, the feature is invisible or replaced by an upgrade nudge; above it, the feature renders normally.)) has to clear both checks. requirePlan is the one call that does.

requirePlan(planSlug: 'pro' | 'team'): Promise<void>

It takes the tier a surface requires, resolves the current org, reads that org’s entitlement, and throws if access is denied or the tier is too low. On success it returns nothing, Promise<void>, because the success case is simply “carry on rendering” and there’s no value to hand back. The shape is borrowed straight from requireOrgUser(), which you already scatter at the top of server code: a guard that either lets you proceed or throws you out, never something you branch on.

One property is non-negotiable, and you’ve met it before in this chapter: the gate reads the local entitlement row, never Stripe. It calls getEntitlement(orgId), which hits your plan_entitlements table for one small indexed row, and never stripe.*. That’s the hot-path rule the whole chapter has been built on: Stripe is the source of truth for billing facts, but you do not call Stripe on the request path. The projection is what you read. This is why requirePlan can sit at the top of every protected page without a second thought: it’s a primary-key lookup, request-scoped and cached, not a network round-trip to Stripe on every render.

The tier check, the same shape you built for roles

Section titled “The tier check, the same shape you built for roles”

The access half of the gate is done, since hasActiveAccess owns it. The tier half needs one small new piece, and you’ve built its twin already.

Back when you built role checks, you didn’t compare them with a tangle of ||s. You gave each role a rank and compared the numbers: ROLE_RANK mapped owner, admin, member to integers, and roleAtLeast asked whether one rank met or beat another. Plans are the identical problem, an ordered ladder where “at least this tier” is the real question, so they get the identical solution.

type Plan = 'free' | 'pro' | 'team';
const PLAN_RANK = { free: 0, pro: 1, team: 2 } as const satisfies Record<Plan, number>;
const planAtLeast = (plan: Plan, required: Plan): boolean =>
PLAN_RANK[plan] >= PLAN_RANK[required];

This is ROLE_RANK / roleAtLeast with the nouns swapped. PLAN_RANK is the ladder as data: as const keeps its values the literal 0, 1, 2, and satisfies Record<Plan, number> means the day you add a plan the map won’t compile until it’s ranked. planAtLeast('team', 'pro') is true; planAtLeast('pro', 'team') is false. If the role version already lives in your head, you already know how this reads, which is the entire point of mirroring it. The ordering of your plans is now stated once, and “is this tier high enough” is a single comparison instead of a condition someone can get subtly wrong.

Now put the two halves together. The whole gate is a resolve, a read, and two refusals, and every piece except the tier comparison is something you already own. Walk through it.

import 'server-only';
import { getEntitlement, hasActiveAccess } from '@/db/queries/entitlements';
import { BillingError } from './billing-error';
export const requirePlan = async (
planSlug: 'pro' | 'team',
): Promise<void> => {
const { orgId } = await requireOrgUser();
const entitlement = await getEntitlement(orgId);
if (!hasActiveAccess(entitlement)) {
throw new BillingError('no_access', 'Your subscription is inactive.');
}
if (!planAtLeast(entitlement.plan, planSlug)) {
throw new BillingError('plan_required', `Upgrade to ${planSlug} to continue.`);
}
};

Resolve the org. requireOrgUser() is the same server-side reflex from the organizations work: it returns { user, orgId, role } and throws to the framework boundary if there’s no session or no org. Authenticating first means the gate can never run on an anonymous request. You destructure orgId straight off the result, since that’s the only field the gate needs.

import 'server-only';
import { getEntitlement, hasActiveAccess } from '@/db/queries/entitlements';
import { BillingError } from './billing-error';
export const requirePlan = async (
planSlug: 'pro' | 'team',
): Promise<void> => {
const { orgId } = await requireOrgUser();
const entitlement = await getEntitlement(orgId);
if (!hasActiveAccess(entitlement)) {
throw new BillingError('no_access', 'Your subscription is inactive.');
}
if (!planAtLeast(entitlement.plan, planSlug)) {
throw new BillingError('plan_required', `Upgrade to ${planSlug} to continue.`);
}
};

Read the local entitlement. getEntitlement(orgId) hits the plan_entitlements table for one indexed row, and never Stripe. This is the hot-path rule made concrete: the gate reads the projection, not the source of truth, which is what lets it run cheaply at the top of every page.

import 'server-only';
import { getEntitlement, hasActiveAccess } from '@/db/queries/entitlements';
import { BillingError } from './billing-error';
export const requirePlan = async (
planSlug: 'pro' | 'team',
): Promise<void> => {
const { orgId } = await requireOrgUser();
const entitlement = await getEntitlement(orgId);
if (!hasActiveAccess(entitlement)) {
throw new BillingError('no_access', 'Your subscription is inactive.');
}
if (!planAtLeast(entitlement.plan, planSlug)) {
throw new BillingError('plan_required', `Upgrade to ${planSlug} to continue.`);
}
};

The access gate, reusing last lesson’s predicate. If hasActiveAccess returns false, as it does for canceled or incomplete, the org doesn’t get in at all regardless of tier, and the gate throws a BillingError. This is the headline check: “are you in?” comes before “are you high enough?”

import 'server-only';
import { getEntitlement, hasActiveAccess } from '@/db/queries/entitlements';
import { BillingError } from './billing-error';
export const requirePlan = async (
planSlug: 'pro' | 'team',
): Promise<void> => {
const { orgId } = await requireOrgUser();
const entitlement = await getEntitlement(orgId);
if (!hasActiveAccess(entitlement)) {
throw new BillingError('no_access', 'Your subscription is inactive.');
}
if (!planAtLeast(entitlement.plan, planSlug)) {
throw new BillingError('plan_required', `Upgrade to ${planSlug} to continue.`);
}
};

The tier gate. Access passed, so the org is in, and planAtLeast now decides whether their plan reaches the tier this surface requires. A Pro org hitting a Team-only screen has access yet fails here, and throws. Two refusals, one for each question.

import 'server-only';
import { getEntitlement, hasActiveAccess } from '@/db/queries/entitlements';
import { BillingError } from './billing-error';
export const requirePlan = async (
planSlug: 'pro' | 'team',
): Promise<void> => {
const { orgId } = await requireOrgUser();
const entitlement = await getEntitlement(orgId);
if (!hasActiveAccess(entitlement)) {
throw new BillingError('no_access', 'Your subscription is inactive.');
}
if (!planAtLeast(entitlement.plan, planSlug)) {
throw new BillingError('plan_required', `Upgrade to ${planSlug} to continue.`);
}
};

Success is the absence of a throw. Control reaches the closing brace, the function resolves void, and the caller carries on rendering its paid content. There’s no success value because the only thing the caller wanted was permission to continue.

1 / 1

Read what that function is: a composition of pieces you already shipped, requireOrgUser, getEntitlement, and hasActiveAccess, plus exactly one new comparison, planAtLeast. There is no new Stripe surface here. The gate that’s about to guard every paid feature in your app is almost entirely made of parts you’d already built, and the wrapper’s job was to assemble them into one named, callable line.

Notice the directive: import 'server-only', not 'use server'. The two session actions next door are Server Actions, triggered by a click, so they have to be callable from the client. requirePlan is different. It’s a guard you call from server code, at the top of a Server Component or inside another action, and never from the browser. Marking it server-only says exactly that, and turns any accidental client import into a build error.

That line is the entire payoff. At the top of a privileged Server Component, the gate is the first statement, before any Pro-only data fetch and before any render:

app/(app)/exports/page.tsx
export default async function ExportsPage() {
await billing.requirePlan('pro');
// Everything below is Pro-only; the gate above guaranteed it.
return <ExportDashboard />;
}

One call, one rule. This is the structural defense you learned with the auth wrapper, restated for billing: the protection isn’t discipline inside the function body, it’s the shape of the call itself. A privileged page either has requirePlan as its first line or it doesn’t, and “doesn’t” is something you can grep for. A surface missing its gate isn’t a subtle logic bug buried in a conditional; it’s a visible, findable omission, exactly the way an action missing authedAction is.

How the gate fails: BillingError, two surfaces

Section titled “How the gate fails: BillingError, two surfaces”

requirePlan throws. Twice in that function you saw throw new BillingError(...), and openPortal threw the same class a couple of lessons ago. It’s time to define it, and then to be precise about what “throws” means at the two different kinds of surface a gate can sit in. A thrown error behaves very differently in a Server Component than in a Server Action, and getting that wrong is how a billing gate either crashes a form or, worse, fails open.

First the class. You’ve built one of these before. Back in the chapter on classes, the argument was that classes earn their weight at exactly one kind of site, and domain error types were the example. BillingError is that pattern, cashed in.

lib/billing/billing-error.ts
export class BillingError extends Error {
readonly name = 'BillingError' as const;
readonly code: 'no_access' | 'plan_required' | 'no_customer';
constructor(
code: 'no_access' | 'plan_required' | 'no_customer',
userMessage: string,
) {
super(userMessage);
this.code = code;
}
}

It’s the same minimal Error subclass shape you authored back in the errors chapter, specialized to billing: it extends Error directly, with a literal-typed name and a typed code field. The name is the literal 'BillingError' as const, not the wider string, and that’s the discriminant that lets a catch tell this error apart from any other thrown thing without guesswork. The code is machine-readable: no_access when the subscription is inactive, plan_required when the tier is too low, no_customer for the never-subscribed case openPortal already used. The message you pass is a safe, customer-facing string, fine to render on a screen, which is why requirePlan writes “Upgrade to pro to continue” rather than leaking anything about the entitlement row. One class, a discriminant, a code, a message, nothing speculative.

Now the part that trips people up. The same BillingError, thrown by the same gate, has to flow two different ways depending on where the gate sits.

In a Server Component, throwing is exactly right. There’s no form to preserve and no input state to keep, so the whole render should stop. The throw propagates up to the segment’s error.tsx boundary, which catches it and renders an “upgrade to continue” screen instead of the page. The render dies and the paywall takes its place.

In a Server Action, throwing is wrong. A user submitted a form, and the action needs to hand a result back to that form so it can show an error in place, with the user’s input intact. Letting a BillingError throw through an action blows up the whole submission instead of reporting “you can’t do this on your plan.” So the action body calls requirePlan inside a try, catches the BillingError, and maps it onto the Result contract you return from every action.

That mapping has one constraint you have to get exactly right, because the instinct is to get it wrong. The course’s Result error code is a fixed union, validation | conflict | not_found | unauthorized | forbidden | rate_limited | internal, and there is no payment_required or paywall in it. Do not invent one. A plan-gate failure maps to forbidden: the user is authenticated and identified, they’re simply not permitted at this tier. The billing-specific nuance, why they were refused and whether it was inactive access or an insufficient plan, doesn’t get smuggled into a new Result code. It’s already carried by BillingError.code, which is available to your logs and telemetry. The form only needs the transport code, and the transport code is forbidden.

The two tabs below show the identical gate failing on each surface. Click between them and watch what changes and what doesn’t.

// app/(app)/exports/page.tsx
export default async function ExportsPage() {
await billing.requirePlan('pro');
return <ExportDashboard />;
}
// app/(app)/exports/error.tsx — the segment boundary
'use client';
export default function ExportsError({ error }: { error: Error }) {
return <UpgradeScreen />;
}

Throws, and the boundary catches it. The gate is the page’s first line. When it throws a BillingError, the render stops and the segment’s error.tsx takes over, rendering the upgrade screen. There’s no form state to keep, so stopping the render is the correct failure, and the boundary owns the paywall UI.

Look at what’s symmetric across those two tabs: both fail closed. In the Server Component, a throw stops the render, error.tsx shows the paywall, and the Pro content underneath never gets a chance to render. In the Server Action, the catch maps to err and returns, so the export never runs. Neither path can accidentally let a denied org through, because in both the denial is the default behavior of the error path, not a branch someone had to remember to write.

That term, fail closed ((On any uncertainty, deny. A gate that fails closed treats an error, a missing record, or an unexpected state as a refusal rather than waving the request through. The opposite, failing open, turns a transient glitch into an authorization hole.)) , is the reflex worth burning in here, because the opposite is a quietly catastrophic bug. Imagine wrapping the entitlement read in a try/catch that, on error, logged “couldn’t read entitlement, allowing” and let the request proceed. Now a momentary database blip hands every paying and non-paying org full access to paid features until someone notices. An access gate must deny on any uncertainty: a thrown read, a missing row, a status it doesn’t recognize. requirePlan gets this for free because it throws rather than catching-and-defaulting, so the absence of a throw is the only thing that grants access. You’ll meet fail-closed again, in depth, in the security work later in the course. For now, just notice that the gate’s structure makes it the default.

What the interface deliberately leaves out

Section titled “What the interface deliberately leaves out”

You now have the whole interface. It is three methods, and the most considered thing about it is everything it refuses to be.

A single rule sizes it: the interface is what the application initiates; everything the user initiates routes through the Stripe Portal. Run every candidate method through that rule and the list writes itself.

  • billing.upgrade(planSlug) starts a Checkout. In. The application is initiating “begin a subscription.”
  • billing.openPortal(returnPath?) opens the Portal. In. The application is initiating “send this customer to manage their billing.”
  • billing.requirePlan(planSlug) gates a surface. In. The gate is the application’s own check, run on the application’s own request path.

So that is three methods. Now the conspicuous absences, each one designed rather than forgotten:

  • No billing.cancelSubscription(). Cancellation is a Portal flow. You handed it to the Portal two lessons ago on purpose, and adding a method here would pull a user-initiated, Stripe-hosted screen back into application code you’d have to build, test, and maintain.
  • No billing.changePlan(). Switching plans, monthly to yearly or Pro to Team, is a Portal flow, with Stripe computing the proration. The application does not do proration math. Wrapping a method that implies it does is a lie waiting to break.
  • No billing.listInvoices(). Invoice history is a Portal screen. The Stripe Customer outlives any individual subscription, and the Portal stays the source of truth for invoices. Mirroring them into a method here buys you a maintenance burden and a second place for the data to be wrong.

Notice the pattern in those three: every one is a screen a user operates, and Stripe already ships, tests, and maintains it. The interface stays small for a reason that is almost the whole architecture of the chapter in one sentence: most billing flows belong to Stripe-hosted UI, so the application only wraps the few flows it actually initiates itself. A billing.* that grows a method per Stripe operation is just reinventing the Customer Portal in your own code, the exact loss you were warned against when you reached for the Portal in the first place. Three methods is the entire list, and that’s by design.

The exercise below drills the cut. Each chip is something a billing system might do; sort it into the interface or out of it. Two of them are traps worth slowing down on, and the last one isn’t about the Portal at all.

Sort each billing operation by whether it belongs in the three-method `billing.*` interface. Remember the rule: the interface is what the *app* initiates; what the *user* initiates lives in the Stripe Portal — and reading status is a different layer entirely. Drag each item into the bucket it belongs to, then press Check.

In the `billing.*` interface The app initiates this
Not in the interface Portal flow, or a different layer
Start a Checkout for the Pro plan
Gate a Pro-only export page
Open the billing-management screen
Cancel the subscription
Switch a plan from monthly to yearly
Download a past invoice
Compute the proration for an upgrade
Read whether the org is past_due

That last chip is the one that separates the experienced answer from the merely tidy one. “Read whether the org is past_duefeels like it should be a billing.* method, since it is about billing, after all. But the interface isn’t “the billing namespace”; it’s specifically the methods that initiate an app-driven flow or gate a surface. Reading status is the projection layer’s job, and it already has a home in getEntitlement and hasActiveAccess. Keep that line crisp and the interface stays three methods. Blur it and you’re back to the utils/ folder, one billing-flavored function at a time.

Step back, because there’s a contradiction you’ve probably been holding since the first line of this lesson. This course told you plainly, in the Server Actions work, not to wrap the framework’s seams. Don’t smother a Server Action in a hand-rolled abstraction; use the platform’s conventions and keep your code thin. And yet here you are, building a wrapper around Stripe and calling it good architecture. Which is it?

Both, and the resolution is a bar. A wrapper earns its place, becoming a carve-out from “don’t wrap things,” when a specific set of conditions all hold at once. In billing’s terms:

  • There’s a single, money-sensitive concern, requirePlan, that is identical boilerplate at every privileged surface. Not similar, identical: the same resolve-read-check, copy-pasted, is what you’d otherwise write at the top of every paid page.
  • Getting it wrong is an incident, not a style nit. A forgotten gate is a free user inside a paid feature, which means lost revenue, the kind of bug that shows up in a billing reconciliation rather than a code review.
  • There’s a vendor SDK whose calls genuinely benefit from one audited home: one place that imports stripe, one place that pins the API version, one place where billing logs and metrics naturally collect.

Billing clears that bar on every line. That’s why it gets wrapped, and why wrapping it isn’t a contradiction of the don’t-wrap rule: it’s the rule’s deliberate exception.

If that list of conditions feels familiar, it’s because you’ve already lived it once. authedAction is the other carve-out this course sanctions, and it cleared the exact same bar for the exact same reasons. The role check was identical boilerplate at every action. A missed check was a security breach, not a nit. And centralizing the policy gave you one place to audit and one thing to grep for. billing.requirePlan is its structural twin: the same problem shape, forgettable and sensitive and recurring, and the same solution, turning the forgettable check into a required, greppable call. Two carve-outs, one rationale. They are the only two SDK-or-seam wrappers this course blesses, and now you’ve built both.

That raises the obvious next question: if billing and auth clear the bar, what about everything else? You’re integrating Resend for email, Trigger.dev for background jobs, and R2 for storage, so why don’t those get wrapped behind a tidy interface too? Underneath the bar stated here in billing-specific terms is a general test, one that looks at how read-hostile an SDK’s shape is, what it would actually cost to swap it, and whether there’s a recurring discipline worth centralizing. That test decides “wrap or don’t” for any vendor. The next lesson makes it explicit and runs it across the course’s integrations, which is where you’ll see why Resend, Trigger.dev, and R2 stay un-wrapped while these two don’t. This lesson’s job was narrower and concrete: build the billing carve-out and name the bar it clears.

The durable win, the thing the wrapper actually creates, is an audit surface. The day a security review asks “where can a free user reach a paid feature?”, the answer isn’t “let me read the whole codebase and reason about it.” It’s “grep for requirePlan, list the privileged surfaces, and find the ones that don’t call it.” That is the identical move you’d make to audit the auth wrapper, “find every privileged action that doesn’t go through authedAction,” and it’s only possible because the check is a single named call instead of scattered inline logic. The wrapper didn’t just tidy the code; it made a class of question answerable with a search.

The five scattered pieces now sit behind one seam. /lib/billing/ holds the Stripe client, the two session actions, the gate, and the error they throw; index.ts exposes exactly three methods as billing.*; and the read layer stays where it belongs, in db/queries/. The load-bearing addition was requirePlan. It resolves the org, reads the local entitlement, composes hasActiveAccess with a planAtLeast tier check, and fails closed: throwing in a Server Component for error.tsx to catch, returning err('forbidden', …) in a Server Action for the form to show.

If you keep three sentences from this lesson, keep these.

  1. The Stripe SDK has one home. It’s imported only inside /lib/billing/; everything else imports from billing.
  2. The interface is three methods: two that start app-initiated flows (upgrade, openPortal) and one that gates (requirePlan). Everything the user initiates lives in the Stripe Portal, not here.
  3. The gate reads local state, composes predicates you already built, and fails closed. Local entitlement, never Stripe; hasActiveAccess plus a tier check; deny on any uncertainty.

And four watch-outs, each a real way this goes wrong:

  • Client-only gating is not security. Hiding a panel with entitlement.plan === 'pro' && <ProFeature /> is a UI convenience; the server gate has to exist too, or the “hidden” feature is one fetch away from anyone with devtools.
  • import stripe outside /lib/billing/ kills the seam. The moment a route or an action imports the SDK directly, the single-home rule is broken and the audit story is gone.
  • New billing.* methods for user-initiated flows belong in the Portal. Every one of cancel, changePlan, and listInvoices is a screen Stripe already ships. Reaching for them is reinventing the Portal.
  • A catch that defaults to allow turns a blip into free access. Fail closed, always. The gate denies on uncertainty; it never logs-and-proceeds.

The next lesson takes the bar this one stated in billing terms and makes it a general test, the one that explains why auth and billing earn full interfaces while Resend, Trigger.dev, and R2 earn only thin helpers, and names the quiet discipline of not wrapping everything else. And the full requirePlan, wired to the project’s real requireOrgUser and guarding the project’s real privileged surfaces, ships in the project at the end of this chapter, where everything you’ve built across these six lessons finally runs.