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

  1. Inspect the offending subscription row (SELECT reference_id, plan, status FROM subscription WHERE ...) and see what value was stored
  2. Add the missing plan key to planLimits in lib/plans.ts (and its limits), or fix the row with a migration/UPDATE
  3. Align the Stripe webhook mapping so price IDs map to exactly the plan keys defined in plans.ts
  4. 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

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.