Skip to content
Chapter 64Lesson 6

The thin billing interface

Collapse scattered Stripe code into one module behind a three-method interface, fronted by the requirePlan paywall gate.

The last five lessons left you five billing pieces in five files: a configured Stripe client, an upgrade action that mints a Checkout URL, an openPortal action that mints a Portal URL, getEntitlement to read an org’s plan row, and hasActiveAccess to turn its status into a yes-or-no. Scattered, they are just a codebase; grouping them only because they feel related is how you get a utils/ folder nobody can navigate.

What makes the scatter expensive is what comes next: a paywall. A Pro-only export page, a Team-only seats screen, a paid API route, each needing the same gate at the very top, asking is this org allowed here, on this tier, right now? before any paid content renders. The check is identical at every privileged surface, it is about money, and forgetting it once lets a free user walk into a paid feature. So this lesson gives billing a single home, /lib/billing/, where the Stripe SDK lives behind a deliberately tiny billing.* interface, fronted by the gate that earns the arrangement: requirePlan.

You built this shape once already, for authorization. Every privileged Server Action got wrapped in authedAction because the role check was identical boilerplate, forgetting it was a security hole, and a missing one had to be findable. Billing is the same move against the same kind of bug, with revenue on the line instead of permissions.

Everything in this lesson hangs off one rule:

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

So the stripe client is a transitive dependency of your application. A route handler, a Server Action body, a Server Component: none of them import Stripe or import { stripe }. They import requirePlan, upgrade, or openPortal, and those functions reach the SDK for them. The moment a file outside /lib/billing/ imports the client directly, the seam is gone, and so is everything it buys you.

Most of that directory already exists: you’re collecting four files you’ve written 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.

The stripe.ts file is not new. You wrote it in this chapter’s first lesson 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/, alongside the actions and gate that use it. Same client, relocated, not a second one. Like 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 expose only the URLs the SDK mints, never the SDK itself.

getEntitlement deliberately stays out of the directory, in db/queries/entitlements.ts, because it is a tenant-scoped database read, not a Stripe call. That 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. Dragging it into the billing folder “to keep all the billing stuff together” is the utils/ instinct again: the directory’s job is Stripe calls and the gate, not everything with billing in its name.

That leaves index.ts, which looks like a barrel file, the kind the project’s conventions forbid in lib/. 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. index.ts does the opposite: it is the public surface of the billing module, re-exporting exactly the three methods callers may use, each 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 and nothing else: not the stripe client, not BillingError, both of which stay internal. This is the sanctioned exception to “no barrels”. The rule exists to stop unrelated code from leaking through accidental re-exports, and a hand-curated three-line door is the opposite of a leak.

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

requirePlan: the two-question paywall gate

Section titled “requirePlan: the two-question paywall gate”

A privileged surface, say a Pro-only export page, must answer one question before it renders: is this org entitled to this tier right now? That breaks into two stacked checks.

The first is access: can this org reach paid features at all? hasActiveAccess(entitlement) from the last lesson answers it, returning true for the trialing, active, and past_due statuses and false for the rest. The second is tier: even with access, is the org on a high enough plan for this feature? A healthy Pro org has access but still can’t open a Team-only seats screen. Tier is an ordering, team over pro over free, and access alone can’t place an org on that ladder.

A paywall 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. Success returns nothing, Promise<void>: the only outcome the caller wants is permission to keep rendering, so there’s no value to hand back. The shape mirrors requireOrgUser(), a guard you either pass or get thrown out of, never something you branch on.

One property is non-negotiable: the gate reads the local entitlement row, never Stripe. It calls getEntitlement(orgId), a single indexed lookup in your plan_entitlements table, and never stripe.*. Stripe is the source of truth for billing facts, but you never call it on the request path; you read the projection instead. That’s why requirePlan can sit at the top of every protected page: it’s a request-scoped, cached primary-key lookup, not a network round-trip 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”

Access is handled by hasActiveAccess. Tier needs one small new piece, and it’s the twin of something you’ve built.

For role checks you didn’t compare with a tangle of ||s. You gave each role a rank and compared numbers: ROLE_RANK mapped owner, admin, member to integers, and roleAtLeast asked whether one rank met or beat another. Plans are the same ordered-ladder problem, so they get the same 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];

PLAN_RANK is the ladder as data: as const pins the values to the literals 0, 1, 2, and satisfies Record<Plan, number> means the day you add a plan, the map won’t compile until you rank it. planAtLeast('team', 'pro') is true; planAtLeast('pro', 'team') is false. The plan ordering now lives in one place, and “is this tier high enough” is a single comparison instead of a condition someone can get subtly wrong.

The whole gate is a resolve, a read, and two refusals.

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() returns { user, orgId, role } and throws to the framework boundary on a missing session or org, so the gate can never run anonymously. You destructure just orgId, the only field it 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) fetches one indexed row from plan_entitlements, never Stripe. Reading the projection rather than the source of truth is what lets the gate 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. If hasActiveAccess is false, as for canceled or incomplete, the org doesn’t get in regardless of tier, and the gate throws. “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. The org is in, so planAtLeast decides whether its plan reaches the required tier. A Pro org hitting a Team-only screen has access yet fails here, and throws.

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. There’s no success value because all the caller wanted was permission to continue.

1 / 1

The gate is a composition of pieces you already shipped, requireOrgUser, getEntitlement, and hasActiveAccess, plus one new comparison, planAtLeast. No new Stripe surface; the wrapper’s job was to assemble them into one named, callable line.

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

That single line is the payoff. At the top of a privileged Server Component, the gate is the first statement, before any Pro-only fetch or 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 />;
}

This is the structural defense from the auth wrapper, restated for billing: the protection isn’t discipline inside the function body, it’s the shape of the call. A privileged page either has requirePlan as its first line or it doesn’t, and “doesn’t” is something you can grep for, a visible omission rather than a logic bug buried in a conditional, exactly like an action missing authedAction.

How the gate fails: BillingError, two surfaces

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

requirePlan throws it twice, and openPortal threw it a couple of lessons ago. Now define BillingError, and be precise about what “throws” means, because a thrown error flows very differently through a Server Component than through a Server Action. Get that wrong and the gate either crashes a form or, worse, fails open.

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 is the minimal Error subclass from that chapter, specialized to billing. The name is the literal 'BillingError' as const, not the wider string, so a catch can tell this error apart from anything else without guessing. 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 is a safe, customer-facing string, which is why requirePlan passes “Upgrade to pro to continue” rather than anything about the entitlement row.

The same BillingError, thrown by the same gate, has to flow two ways depending on where the gate sits.

In a Server Component, throwing is right. There’s no form or input state to preserve, so the render should stop. The throw propagates to the segment’s error.tsx boundary, which renders an “upgrade to continue” screen in place of the page.

In a Server Action, throwing is wrong. A user submitted a form, and the action has to hand a result back so the form can show the error in place with the input intact. Throwing through the action blows up the whole submission instead. So the action calls requirePlan inside a try, catches the BillingError, and maps it onto the Result contract every action returns.

That mapping has one constraint. The course’s Result error code is a fixed union, validation | conflict | not_found | unauthorized | forbidden | rate_limited | internal, with no payment_required or paywall; do not invent one. A plan-gate failure maps to forbidden: the user is authenticated and identified, simply not permitted at this tier. The billing nuance, whether it was inactive access or an insufficient plan, stays in BillingError.code for your logs and telemetry. The form needs only the transport code, and that 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, the render stops and the segment’s error.tsx takes over with the upgrade screen. There’s no form state to keep, so stopping the render is the correct failure.

Both paths fail closed. The Server Component’s throw stops the render before the Pro content can appear; the Server Action’s catch returns before the export runs. Neither can 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.

Fail closed is the reflex worth burning in, because the opposite is quietly catastrophic. Picture wrapping the entitlement read in a try/catch that, on error, logged “couldn’t read entitlement, allowing” and let the request proceed: one database blip would hand every org, paying or not, full access to paid features until someone noticed. requirePlan avoids 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.

One rule sizes the interface: it wraps what the application initiates; everything the user initiates routes through the Stripe Portal.

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

The absences are designed, not forgotten:

  • No billing.cancelSubscription(). Cancellation is a Portal flow. You handed it to the Portal two lessons ago on purpose; a method here would pull a user-initiated, Stripe-hosted screen back into app 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, and Stripe computes the proration. The app does no proration math, so 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 single subscription, and the Portal stays the source of truth; mirroring invoices into a method here buys a maintenance burden and a second place for the data to be wrong.

Every absence is a screen a user operates that Stripe already ships, tests, and maintains. That is the chapter in one sentence: most billing flows belong to Stripe-hosted UI, so the app only wraps the few flows it initiates itself. A billing.* that grows a method per Stripe operation just reinvents the Customer Portal in your own code.

The exercise below drills the cut. Each chip is something a billing system might do; sort it into the interface or out of it.

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

The last chip is the trap. “Read whether the org is past_duefeels like a billing.* method, since it is about billing. But the interface isn’t the billing namespace; it’s the methods that initiate an app-driven flow or gate a surface. Reading status is the projection layer’s job, and it already lives in getEntitlement and hasActiveAccess. Keep that line crisp and the interface stays three methods.

There’s a contradiction to resolve here. This course told you, back in Server Actions work, not to wrap the framework’s seams: use the platform’s conventions and keep your code thin. Yet here you are wrapping Stripe and calling it good architecture. Which is it?

Both. A wrapper earns its place, as a deliberate exception to “don’t wrap things,” only when a specific set of conditions all hold at once. Billing meets every one:

  • There’s a single, money-sensitive concern, requirePlan, that is identical boilerplate at every privileged surface. Not similar, identical: the same resolve-read-check you’d otherwise copy-paste to 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, the kind of lost-revenue bug that surfaces in a billing reconciliation rather than a code review.
  • There’s a vendor SDK that benefits from one audited home: one place imports stripe, pins the API version, and collects billing logs and metrics.

You’ve cleared this bar once before. authedAction is the other carve-out the course sanctions, for the same reasons: the role check was identical boilerplate at every action, a missed check was a breach rather than a nit, and centralizing the policy gave you one thing to grep for. billing.requirePlan is its structural twin, the same forgettable-and-sensitive check turned into a required, greppable call. These are the only two SDK-or-seam wrappers the course blesses, and now you’ve built both. The next lesson generalizes this bar into a test you can run against any vendor, which is where Resend, Trigger.dev, and R2 stay un-wrapped while these two don’t.

The durable payoff is an audit surface. When a security review asks “where can a free user reach a paid feature?”, the answer isn’t “let me read the whole codebase.” It’s “grep for requirePlan, and find the privileged surfaces that don’t call it”, the same move you’d make to audit authedAction. That only works because the check is one named call instead of scattered inline logic.

One caution survives all of this: 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. The full requirePlan, wired to the project’s real privileged surfaces, ships in the project at the end of this chapter.