toeverything/AFFiNE · error · UnsupportedSubscriptionPlan
unsupported_subscription_plan
unsupported_subscription_plan
Error message
Unsupported subscription plan: ${plan}. What it means
Thrown by SubscriptionService.select(plan) when the plan does not map to a subscription manager. Only Team, Pro, AI, and SelfHostedTeam are purchasable-managed plans; Free, Enterprise, SelfHosted (and any unknown string reaching the switch) fall through to UnsupportedSubscriptionPlan.
Source
Thrown at packages/backend/server/src/plugins/payment/service.ts:105
) {
this.scheduleManager = new ScheduleManager(this.stripeProvider);
}
get stripe() {
return this.stripeProvider.stripe;
}
select(plan: SubscriptionPlan): SubscriptionManager {
switch (plan) {
case SubscriptionPlan.Team:
return this.workspaceManager;
case SubscriptionPlan.Pro:
case SubscriptionPlan.AI:
return this.userManager;
case SubscriptionPlan.SelfHostedTeam:
return this.selfhostManager;
default:
throw new UnsupportedSubscriptionPlan({ plan });
}
}
async listPrices(user?: CurrentUser): Promise<KnownStripePrice[]> {
const prices = await this.listStripePrices();
const customer = user
? await this.getOrCreateCustomer({
userId: user.id,
userEmail: user.email,
})
: undefined;
return [
...(await this.userManager.filterPrices(prices, customer)),
...this.workspaceManager.filterPrices(prices, customer),
];
}View on GitHub (pinned to b4c8548c09)
Solutions
- Only pass 'pro', 'ai', 'team', or 'selfhostedteam' as plan to checkout/subscription mutations.
- Filter the plan options in your UI/pricing config to the purchasable set before rendering or submitting.
- Validate the plan against the enum at the client boundary (zod/TS union) so bad values fail locally with a clear message.
Example fix
// before
const PLAN = process.env.NEXT_PUBLIC_PLAN!; // 'free' slips in
await svc.checkout({ plan: PLAN, recurring }, args);
// after
const PurchasablePlan = z.enum(['pro', 'ai', 'team', 'selfhostedteam']);
const plan = PurchasablePlan.parse(inputPlan); // throws client-side, not server-side
await svc.checkout({ plan, recurring }, args); Defensive patterns
Strategy: type-guard
Validate before calling
const PURCHASABLE = ['pro', 'ai', 'team', 'selfhostedteam'] as const; const PurchasablePlan = z.enum(PURCHASABLE); const plan = PurchasablePlan.parse(rawPlan); // fails client-side with clear message
Type guard
type PurchasablePlan = 'pro' | 'ai' | 'team' | 'selfhostedteam';
function isPurchasablePlan(p: string): p is PurchasablePlan {
return p === 'pro' || p === 'ai' || p === 'team' || p === 'selfhostedteam';
} Try / catch
catch (e) { if (gqlErrorCode(e) === 'unsupported_subscription_plan') { filterOutPlanFromUI(plan); logBadPlanSource(plan); return; } throw e; } Prevention
- Keep a single purchasable-plans constant shared by pricing UI and API calls.
- Validate plan at system boundaries (env, deep links, config) before it reaches service.select.
- Regenerate client enums after backend plan changes.
When it happens
Trigger: Calling checkout/cancel/resume/updateRecurring with plan 'free', 'enterprise', or 'selfhosted'; sending a plan string not in the SubscriptionPlan enum that TypeScript did not catch (untyped client, raw HTTP); plan value read from a config/env and passed through unvalidated.
Common situations: Checkout page built from a plan list that includes Free; Enterprise customers trying the self-serve checkout; API consumers using a plan name that changed casing or was renamed across versions.
Related errors
- workspace_id_required_to_update_team_subscription
- invalid_checkout_parameters
- subscription_not_exists
- cant_update_onetime_payment_subscription
- subscription_has_been_canceled
AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18).
Data as JSON: /api/errors/efe2e50966f135ea.
Report an issue: GitHub.