Skip to content
Chapter 64Lesson 5

Decoding subscription status into access

Turn the Stripe subscription status string into access decisions through one function, instead of a lossy boolean.

A customer’s renewal card expires overnight. The bank reissued it, but the new number never reached your billing provider. Saturday morning, they open the product they pay for and they’re locked out: no warning email, no banner, no grace, just a paywall where their dashboard used to be. The worst support ticket your product can generate, from a one-line mistake: somewhere in your code, access is a boolean, and it just flipped to false.

The opposite failure has the same root cause. Someone clicks Upgrade to Pro, lands on Stripe Checkout, and their card is declined. A half-built subscription row gets created anyway, the first charge never clears, and to your boolean it looks active. They never paid a cent, and they’re using Pro for free. One bug locks out a paying customer; the other gives the product away. Both come from collapsing a rich billing status into a yes/no.

In the previous lesson you stored a status column on the plan_entitlements row, a label copied straight from Stripe with no behavior attached. Here you give it meaning. By the end you’ll have one function, hasActiveAccess, that every gate calls to answer “can this org get in at all,” plus a banner that warns a lapsing customer before it revokes anything. One idea carries the lesson: a subscription status is a string with semantics, decoded in exactly one place.

The tempting shape is a single column, is_paid: boolean. It reads beautifully at the call site, if (org.isPaid), and it costs real revenue. A boolean has two states; the billing relationship has at least five, and the difference between them is money.

Three situations a boolean cannot represent, each one it gets expensively wrong:

  • A card that just failed. The charge bounced, but Stripe isn’t done: it’s retrying on a schedule, and most failed renewals recover within days. Flip to false on the first failure and you lock out a customer Stripe was about to recover.
  • A cancellation that hasn’t taken effect yet. A customer cancels on the 2nd but has paid through the 14th. They bought the month, so they’re owed access until the 14th. Cut them off on the 2nd and you’ve charged for a service you stopped providing, a chargeback waiting to happen.
  • A Checkout that never completed. The subscription row exists, but the first payment never cleared. A row existing is not the customer paying. Treat “a row is here” as “they’re a customer” and you hand out the product for free.

There is no smarter boolean. The fix is to stop throwing the information away. Stripe already tracks which situation a subscription is in, as a status string: trialing, active, past_due, canceled, incomplete. Each carries a precise meaning and a matching app behavior, so you store the string and decode it once, into the only question your features care about.

A boolean stored next to the status would be a second source of truth: a webhook updates status, some other path forgets to recompute is_paid, and now the row says past_due and is_paid: true at once, with no way to tell which to believe. Keep one stored fact, the status, and derive access at read time so the two can never disagree.

The subscription lifecycle, state by state

Section titled “The subscription lifecycle, state by state”

A subscription moves through a sequence of states, and the transitions between them are where your app has work to do. A trial converts to paid. A card fails, so the subscription slides into a retry window and then recovers or gives up. A customer schedules a cancellation, and the subscription winds down to its paid-through date before it ends. That shape is a state machine, best learned one state at a time.

The walker below shows every state, every transition, and the Stripe event (like invoice.payment_failed) that triggers each move. Those events are the webhook signals you ingested in the webhook ingestion chapter; this is the other side of that pipe. For each state, read what it means and, more importantly, what your app does while a subscription sits there.

Subscription lifecycle
stateDiagram-v2
  direction LR
  [*] --> trialing : created with trial
  [*] --> active : created, no trial
  [*] --> incomplete : first charge fails
  trialing --> active : trial converts
  active --> past_due : invoice.payment_failed
  past_due --> active : retry succeeds
  past_due --> canceled : dunning exhausted
  active --> active : cancel_at_period_end = true
  active --> canceled : customer.subscription.deleted
  incomplete --> active : completed in window
  incomplete --> canceled : window expires

Two states deserve a second look.

past_due surprises people. The instinct, “payment failed, cut them off,” is backwards. When a renewal charge fails, Stripe starts a sequence of automatic retries, called dunning : by default several attempts across about two weeks, and many renewals recover once the bank clears the charge or the customer updates the card. So during past_due, keep the customer working and show a banner asking them to fix their card. Lock out only when dunning runs out and the status moves to canceled.

Cancellation isn’t an instant flip either. When a customer cancels in the Portal, the default is to cancel at period end: the status stays active and a separate flag, cancelAtPeriodEnd, becomes true. They keep full access through the date they’ve paid for. Only when that date arrives does Stripe fire customer.subscription.deleted and the status become canceled. “They cancelled” and “they lost access” are two different moments, sometimes weeks apart, and the access check has to account for that.

Now flatten the states into a single answer: for each status, does the customer get in? The table is that decision written down, before any code.

StatusAccess?Why
trialingGrantA trial is full access.
activeGrantHealthy, and also covers the wind-down, which stays active.
past_dueGrantDunning grace. Warn them; don’t lock them out.
canceledDenyThe relationship has ended.
incompleteDenyNever truly subscribed.

The table never special-cases “cancelled but still in the paid period,” and it doesn’t have to. Stripe holds the status at active (with cancelAtPeriodEnd: true) for the whole wind-down and flips to canceled only once the paid period is over. So active already covers the customer who cancelled but has time left, and canceled always means no access. The grace is carried entirely by active.

This is the single place that knows the access table. Every gate calls it; nobody hand-writes status === 'active' || status === 'trialing'. Scatter that check and someone eventually drops trialing, or forgets past_due should pass, and ships a lockout. One function, one switch, one place to be right.

import type { PlanEntitlement } from '@/db/queries/entitlements';
export const hasActiveAccess = (entitlement: PlanEntitlement): boolean => {
switch (entitlement.status) {
case 'trialing':
case 'active':
case 'past_due':
return true;
case 'canceled':
case 'incomplete':
return false;
default: {
const _exhaustive: never = entitlement.status;
return _exhaustive;
}
}
};

Import PlanEntitlement from last lesson’s entitlements module (typeof planEntitlements.$inferSelect) rather than redefine the row type here. That keeps the switch in lockstep with the stored status union.

import type { PlanEntitlement } from '@/db/queries/entitlements';
export const hasActiveAccess = (entitlement: PlanEntitlement): boolean => {
switch (entitlement.status) {
case 'trialing':
case 'active':
case 'past_due':
return true;
case 'canceled':
case 'incomplete':
return false;
default: {
const _exhaustive: never = entitlement.status;
return _exhaustive;
}
}
};

A PlanEntitlement in, a boolean out. The input is the whole entitlement, so call sites read hasActiveAccess(entitlement).

import type { PlanEntitlement } from '@/db/queries/entitlements';
export const hasActiveAccess = (entitlement: PlanEntitlement): boolean => {
switch (entitlement.status) {
case 'trialing':
case 'active':
case 'past_due':
return true;
case 'canceled':
case 'incomplete':
return false;
default: {
const _exhaustive: never = entitlement.status;
return _exhaustive;
}
}
};

The grant cases. trialing, active, and past_due fall through to one return true. active does double duty: it also covers the wind-down, which stays active until the paid period ends.

import type { PlanEntitlement } from '@/db/queries/entitlements';
export const hasActiveAccess = (entitlement: PlanEntitlement): boolean => {
switch (entitlement.status) {
case 'trialing':
case 'active':
case 'past_due':
return true;
case 'canceled':
case 'incomplete':
return false;
default: {
const _exhaustive: never = entitlement.status;
return _exhaustive;
}
}
};

The deny cases. canceled (a relationship that ended) and incomplete (a Checkout that never cleared) return false.

import type { PlanEntitlement } from '@/db/queries/entitlements';
export const hasActiveAccess = (entitlement: PlanEntitlement): boolean => {
switch (entitlement.status) {
case 'trialing':
case 'active':
case 'past_due':
return true;
case 'canceled':
case 'incomplete':
return false;
default: {
const _exhaustive: never = entitlement.status;
return _exhaustive;
}
}
};

The switch covers all five labels, so by default entitlement.status has narrowed to never, and assigning it to a never-typed variable compiles. Add a sixth status and this line stops compiling: the build fails here, on the one function that must decide what the new status means.

1 / 1

That never default is the difference between a comment and a guarantee: a comment saying “update this if you add a status” can’t fail a build; the never assignment turns “handle the new case” into a compile error on this exact function. With the project’s noFallthroughCasesInSwitch rule, the switch can only be wrong loudly, at build time.

Now write it yourself: fill in the switch so each status maps to the right decision. The tests check all five.

Implement hasActiveAccess: return true for the statuses that should keep access (trialing, active, past_due) and false for the rest (canceled, incomplete). It takes a bare Status string here so the sandbox stays self-contained — the canonical version above takes the whole entitlement, but the decision is the same. The App below just renders the result per status so the tests can read it; you only need to complete the function.

Preview

    You call hasActiveAccess from the gates that protect access. The simplest gate is a Server Component that reads the org’s entitlement and renders a paywall when access is denied:

    const entitlement = await getEntitlement(orgId);
    if (!hasActiveAccess(entitlement)) {
    return <Paywall />;
    }

    That’s the shape, not the finished tool. You don’t want every protected page repeating getEntitlement, hasActiveAccess, then a paywall by hand. You want one helper, requirePlan('pro'), that does the read, runs this check, and throws to the framework boundary when access is denied, the way requireUser() already guards authentication. That helper is the next lesson, where hasActiveAccess becomes the boolean core requirePlan is built on.

    Two questions hide here, and conflating them is a classic mistake: “Can this org get in at all?” and “Which tier are they on?” Different fields answer them. Access comes from hasActiveAccess(entitlement), which reads status. Tier comes from entitlement.plan, one of 'free', 'pro', or 'team'. The two are independent: every combination is possible.

    The cell people get wrong is a past_due Pro org. Their card failed, so access is still true (dunning grace); they’re still on Pro, so tier is still pro. They should keep seeing Pro features while Stripe retries the card. Downgrading them to free conflates an access signal (past_due) with a tier change. Tier changes only when the plan changes; a failed payment moves the access axis, not the tier axis.

    Access and tier are independent axes: a failed payment moves you down the access axis, never across the tier axis.

    At a call site you read the two axes independently. hasActiveAccess(entitlement) is the access gate: show the app or the paywall. entitlement.plan is the tier gate: show the Pro analytics panel or an upgrade nudge to a customer who already has access. A page may ask both: first “are you in?”, then “is your tier high enough for this panel?” Two questions, two fields, never collapsed into one.

    So far this has been about the gate, the silent yes/no that decides what renders. But the Saturday-morning lockout wasn’t a gate failure, it was a communication failure: the card had been failing for days and nobody told the customer. Status is also something the customer has to see while there’s still time to act.

    So the UI rule mirrors the gate rule. Every authenticated page renders a status banner above the main content whenever the status is something the customer needs to act on: everything except the two quiet states, active and trialing, plus the one active sub-case that does need a banner, a subscription winding down. Each banner’s copy is keyed by status and carries a call to action pointing at the exact place to fix the problem, never a vague “Manage billing.”

    This record maps status to message and CTA. It’s illustrative, not a finished component:

    banner copy (illustrative)
    const bannerCopy = {
    past_due: {
    message: 'Your payment failed — update your card to keep Pro.',
    ctaLabel: 'Update payment method',
    destination: 'portal',
    },
    winding_down: {
    message: 'Your subscription ends on {date} — reactivate to keep your features.',
    ctaLabel: 'Keep my subscription',
    destination: 'portal',
    },
    canceled: {
    message: 'Your subscription was canceled — re-subscribe to restore access.',
    ctaLabel: 'Re-subscribe',
    destination: 'checkout',
    },
    };

    The destination field encodes an asymmetry from the last two lessons. Two banners send the customer to the Customer Portal: past_due (to fix the failing card) and the winding-down case (to undo the scheduled cancellation, flipping cancelAtPeriodEnd back to false). Both have a live subscription to modify, which is what the Portal is for. The canceled banner sends them to Checkout instead: with no live subscription left, they’re starting a new one, and that’s Checkout’s job. Starting bills through Checkout, modifying through the Portal.

    The tabs below show each banner in the product. The tone escalates from a gentle nudge (past_due, where the customer is probably fine) to a last call (canceled, where they’ve already lost access).

    Your payment failed — update your card to keep Pro.
    Card failed, dunning in progress — keep them working, point them at the Portal.

    These are non-urgent messages, so the banner container is a role="status" live region: a screen reader announces it politely when it appears, without interrupting the user, the right register for “your card needs attention.”

    Both traps come from respecting a boundary: one between your statuses and Stripe’s, one between an automatic action and a human decision.

    Don’t invent statuses Stripe doesn’t ship. Sooner or later you’ll want a state Stripe has no label for. The classic is “the trial ended.” Adding a synthetic 'trial_ended' status is tempting, but resist. Stripe never emits it, so to know a trial ended you’d track that it was trialing and now isn’t, a small state machine over event history that someone must maintain and that drifts out of sync with Stripe. Instead, keep your stored statuses a faithful mirror of Stripe’s and express any derived question as a pure predicate over fields you already have. “Is this subscription winding down?” isn’t a new status; two existing fields answer it:

    const isWindingDown = (entitlement: PlanEntitlement): boolean =>
    entitlement.status === 'active' && entitlement.cancelAtPeriodEnd;

    No new stored field to keep in sync. “Is this org in its dunning grace window?” is entitlement.status === 'past_due', wrapped in an isInGracePeriod predicate for readability. The discipline: exactly one switch over the raw status (hasActiveAccess), everything else a thin predicate over existing fields. Stored statuses come from Stripe; meanings are computed in your code.

    Seat overage is a human decision, not an automatic one. A Team org bought 10 seats and filled all 10. The owner opens the Portal and downgrades to 5. Your seats column updates to 5 on the next webhook, and now the org has 10 members but 5 seats. The wrong answer, the one that feels “consistent,” is to automatically remove 5 members. Never do that: the app can’t know who’s expendable, and silently removing people from an organization is the kind of destructive surprise that ends a customer relationship. Instead, surface the constraint and let the owner resolve it: show “10 members, 5 seats: remove 5 members or add seats,” block new invites until they’re back in balance, and leave existing members in place. The entitlement says how many seats they bought; the membership data, the source of truth from the organizations chapter, says how many they have. The gate doesn’t delete the difference; it blocks the next action that would cross the line.

    Two checks on the ideas that carry the lesson: how to react to past_due, and why cancellation is a sequence over time, not a single switch.

    A customer’s monthly renewal charge just bounced and their subscription is now past_due. Of the four reactions below, which one does your app take right now?

    Let them keep working and raise a banner pointing them at the Customer Portal to fix their card.
    Pull access immediately and restore it only once a charge finally clears.
    Move them to the free plan so the paid features stop rendering until they pay.
    Tear down the subscription row and route them through Checkout to start over.

    A graceful cancellation is a sequence that plays out over days or weeks, and the status flips to canceled only at the very end. Put the steps in order.

    A customer on Pro cancels their subscription. Put the steps of the graceful cancellation in the order they actually happen — remember the status doesn't change until the paid period is over. Drag the items into the correct order, then press Check.

    Customer clicks Cancel in the Customer Portal
    Stripe keeps the subscription active and sets cancelAtPeriodEnd to true
    Your app shows the “ends on <date>” banner with a reactivate CTA
    The paid period reaches its end date
    Stripe fires customer.subscription.deleted
    The webhook writes status: 'canceled' to the entitlement row
    hasActiveAccess now returns false and access ends

    The Stripe references behind this lesson: the status lifecycle, the status enum your row mirrors, and how the automatic failed-payment retries (dunning) behave.