kriasoft/react-starter-kit · error · Error
Unknown plan "${plan}"
Error message
Unknown plan "${plan}" What it means
After resolving the subscription, plan = sub?.plan ?? 'free' is checked against the planLimits lookup table (lib/plans.ts). If the stored subscription plan string is not a known key, this plain Error throws, guarding planLimits[plan as PlanName] from reading an undefined limits object. It is a data-integrity check, not an input-validation error.
Source
Thrown at apps/api/routers/billing.ts:76
}
canManage = canManageOrgBilling(membership.role);
}
const referenceId = organizationId ?? ctx.user.id;
const sub = await ctx.db.query.subscription.findFirst({
where: (s, { eq, and, inArray }) =>
and(
eq(s.referenceId, referenceId),
inArray(s.status, ["active", "trialing"]),
),
});
const plan = sub?.plan ?? "free";
if (!(plan in planLimits)) {
throw new Error(`Unknown plan "${plan}"`);
}
return {
enabled,
canManage,
plan,
status: sub?.status ?? null,
periodEnd: sub?.periodEnd ?? null,
cancelAtPeriodEnd: sub?.cancelAtPeriodEnd ?? false,
limits: planLimits[plan as PlanName],
};
}),
});
View on GitHub (pinned to 0aa7603435)
Solutions
- Inspect the offending subscription row (SELECT reference_id, plan, status FROM subscription WHERE ...) and see what value was stored
- Add the missing plan key to planLimits in lib/plans.ts (and its limits), or fix the row with a migration/UPDATE
- Align the Stripe webhook mapping so price IDs map to exactly the plan keys defined in plans.ts
- Narrow the type: validate sub.plan against PlanName at write time (webhook) instead of only at read time
Example fix
// before
// lib/plans.ts had { free, starter, pro } but DB row has plan: 'starter_v2'
// after
export const planLimits = { free: ..., starter: ..., pro: ..., starter_v2: ... } satisfies Record<PlanName, Limits>;
// or fix the row:
// UPDATE subscription SET plan = 'starter' WHERE plan = 'starter_v2'; Defensive patterns
Strategy: validation
Validate before calling
import { planLimits } from '../lib/plans.js';
function assertKnownPlan(plan: string): asserts plan is keyof typeof planLimits {
if (!(plan in planLimits)) {
throw new Error(`Unknown plan "${plan}" — add it to planLimits or migrate the subscription row`);
}
}
// run against the DB before reading billing:
// SELECT DISTINCT plan FROM subscription WHERE status IN ('active','trialing'); Type guard
function isKnownPlan(plan: string): plan is keyof typeof planLimits {
return plan in planLimits;
} Try / catch
try {
const billing = await trpc.billing.subscription.query();
return billing;
} catch (error) {
if (error instanceof Error && /^Unknown plan /.test(error.message)) {
console.error('Plan data mismatch between DB and plans.ts:', error.message);
return { enabled: true, canManage: false, plan: 'free', limits: fallbackLimits }; // degrade gracefully
}
throw error;
} Prevention
- Keep planLimits in sync with every Stripe price ID added in Terraform/config
- When renaming a plan, write a DB migration updating existing subscription rows in the same deploy
- In the webhook handler, validate incoming plan values against planLimits before persisting
- Add a CI/test that asserts every plan string in seed/fixture data exists in planLimits
When it happens
Trigger: A subscription row in the database has a plan value not present in planLimits — e.g. Stripe webhook wrote a price/product name that doesn't map to a known plan key, or plans.ts was edited/renamed without migrating existing subscription rows.
Common situations: Renaming a plan in lib/plans.ts while old subscriptions keep the old string, adding a new Stripe price in Terraform without adding it to planLimits, or a webhook handler storing planId instead of the mapped plan name.
AI-assisted analysis of kriasoft/react-starter-kit@0aa7603435 (2026-08-31).
Data as JSON: /api/errors/21d9425f9274b971.
Report an issue: GitHub.