Skip to content
Chapter 65Lesson 5

The three-method billing interface

The inbound half of this project is done: a webhook projects three Stripe events into one plan_entitlements row and writes an audit line. But nobody has started a subscription yet. This lesson builds the outbound half: the three methods that let a user begin paying, manage what they’re paying for, and gate the pages only paying users should see.

By the end the whole loop closes against a test-mode Stripe account: Upgrade to Pro runs real Checkout, Manage billing opens the Customer Portal, and /inspector/pro-only serves its protected content instead of an upgrade wall.

Everything the app says to Stripe goes through lib/billing/. The Stripe SDK is imported in exactly one file, lib/billing/stripe.ts; every other call site reads the entitlement row or calls one of the three methods you’re about to write: upgrade, openPortal, requirePlan. Only the single import and code review enforce that boundary, no lint rule. The payoff: the day Stripe ships a breaking SDK change, or you swap providers, the blast radius is one directory. This is the interface the teaching chapter designed; here you build it against the running app.

Two of the three are mutations, one is a gate. upgrade and openPortal are authedAction('admin', …) Server Actions: they validate input, do the privileged thing, and return a Result<{ url }>, a Stripe-hosted URL the client navigates to. requirePlan is not an action. It opens with import 'server-only' rather than 'use server', runs inside Server Components before any data read, and throws a BillingError instead of returning a Result. A gate has nothing to return: it either lets the render proceed or throws, and a throw caught by the segment’s error.tsx is exactly “stop rendering this page.” Treat a missing requirePlan on a paywalled component like a missing authedAction on a mutation: same severity.

The interface stays at exactly three. You build no cancel or changePlan: the Customer Portal owns every user-initiated mutation — cancellation, plan switches, card updates — and wrapping more methods “for symmetry” is the named failure mode. The forged-organization_id cross-check is next lesson’s hardening, not this one’s.

Clicking “Upgrade to Pro” opens Stripe-hosted Checkout; paying with 4242 4242 4242 4242 lands on /billing/success, which shows “Finalizing your subscription…” then “You are all set” / “Your plan is now pro” as the entitlement panel flips.
untested
A first-time upgrade creates the Stripe Customer and persists stripeCustomerId on the org; the inspector header shows the id populated where it was null before.
untested
upgrade returns a successful Result carrying a Checkout url for a valid plan, and resolves the Price from the catalog lookup_key rather than a hardcoded id.
tested
upgrade for a plan with no configured Price returns err('not_found', …).
tested
Clicking “Manage billing” opens the Customer Portal in a new tab; cancelling there returns to the app and the entitlement panel shows cancelAtPeriodEnd: true within a moment.
untested
openPortal with no Stripe Customer yet returns err('forbidden', …), and the inspector’s Portal button is disabled with its tooltip until a Checkout has run.
tested
requirePlan('pro') resolves when the org’s entitlement is active and ranks at or above pro; throws BillingError('no_access') when the entitlement is inactive; throws BillingError('plan_required') when the tier is too low.
tested
/inspector/pro-only renders the “Upgrade to Pro” fallback before the upgrade, the protected content after, and reverts to the fallback once the subscription is deleted.
untested

Implement the three method bodies against the brief and the lesson’s tests before you open the solution. The scaffolding ships complete in the starter — BillingError, the index.ts barrel, and the pro-only/error.tsx gate are written, and this lesson only deletes their TODO(L5) markers. Your work is the three bodies in upgrade.ts, portal.ts, and require-plan.ts. The annotations below explain each Checkout decision at its line; Starting subscriptions with Checkout covers the session shape and trial mechanics in full.

Reference solution and walkthrough

The decision-dense file: an authedAction('admin', …) whose body runs four steps — read the org, ensure the Customer, resolve the Price, create the session. The Customer-creation ordering is the one to internalize: customers.create runs before the local setStripeCustomerId. Reverse them and a Stripe failure leaves your database pointing at a Customer that doesn’t exist, with nothing to repair it; in the kept order the worst case is a harmless orphan Customer. Production would add an idempotency key so a retry reuses the first Customer; this project names that hardening rather than building it.

'use server';
import { z } from 'zod';
import {
getOrgWithOwnerEmail,
setStripeCustomerId,
} from '@/db/queries/organizations';
import { env } from '@/env';
import { authedAction } from '@/lib/auth/authed-action';
import { loadCatalog } from '@/lib/billing/catalog';
import { stripe } from '@/lib/billing/stripe';
import { err, ok, type Result } from '@/lib/result';
export const upgrade = authedAction(
'admin',
z.strictObject({ planSlug: z.enum(['pro', 'team']) }),
async ({ planSlug }, ctx): Promise<Result<{ url: string }>> => {
const org = await getOrgWithOwnerEmail(ctx.orgId);
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: org.ownerEmail,
metadata: { organization_id: ctx.orgId },
});
customerId = customer.id;
await setStripeCustomerId(ctx.orgId, customerId);
}
const catalog = loadCatalog();
const lookupKey = Object.keys(catalog.lookupKeys).find(
(key) => catalog.lookupKeys[key] === planSlug,
);
if (!lookupKey) {
return err('not_found', 'No price is configured for that plan.');
}
const prices = await stripe.prices.list({
lookup_keys: [lookupKey],
active: true,
limit: 1,
});
const price = prices.data[0];
if (!price) {
return err('not_found', 'No price is configured for that plan.');
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
subscription_data: {
metadata: { organization_id: ctx.orgId },
trial_period_days: 14,
},
payment_method_collection: 'always',
allow_promotion_codes: false,
success_url: `${env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.APP_URL}/inspector`,
});
if (!session.url) {
return err('internal', 'Stripe did not return a Checkout URL.');
}
return ok({ url: session.url });
},
);

Admin-only, input validated to a single planSlug enum, returning the canonical Promise<Result<{ url: string }>> — a Stripe-hosted URL the client island navigates to.

'use server';
import { z } from 'zod';
import {
getOrgWithOwnerEmail,
setStripeCustomerId,
} from '@/db/queries/organizations';
import { env } from '@/env';
import { authedAction } from '@/lib/auth/authed-action';
import { loadCatalog } from '@/lib/billing/catalog';
import { stripe } from '@/lib/billing/stripe';
import { err, ok, type Result } from '@/lib/result';
export const upgrade = authedAction(
'admin',
z.strictObject({ planSlug: z.enum(['pro', 'team']) }),
async ({ planSlug }, ctx): Promise<Result<{ url: string }>> => {
const org = await getOrgWithOwnerEmail(ctx.orgId);
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: org.ownerEmail,
metadata: { organization_id: ctx.orgId },
});
customerId = customer.id;
await setStripeCustomerId(ctx.orgId, customerId);
}
const catalog = loadCatalog();
const lookupKey = Object.keys(catalog.lookupKeys).find(
(key) => catalog.lookupKeys[key] === planSlug,
);
if (!lookupKey) {
return err('not_found', 'No price is configured for that plan.');
}
const prices = await stripe.prices.list({
lookup_keys: [lookupKey],
active: true,
limit: 1,
});
const price = prices.data[0];
if (!price) {
return err('not_found', 'No price is configured for that plan.');
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
subscription_data: {
metadata: { organization_id: ctx.orgId },
trial_period_days: 14,
},
payment_method_collection: 'always',
allow_promotion_codes: false,
success_url: `${env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.APP_URL}/inspector`,
});
if (!session.url) {
return err('internal', 'Stripe did not return a Checkout URL.');
}
return ok({ url: session.url });
},
);

Ensure the Customer behind an if (!customerId) guard. stripe.customers.create runs before setStripeCustomerId: an orphan Customer from a failed retry is fixable, a local pointer to a non-existent Customer is not. The organization_id metadata is the carry-channel the webhook reads back to resolve the tenant.

'use server';
import { z } from 'zod';
import {
getOrgWithOwnerEmail,
setStripeCustomerId,
} from '@/db/queries/organizations';
import { env } from '@/env';
import { authedAction } from '@/lib/auth/authed-action';
import { loadCatalog } from '@/lib/billing/catalog';
import { stripe } from '@/lib/billing/stripe';
import { err, ok, type Result } from '@/lib/result';
export const upgrade = authedAction(
'admin',
z.strictObject({ planSlug: z.enum(['pro', 'team']) }),
async ({ planSlug }, ctx): Promise<Result<{ url: string }>> => {
const org = await getOrgWithOwnerEmail(ctx.orgId);
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: org.ownerEmail,
metadata: { organization_id: ctx.orgId },
});
customerId = customer.id;
await setStripeCustomerId(ctx.orgId, customerId);
}
const catalog = loadCatalog();
const lookupKey = Object.keys(catalog.lookupKeys).find(
(key) => catalog.lookupKeys[key] === planSlug,
);
if (!lookupKey) {
return err('not_found', 'No price is configured for that plan.');
}
const prices = await stripe.prices.list({
lookup_keys: [lookupKey],
active: true,
limit: 1,
});
const price = prices.data[0];
if (!price) {
return err('not_found', 'No price is configured for that plan.');
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
subscription_data: {
metadata: { organization_id: ctx.orgId },
trial_period_days: 14,
},
payment_method_collection: 'always',
allow_promotion_codes: false,
success_url: `${env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.APP_URL}/inspector`,
});
if (!session.url) {
return err('internal', 'Stripe did not return a Checkout URL.');
}
return ok({ url: session.url });
},
);

Resolve the Price by lookup_key, never a hardcoded price_id: reverse-scan catalog.lookupKeys for the slug. No key for the plan is the first not_found exit — a misconfiguration the admin can act on, so an err, not a throw.

'use server';
import { z } from 'zod';
import {
getOrgWithOwnerEmail,
setStripeCustomerId,
} from '@/db/queries/organizations';
import { env } from '@/env';
import { authedAction } from '@/lib/auth/authed-action';
import { loadCatalog } from '@/lib/billing/catalog';
import { stripe } from '@/lib/billing/stripe';
import { err, ok, type Result } from '@/lib/result';
export const upgrade = authedAction(
'admin',
z.strictObject({ planSlug: z.enum(['pro', 'team']) }),
async ({ planSlug }, ctx): Promise<Result<{ url: string }>> => {
const org = await getOrgWithOwnerEmail(ctx.orgId);
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: org.ownerEmail,
metadata: { organization_id: ctx.orgId },
});
customerId = customer.id;
await setStripeCustomerId(ctx.orgId, customerId);
}
const catalog = loadCatalog();
const lookupKey = Object.keys(catalog.lookupKeys).find(
(key) => catalog.lookupKeys[key] === planSlug,
);
if (!lookupKey) {
return err('not_found', 'No price is configured for that plan.');
}
const prices = await stripe.prices.list({
lookup_keys: [lookupKey],
active: true,
limit: 1,
});
const price = prices.data[0];
if (!price) {
return err('not_found', 'No price is configured for that plan.');
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
subscription_data: {
metadata: { organization_id: ctx.orgId },
trial_period_days: 14,
},
payment_method_collection: 'always',
allow_promotion_codes: false,
success_url: `${env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.APP_URL}/inspector`,
});
if (!session.url) {
return err('internal', 'Stripe did not return a Checkout URL.');
}
return ok({ url: session.url });
},
);

List active Prices for that key with limit: 1. An empty list is the second not_found exit, a seed that never ran. The chosen price.id flows into line_items — proof the lookup_key indirection is real.

'use server';
import { z } from 'zod';
import {
getOrgWithOwnerEmail,
setStripeCustomerId,
} from '@/db/queries/organizations';
import { env } from '@/env';
import { authedAction } from '@/lib/auth/authed-action';
import { loadCatalog } from '@/lib/billing/catalog';
import { stripe } from '@/lib/billing/stripe';
import { err, ok, type Result } from '@/lib/result';
export const upgrade = authedAction(
'admin',
z.strictObject({ planSlug: z.enum(['pro', 'team']) }),
async ({ planSlug }, ctx): Promise<Result<{ url: string }>> => {
const org = await getOrgWithOwnerEmail(ctx.orgId);
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: org.ownerEmail,
metadata: { organization_id: ctx.orgId },
});
customerId = customer.id;
await setStripeCustomerId(ctx.orgId, customerId);
}
const catalog = loadCatalog();
const lookupKey = Object.keys(catalog.lookupKeys).find(
(key) => catalog.lookupKeys[key] === planSlug,
);
if (!lookupKey) {
return err('not_found', 'No price is configured for that plan.');
}
const prices = await stripe.prices.list({
lookup_keys: [lookupKey],
active: true,
limit: 1,
});
const price = prices.data[0];
if (!price) {
return err('not_found', 'No price is configured for that plan.');
}
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
subscription_data: {
metadata: { organization_id: ctx.orgId },
trial_period_days: 14,
},
payment_method_collection: 'always',
allow_promotion_codes: false,
success_url: `${env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.APP_URL}/inspector`,
});
if (!session.url) {
return err('internal', 'Stripe did not return a Checkout URL.');
}
return ok({ url: session.url });
},
);

Create the subscription-mode session. subscription_data.metadata repeats the carry-channel; trial_period_days: 14 with payment_method_collection: 'always' collects a card up front so trial-end becomes a silent charge, not a downgrade by hand; allow_promotion_codes: false is the conservative default. The success_url carries {CHECKOUT_SESSION_ID}, but the success page reads-and-polls the entitlement rather than retrieving the session — the webhook owns the write.

1 / 1

Smaller: an authedAction('admin', …) taking an optional returnPath, with one guard and one Stripe call.

'use server';
import { z } from 'zod';
import { getOrgWithOwnerEmail } from '@/db/queries/organizations';
import { env } from '@/env';
import { authedAction } from '@/lib/auth/authed-action';
import { BillingError } from '@/lib/billing/billing-error';
import { stripe } from '@/lib/billing/stripe';
import { err, ok, type Result } from '@/lib/result';
// 'use server' — the Portal client island imports and calls this. Opens a Stripe
// Billing Portal session for the org's Customer and returns its URL (the island opens
// it in a new tab). Plan changes and cancellation happen in the Portal, never via
// stripe.subscriptions.update from app code.
export const openPortal = authedAction(
'admin',
z.strictObject({ returnPath: z.string().optional() }),
async ({ returnPath }, ctx): Promise<Result<{ url: string }>> => {
const org = await getOrgWithOwnerEmail(ctx.orgId);
// No Customer → no Portal to open. The inspector already disables the button when
// stripeCustomerId is null, so this is belt-and-suspenders; the BillingError carries
// the machine-readable distinction the Result's userMessage cannot.
if (!org.stripeCustomerId) {
const reason = new BillingError(
'no_customer',
'Start a Checkout to create a billing account first.',
);
return err('forbidden', reason.userMessage);
}
const session = await stripe.billingPortal.sessions.create({
customer: org.stripeCustomerId,
return_url: returnPath ?? env.STRIPE_PORTAL_RETURN_URL,
});
return ok({ url: session.url });
},
);

The no-Customer branch looks redundant against the disabled UI button, and that redundancy is the point: the UI guard is convenience, the action guard is the real boundary. Never trust the client to have disabled the dangerous path.

The load-bearing gate. Note the very first line.

import 'server-only';
import { getEntitlement, hasActiveAccess } from '@/db/queries/entitlements';
import { requireOrgUser } from '@/lib/auth';
import { BillingError } from '@/lib/billing/billing-error';
import type { PlanSlug } from '@/lib/billing/catalog';
// The tier order, free < pro < team. A higher tier admits a lower-tier gate, so the
// gate compares ranks rather than equality. `satisfies` keeps the map exhaustive over
// PlanSlug — a new tier without a rank is a tsc error.
const PLAN_RANK = { free: 0, pro: 1, team: 2 } as const satisfies Record<
PlanSlug,
number
>;
// The load-bearing Server-Component gate. `import 'server-only'` (NOT 'use server') —
// it is called from server components, never client-callable. A failure throws a
// BillingError, which the segment error.tsx catches and renders as the upgrade
// fallback; the gate is fail-closed (a thrown error inside the check is a refusal).
//
// Two distinct refusals carry distinct codes: an inactive entitlement throws
// 'no_access', a too-low tier throws 'plan_required'. error.tsx switches on the code to
// render the right message.
export const requirePlan = async (planSlug: 'pro' | 'team'): Promise<void> => {
const { orgId } = await requireOrgUser();
const e = await getEntitlement(orgId);
if (!hasActiveAccess(e)) {
throw new BillingError(
'no_access',
'Your subscription is no longer active.',
);
}
if (PLAN_RANK[e.plan] < PLAN_RANK[planSlug]) {
throw new BillingError(
'plan_required',
`This area requires the ${planSlug} plan.`,
);
}
};

Three details carry the gate, each in the comments above. import 'server-only', not 'use server': a Server Action is client-callable over the network, wrong for a gate that only runs inside a render, so this is a build-time tripwire instead. The gate compares ranks, not equality, so a team entitlement satisfies a requirePlan('pro') gate; satisfies Record<PlanSlug, number> fails the build if you add a tier without a rank. And the two refusals stay distinct — no_access for a lapsed subscription, plan_required for too low a tier — so the fallback can show the right message.

These three are provided complete; you only delete their TODO(L5) comments. Read them to see how the methods fit together.

billing-error.ts carries the full code vocabulary the billing domain throws — no_access and plan_required from the gate, no_customer from openPortal, unknown_customer and unknown_plan from the webhook handlers:

export class BillingError extends Error {
override readonly name = 'BillingError' as const;
readonly code:
| 'no_access'
| 'plan_required'
| 'no_customer'
| 'unknown_customer'
| 'unknown_plan';
constructor(
code: BillingError['code'],
public readonly userMessage: string,
) {
super(userMessage);
this.code = code;
}
}

index.ts is the sanctioned barrel — exactly the three methods, no wildcard, and no stripe / BillingError / catalog re-export, so the SDK and the error class stay internal to the directory:

// The sanctioned billing barrel: re-export EXACTLY the three interface methods.
// No wildcard re-export, and no stripe / BillingError / catalog re-export — the SDK
// and the error class stay internal to lib/billing. Surfaces import
// `billing.upgrade`/`billing.openPortal`/`billing.requirePlan` from here.
export { openPortal } from '@/lib/billing/portal';
export { requirePlan } from '@/lib/billing/require-plan';
export { upgrade } from '@/lib/billing/upgrade';

pro-only/error.tsx is the segment boundary that catches what requirePlan throws, switching on the error’s code read off a plain shape rather than instanceof BillingError:

// error.tsx must be a Client Component. The thrown BillingError arrives as a plain
// Error here (its prototype is lost across the boundary), so the code is read off the
// serialized shape rather than `instanceof`. The discrimination is on BillingError.code:
// 'no_access' (the subscription is inactive) vs 'plan_required' (the tier is too low) —
// the two refusals requirePlan throws against this gate.
type BillingErrorLike = Error & { code?: string };
const ProOnlyGate = ({
error,
}: {
error: BillingErrorLike;
reset: () => void;
}) => {
const code = error.code ?? 'plan_required';
const message =
code === 'no_access'
? 'Your subscription is no longer active. Reactivate to regain access.'
: 'This area requires the Pro plan. Upgrade to continue.';

An error crossing React’s server-to-client boundary loses its prototype chain, so instanceof BillingError would be false on the other side; discriminating on a plain code property is the pattern that survives. This is the same error.tsx interop you saw with Result and Server Actions in Result, or throw.

Two client islands wire the buttons, both provided. CheckoutButton calls upgrade(null, formData) and, on ok, does a full window.location.assign(result.data.url) — a real navigation to Stripe’s domain, not a router.push. PortalButton calls openPortal and window.opens the URL in a new tab — the Portal’s return_url navigation would otherwise fight the SPA ’s back button. When hasCustomer is false it renders a disabled button wrapped in a <span>, since a disabled button fires no pointer events and the tooltip needs a live element to hover.

Run the lesson’s automated suite:

Terminal window
pnpm test:lesson 5

With Stripe, the auth context, and the database stubbed, each assertion lands on the method’s returned Result or thrown error, no live call. Expect green:

✓ FR3: upgrade resolves a Price from the catalog lookup_key and returns a Checkout url (3)
✓ FR4: upgrade returns not_found when no Price is configured for the plan (2)
✓ FR6: openPortal refuses with forbidden when the org has no Stripe Customer (2)
✓ FR7: requirePlan gates on the entitlement row (active / inactive / too-low tier) (4)
Test Files 1 passed (1)
Tests 11 passed (11)

The live Stripe round-trip is yours to confirm by hand. With pnpm dev running and pnpm stripe:listen forwarding events, walk the inspector through each:

Click “Upgrade to Pro”, complete Checkout with 4242 4242 4242 4242 (any future expiry, any CVC), and confirm /billing/success shows “Finalizing your subscription…” then flips to “You are all set” / “Your plan is now pro” within a second or two as the entitlement panel updates.
untested
Before that first upgrade the inspector header shows stripeCustomerId as null; after it, the header shows a populated cus_… id — the Customer was created on the first Checkout.
untested
Click “Manage billing”, cancel the subscription in the Portal tab, return to the inspector, and confirm the entitlement panel shows cancelAtPeriodEnd: true within a moment.
untested
With a freshly reseeded org that has no Stripe Customer, the Portal button is disabled and hovering it shows the “start a Checkout to create one” tooltip.
untested
/inspector/pro-only renders the “Upgrade to Pro” fallback before upgrading, the protected content after upgrading, and reverts to the fallback once the subscription is deleted (fire stripe trigger customer.subscription.deleted, or cancel and let the period end).
untested