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

  1. Only pass 'pro', 'ai', 'team', or 'selfhostedteam' as plan to checkout/subscription mutations.
  2. Filter the plan options in your UI/pricing config to the purchasable set before rendering or submitting.
  3. 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

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


AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18). Data as JSON: /api/errors/efe2e50966f135ea. Report an issue: GitHub.