Entitlements Need Plan and Status

Storyie Engineering Team
5 min read

A plan name alone cannot decide Pro access — billing status matters too. How we folded the plan × status truth table into one shared predicate.

The same Storyie user was Pro in one feature and Free in another. Image UI treated them as Free because it only accepted status === "active". AI assist treated them as Pro on trialing. The monthly report cron already included past_due. Export gated on name !== "free", so almost anything not literally free looked paid. None of those checks were "wrong" in isolation — they just answered different questions. This post is about collapsing that plan × billing-status truth table into one predicate every gate shares.

TL;DR

  • Entitlement is two-dimensional. Plan name picks the tier; subscription status says whether that tier still grants access. Either field alone is insufficient.
  • Pro plan names: pro and legacy pro_v1 (via PRO_PLAN_NAMES / isProPlan).
  • Access-granting statuses: active, trialing, past_due (via ACTIVE_STATUSES / PRO_ENTITLED_STATUSES).
  • Canonical predicate: isProEntitled(planName, status) === isProPlan(planName) && isActiveSubscription(status).
  • SQL uses the same lists. Query builders import PRO_PLAN_NAMES and PRO_ENTITLED_STATUSES so IN-clauses cannot drift from the TypeScript helper.
  • Full matrix and call-site audit live in packages/subscription and ADR-0028; neighboring quota work is in UTC month boundaries for subscription quotas.

One field is never enough

Before the unification (issue #1210), each feature invented its own "are they Pro?" check. The contradiction matrix looked like this:

Gate

What it checked

trialing

past_due

legacy pro_v1

Image UI checkIsProUser

status === "active" plus unlimited diaries

Free

Free

ignored

AI assist limit

name === "pro" and active/trialing

Pro

Free

Free

getActiveSubscription

active/trialing

Pro

Free

Pro

getLimitsForPlan

name === "pro" only

Free caps

Monthly report cron

pro + active/trialing/past_due

Pro

Pro

Free

Export isFreePlan

name === "free"

everything else treated as Pro

Pro

Two failure modes show up immediately.

Status alone: a canceled row can still say plan = pro. If you only look at the plan column, that user keeps Pro images, AI quota, and unlimited diaries after billing has ended.

Plan alone: a free tenant with status = active is a perfectly healthy free subscription — not a paid entitlement. Status tells you the lifecycle window is open; it does not tell you which product they bought.

The product requirement is the AND of both axes. Anything else is a local approximation that will disagree with the next gate the user hits.

Plan aliases: pro and pro_v1

Plan identity in the database is a string on subscription_plans.name. We currently ship two names that mean full Pro limits:

// packages/subscription/src/constants/plans.ts (excerpt)
/**
 * Plan names that grant Pro entitlements.
 *
 * Both the current `pro` plan and the legacy `pro_v1` plan receive full Pro
 * limits. This is the single source of truth for "which plan names are Pro" —
 * SQL/query builders (e.g. `sp.name IN (...)`) derive their IN-list from here so
 * every feature agrees on the same set.
 */
export const PRO_PLAN_NAMES: readonly string[] = ["pro", "pro_v1"] as const;

isProPlan is a thin membership check over that list. Keeping pro_v1 is boring, deliberate backward compatibility: existing paid rows still carry the old name, and silently mapping them to Free would be a billing incident disguised as a refactor. New code never invents a third spelling of Pro without updating this constant first.

Active states: grace is part of the product

Billing status is a separate enum. Feature access is not "Stripe says active." We grant access for three values:

// packages/subscription/src/constants/status.ts (excerpt)
/**
 * Subscription statuses that grant feature access.
 * - active: Subscription is active and billing successfully
 * - trialing: User is in trial period
 * - past_due: Payment failed, grace period active (still grants access)
 */
export const ACTIVE_STATUSES: readonly SubscriptionStatus[] = [
  "active",
  "trialing",
  "past_due",
] as const;

/**
 * Subscription statuses that grant Pro entitlements.
 *
 * Alias of {@link ACTIVE_STATUSES} exported under an entitlement-focused name so
 * SQL/query builders can share the exact same list when filtering Pro users
 * (e.g. `inArray(subscriptions.status, PRO_ENTITLED_STATUSES)`). Keeping it as an
 * alias guarantees the two never drift apart.
 */
export const PRO_ENTITLED_STATUSES: readonly SubscriptionStatus[] = ACTIVE_STATUSES;

trialing is obvious — a trial is supposed to feel like Pro. past_due is the deliberate product call. Stripe keeps the subscription in dunning while it retries the charge; yanking Pro at the first failure punishes a recoverable card problem and races webhook lag. We aligned every gate with the monthly-report cron, which already treated past_due as entitled, rather than teaching that cron a harsher rule.

Everything else — canceled, unpaid, incomplete, paused — is not entitled. PRO_ENTITLED_STATUSES is an alias, not a copy, so SQL IN (...) lists and isActiveSubscription cannot disagree about the set.

The predicate: fold the truth table once

The TypeScript API is one function that combines both lists:

// packages/subscription/src/utils/status.ts (excerpt)
/**
 * Check if a user is entitled to Pro features.
 *
 * This is the single canonical entitlement check for the whole product. A user
 * is Pro-entitled when BOTH:
 * - the plan is a Pro plan (`pro` or legacy `pro_v1`), AND
 * - the subscription status grants access (`active`, `trialing`, or `past_due`).
 */
export function isProEntitled(planName: string, status: string): boolean {
  return isProPlan(planName) && isActiveSubscription(status as SubscriptionStatus);
}

The resulting matrix is small enough to hold in your head — and small enough to pin in a unit test:

status \ plan

pro

pro_v1

free

active

entitled

entitled

no

trialing

entitled

entitled

no

past_due

entitled

entitled

no

canceled / unpaid / incomplete / paused

no

no

no

packages/subscription/__tests__/status.test.ts walks the Pro plans × access-granting statuses as a nested loop, asserts canceled fails for each Pro plan, and asserts every status fails for free. The package stays platform-agnostic: pure functions, no Drizzle, no Stripe SDK — the same design we described in the subscription package monorepo post.

Gate audit: call the predicate or share its lists

Centralizing a helper only helps if call sites stop inventing local definitions. After ADR-0028, the surfaces look like this:

In-process TypeScript — call isProEntitled (or compose isProPlan with a status already filtered to the entitled set). The image-limit backstop is the clearest example:

// apps/web/lib/utils/image-limit-helpers.ts (excerpt)
const planName = result?.subscription?.plan?.name;
const status = result?.subscription?.subscription?.status;
const isPro = planName != null && status != null && isProEntitled(planName, status);
const limits = getLimitsForPlan(isPro ? "pro" : "free");

Tenant context that already loaded a row through getActiveSubscription (status pre-filtered with PRO_ENTITLED_STATUSES) only needs isProPlan for "paid vs free" — the status axis was applied in the query.

SQL / raw queries — cannot call the function. They import the same constants and build IN-lists:

// apps/web/services/subscriptionService.ts (excerpt)
const entitledStatusList = sql.join(
  PRO_ENTITLED_STATUSES.map((status) => sql`${status}`),
  sql`, `
);
const proPlanNameList = sql.join(
  PRO_PLAN_NAMES.map((name) => sql`${name}`),
  sql`, `
);
// ... AND s.status IN (${entitledStatusList}) AND sp.name IN (${proPlanNameList})

The same pattern appears in content-limit notifications, getActiveSubscription, and the monthly-report job in packages/jobs. Expo’s proStatusService uses isProPlan against an API payload whose isActive flag is produced by the web stack that already follows this policy — the mobile client does not redefine the truth table.

The audit rule for new gates is short: if you are about to write status === "active" or planName === "pro", stop. Import isProEntitled, or import the shared lists for SQL. A third local definition is how we got the contradiction matrix in the first place.

Closing

Entitlement bugs feel random because they are path-dependent: the user hits whichever feature implemented "Pro" last. Folding plan × status into isProEntitled — and forcing SQL to share PRO_PLAN_NAMES / PRO_ENTITLED_STATUSES — makes "are they Pro?" a property of the subscription row, not of the feature file you opened.

Related reading: how monthly quotas agree on a single UTC window in UTC month boundaries for subscription quotas, and why limit math lives in a DB-free package in Shared subscription limits across web and mobile.

Available on iOS & Android

Download on the App StoreGet it on Google Play