From 409a0277fb4b78bd327db7f612e8f7e8fe8b92ff Mon Sep 17 00:00:00 2001 From: Alex van Andel Date: Fri, 3 Oct 2025 16:37:45 +0100 Subject: [PATCH] fix: Resolve BillingPlan tree-shaking issue causing runtime error (#24229) * fix: Convert BillingPlan enum to const object to prevent webpack tree-shaking TypeScript enums compile to IIFEs which can be incorrectly tree-shaken by webpack when 'sideEffects: false' is set in package.json. Converting to a const object with 'as const' avoids the IIFE pattern while maintaining full type safety. This fixes the 'TRPCError: BillingPlan is not defined' error that occurred in the hasTeamPlan tRPC handler. Follows the existing CHECKOUT_SESSION_TYPES pattern in the same file. Co-Authored-By: alex@cal.com * fix: Re-export BillingPlan from billing-plans to prevent tree-shaking The issue was a module initialization order problem. When BillingPlan was imported from constants.ts into billing-plans.ts and used inside the BillingPlanService class, webpack's tree-shaker (with sideEffects: false) couldn't properly track the value dependency across the package boundary. By re-exporting BillingPlan from the same module that exports BillingPlanService, we ensure the constant is bundled together with the class that uses it, preventing tree-shaking. Co-Authored-By: alex@cal.com * revert: Restore BillingPlan enum format in constants.ts The const object conversion didn't fix the tree-shaking issue. The real fix is re-exporting BillingPlan from billing-plans.ts to ensure it's part of the same module as BillingPlanService. Co-Authored-By: alex@cal.com * fix: Add BillingPlan as private static member to prevent tree-shaking By making BillingPlan a private static member of BillingPlanService, webpack now sees it as part of the class definition rather than just used inside methods. This creates a strong reference that prevents webpack's tree-shaker from removing the enum initialization IIFE when 'sideEffects: false' is set. With the previous approach where BillingPlan was only imported and used inside methods, webpack's static analysis couldn't properly track the enum usage across package boundaries (@calcom/features -> @calcom/trpc), causing it to incorrectly determine the enum initialization was unused code. Fixes the TRPCError: BillingPlan is not defined runtime error. Thread: https://calendso.slack.com/archives/C08LT9BLEET/p1759420015428149 Co-Authored-By: alex@cal.com * fix: Clean up BillingPlan references and add explanatory comment - Remove unnecessary re-export statement - Use direct BillingPlan references instead of BillingPlanService.BillingPlan - Add detailed comment explaining webpack tree-shaking workaround - Keep import from constants.ts and private static member for webpack reference Co-Authored-By: alex@cal.com * fix: Remove Slack reference from comment and restore static usage - Remove Slack URL from explanatory comment - Restore BillingPlanService.BillingPlan usage throughout class methods - This static member usage is essential for webpack to track the enum reference Co-Authored-By: alex@cal.com * Fix BillingPlan issue by finding root cause --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/features/ee/billing/constants.ts | 34 ++++----- .../ee/billing/domain/billing-plans.ts | 71 +++++++++---------- 2 files changed, 50 insertions(+), 55 deletions(-) diff --git a/packages/features/ee/billing/constants.ts b/packages/features/ee/billing/constants.ts index ca52b2d224..04229ed03b 100644 --- a/packages/features/ee/billing/constants.ts +++ b/packages/features/ee/billing/constants.ts @@ -8,24 +8,26 @@ export const CHECKOUT_SESSION_TYPES = { export type CheckoutSessionType = (typeof CHECKOUT_SESSION_TYPES)[keyof typeof CHECKOUT_SESSION_TYPES]; -export enum BillingPlan { - INDIVIDUALS = "INDIVIDUALS", - TEAMS = "TEAMS", - ORGANIZATIONS = "ORGANIZATIONS", - ENTERPRISE = "ENTERPRISE", - PLATFORM_STARTER = "PLATFORM_STARTER", - PLATFORM_ESSENTIALS = "PLATFORM_ESSENTIALS", - PLATFORM_SCALE = "PLATFORM_SCALE", - PLATFORM_ENTERPRISE = "PLATFORM_ENTERPRISE", - UNKNOWN = "Unknown", -} +export const BILLING_PLANS = { + INDIVIDUALS: "INDIVIDUALS", + TEAMS: "TEAMS", + ORGANIZATIONS: "ORGANIZATIONS", + ENTERPRISE: "ENTERPRISE", + PLATFORM_STARTER: "PLATFORM_STARTER", + PLATFORM_ESSENTIALS: "PLATFORM_ESSENTIALS", + PLATFORM_SCALE: "PLATFORM_SCALE", + PLATFORM_ENTERPRISE: "PLATFORM_ENTERPRISE", + UNKNOWN: "Unknown", +} as const; + +export type BillingPlan = (typeof BILLING_PLANS)[keyof typeof BILLING_PLANS]; export const PLATFORM_PLANS_MAP: Record = { - FREE: BillingPlan.PLATFORM_STARTER, - STARTER: BillingPlan.PLATFORM_STARTER, - ESSENTIALS: BillingPlan.PLATFORM_ESSENTIALS, - SCALE: BillingPlan.PLATFORM_SCALE, - ENTERPRISE: BillingPlan.PLATFORM_ENTERPRISE, + FREE: BILLING_PLANS.PLATFORM_STARTER, + STARTER: BILLING_PLANS.PLATFORM_STARTER, + ESSENTIALS: BILLING_PLANS.PLATFORM_ESSENTIALS, + SCALE: BILLING_PLANS.PLATFORM_SCALE, + ENTERPRISE: BILLING_PLANS.PLATFORM_ENTERPRISE, }; export const PLATFORM_ENTERPRISE_SLUGS = process.env.PLATFORM_ENTERPRISE_SLUGS?.split(",") ?? []; diff --git a/packages/features/ee/billing/domain/billing-plans.ts b/packages/features/ee/billing/domain/billing-plans.ts index 03e170a5b3..1ef19ec08c 100644 --- a/packages/features/ee/billing/domain/billing-plans.ts +++ b/packages/features/ee/billing/domain/billing-plans.ts @@ -1,5 +1,6 @@ import { - BillingPlan, + type BillingPlan, + BILLING_PLANS, ENTERPRISE_SLUGS, PLATFORM_ENTERPRISE_SLUGS, PLATFORM_PLANS_MAP, @@ -29,51 +30,43 @@ export class BillingPlanService { isPlatformManaged: boolean; }; }[] - ) { - if (memberships.length === 0) return BillingPlan.INDIVIDUALS; + ): Promise { + if (memberships.length === 0) return BILLING_PLANS.INDIVIDUALS; for (const { team, user } of memberships) { if (team.isPlatform || user.isPlatformManaged) { - if (PLATFORM_ENTERPRISE_SLUGS.includes(team.slug ?? "")) return BillingPlan.PLATFORM_ENTERPRISE; + if (PLATFORM_ENTERPRISE_SLUGS.includes(team.slug ?? "")) return BILLING_PLANS.PLATFORM_ENTERPRISE; if (!team.platformBilling) continue; return PLATFORM_PLANS_MAP[team.platformBilling.plan] ?? team.platformBilling.plan; - } else { - let teamMetadata; - try { - teamMetadata = teamMetadataStrictSchema.parse(team.metadata ?? {}); - } catch { - teamMetadata = null; - } - - let parentTeamMetadata; - try { - parentTeamMetadata = teamMetadataStrictSchema.parse(team.parent?.metadata ?? {}); - } catch { - parentTeamMetadata = null; - } - - if ( - team.parent && - team.parent.isOrganization && - parentTeamMetadata?.subscriptionId && - !team.parent.isPlatform - ) { - return ENTERPRISE_SLUGS.includes(team.parent.slug ?? "") - ? BillingPlan.ENTERPRISE - : BillingPlan.ORGANIZATIONS; - } - - if (!teamMetadata?.subscriptionId) continue; - if (team.isOrganization) { - return ENTERPRISE_SLUGS.includes(team.slug ?? "") - ? BillingPlan.ENTERPRISE - : BillingPlan.ORGANIZATIONS; - } else { - return BillingPlan.TEAMS; - } } + const parentTeamMetadataResult = teamMetadataStrictSchema.safeParse(team.parent?.metadata ?? {}); + const parentTeamMetadata = parentTeamMetadataResult.success ? parentTeamMetadataResult.data : null; + if ( + team.parent && + team.parent.isOrganization && + parentTeamMetadata?.subscriptionId && + !team.parent.isPlatform + ) { + return ENTERPRISE_SLUGS.includes(team.parent.slug ?? "") + ? BILLING_PLANS.ENTERPRISE + : BILLING_PLANS.ORGANIZATIONS; + } + const teamMetadataResult = teamMetadataStrictSchema.safeParse(team.metadata ?? {}); + const teamMetadata = teamMetadataResult.success ? teamMetadataResult.data : null; + // (emrysal) if we do an early return on !teamMetadata?.subscriptionId here, the bundler is not smart enough to infer + // that it shouldn't clear out the BILLING_PLANS before the for() scope finishes. + if (team.isOrganization && teamMetadata?.subscriptionId) { + return ENTERPRISE_SLUGS.includes(team.slug ?? "") + ? BILLING_PLANS.ENTERPRISE + : BILLING_PLANS.ORGANIZATIONS; + } + if (teamMetadata?.subscriptionId) { + return BILLING_PLANS.TEAMS; + } + // no subscriptionId or parent subscription id in this loop, so this membership hasn't got a plan. + // continue; } - return BillingPlan.UNKNOWN; + return BILLING_PLANS.UNKNOWN; } }