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:
proand legacypro_v1(viaPRO_PLAN_NAMES/isProPlan). - Access-granting statuses:
active,trialing,past_due(viaACTIVE_STATUSES/PRO_ENTITLED_STATUSES). - Canonical predicate:
isProEntitled(planName, status) === isProPlan(planName) && isActiveSubscription(status). - SQL uses the same lists. Query builders import
PRO_PLAN_NAMESandPRO_ENTITLED_STATUSESso IN-clauses cannot drift from the TypeScript helper. - Full matrix and call-site audit live in
packages/subscriptionand 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 |
|
| legacy |
|---|---|---|---|---|
Image UI |
| Free | Free | ignored |
AI assist limit |
| Pro | Free | Free |
| active/trialing | Pro | Free | Pro |
|
| — | — | Free caps |
Monthly report cron |
| Pro | Pro | Free |
Export |
| 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 |
|
|
|
|---|---|---|---|
| entitled | entitled | no |
| entitled | entitled | no |
| entitled | entitled | no |
| 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.