From 2ed0380e4fe8b27f03d9f4cccf88efbb6c13e639 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:00:19 +0530 Subject: [PATCH] feat: adds user plan info in `useHasPaidPlan` for intercom (#23790) * feat: adds user plan info in `useHasPaidPlan` for intercom * add to support api route * Update constants.ts * sql migration to backfill plans and create/change plans on upgrade/downgrade/create of teams and orgs * fix: breaking unit tests * test: add comprehensive tests for billing plan service and team/org flows - Add unit tests for BillingPlanService.getUserPlanByMemberships() covering all plan determination scenarios - Add tests for team creation handler verifying TEAMS vs ORGANIZATIONS plan assignment - Add tests for hasTeamPlan handler integration with BillingPlanService - Add tests for MembershipRepository.findAllMembershipsByUserIdForBilling() data fetching - Add tests for InternalTeamBilling upgrade/downgrade flows with proper mocking - All tests follow existing vitest patterns with proper Prisma and service mocking - Covers both self-serve and platform billing scenarios with comprehensive edge cases Co-Authored-By: amit@cal.com * Revert "test: add comprehensive tests for billing plan service and team/org flows" This reverts commit 58e511f15caf8757c3ec45f6d026caf96ee1a75e. * fix: make `BillingPlanService` instantiable and use `TeamRepository` * Revert "fix: make `BillingPlanService` instantiable and use `TeamRepository`" This reverts commit ae1ff8f15b725566b828864a217d8d0e308b520f. * revert to runtime calculations. review fixes * remove uneccessary changes and logs * review fixes * review fixes * fix: type check --------- Co-authored-by: Keith Williams Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../web/app/api/support/conversation/route.ts | 10 ++- packages/features/ee/billing/constants.ts | 23 ++++++ .../ee/billing/domain/billing-plans.ts | 79 +++++++++++++++++++ .../ee/support/lib/intercom/useIntercom.ts | 4 +- packages/lib/hooks/useHasPaidPlan.ts | 6 +- packages/lib/server/repository/membership.ts | 38 ++++++++- .../viewer/organizations/publish.handler.ts | 1 + .../viewer/teams/hasTeamPlan.handler.ts | 12 ++- turbo.json | 4 +- 9 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 packages/features/ee/billing/domain/billing-plans.ts diff --git a/apps/web/app/api/support/conversation/route.ts b/apps/web/app/api/support/conversation/route.ts index d2f1cc2930..671bd39b27 100644 --- a/apps/web/app/api/support/conversation/route.ts +++ b/apps/web/app/api/support/conversation/route.ts @@ -3,13 +3,15 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans"; import type { Contact } from "@calcom/features/ee/support/lib/intercom/intercom"; import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom"; import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { MembershipRepository } from "@calcom/lib/server/repository/membership"; import { UserRepository } from "@calcom/lib/server/repository/user"; -import prisma from "@calcom/prisma"; +import { prisma } from "@calcom/prisma"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -43,6 +45,11 @@ export async function POST(req: NextRequest) { const { user } = session; + const membershipRepository = new MembershipRepository(prisma); + const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId: user.id }); + const billingPlanService = new BillingPlanService(); + const plan = await billingPlanService.getUserPlanByMemberships(memberships); + if (!existingContact.data) { const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id }); const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce( @@ -72,6 +79,7 @@ export async function POST(req: NextRequest) { sum_of_teams: additionalUserInfo?._count?.teams, sum_of_event_types: additionalUserInfo?._count?.eventTypes, sum_of_team_event_types: sumOfTeamEventTypes, + Plan: plan, }, }); diff --git a/packages/features/ee/billing/constants.ts b/packages/features/ee/billing/constants.ts index 3cffd1ce06..ca52b2d224 100644 --- a/packages/features/ee/billing/constants.ts +++ b/packages/features/ee/billing/constants.ts @@ -7,3 +7,26 @@ export const CHECKOUT_SESSION_TYPES = { } as const; 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 PLATFORM_PLANS_MAP: Record = { + FREE: BillingPlan.PLATFORM_STARTER, + STARTER: BillingPlan.PLATFORM_STARTER, + ESSENTIALS: BillingPlan.PLATFORM_ESSENTIALS, + SCALE: BillingPlan.PLATFORM_SCALE, + ENTERPRISE: BillingPlan.PLATFORM_ENTERPRISE, +}; + +export const PLATFORM_ENTERPRISE_SLUGS = process.env.PLATFORM_ENTERPRISE_SLUGS?.split(",") ?? []; +export const ENTERPRISE_SLUGS = process.env.ENTERPRISE_SLUGS?.split(",") ?? []; diff --git a/packages/features/ee/billing/domain/billing-plans.ts b/packages/features/ee/billing/domain/billing-plans.ts new file mode 100644 index 0000000000..03e170a5b3 --- /dev/null +++ b/packages/features/ee/billing/domain/billing-plans.ts @@ -0,0 +1,79 @@ +import { + BillingPlan, + ENTERPRISE_SLUGS, + PLATFORM_ENTERPRISE_SLUGS, + PLATFORM_PLANS_MAP, +} from "@calcom/features/ee/billing/constants"; +import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; +import type { JsonValue } from "@calcom/types/Json"; + +export class BillingPlanService { + async getUserPlanByMemberships( + memberships: { + team: { + isOrganization: boolean; + isPlatform: boolean; + slug: string | null; + metadata: JsonValue; + parent: { + isOrganization: boolean; + slug: string | null; + isPlatform: boolean; + metadata: JsonValue; + } | null; + platformBilling: { + plan: string; + } | null; + }; + user: { + isPlatformManaged: boolean; + }; + }[] + ) { + if (memberships.length === 0) return BillingPlan.INDIVIDUALS; + + for (const { team, user } of memberships) { + if (team.isPlatform || user.isPlatformManaged) { + if (PLATFORM_ENTERPRISE_SLUGS.includes(team.slug ?? "")) return BillingPlan.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; + } + } + } + return BillingPlan.UNKNOWN; + } +} diff --git a/packages/features/ee/support/lib/intercom/useIntercom.ts b/packages/features/ee/support/lib/intercom/useIntercom.ts index 30d60f7a6a..7b08f4ad56 100644 --- a/packages/features/ee/support/lib/intercom/useIntercom.ts +++ b/packages/features/ee/support/lib/intercom/useIntercom.ts @@ -40,7 +40,7 @@ export const useIntercom = () => { }, }, }); - const { hasPaidPlan } = useHasPaidPlan(); + const { hasPaidPlan, plan } = useHasPaidPlan(); const { hasTeamPlan } = useHasTeamPlan(); const boot = async () => { @@ -83,6 +83,7 @@ export const useIntercom = () => { sum_of_event_types: statsData?.sumOfEventTypes, sum_of_team_event_types: statsData?.sumOfTeamEventTypes, is_premium: data?.isPremium, + Plan: plan, }, }); }; @@ -127,6 +128,7 @@ export const useIntercom = () => { sum_of_event_types: statsData?.sumOfEventTypes, sum_of_team_event_types: statsData?.sumOfTeamEventTypes, is_premium: data?.isPremium, + Plan: plan, }, }); hookData.show(); diff --git a/packages/lib/hooks/useHasPaidPlan.ts b/packages/lib/hooks/useHasPaidPlan.ts index e57de06e77..fa08470a3a 100644 --- a/packages/lib/hooks/useHasPaidPlan.ts +++ b/packages/lib/hooks/useHasPaidPlan.ts @@ -6,7 +6,7 @@ import hasKeyInMetadata from "../hasKeyInMetadata"; export function useHasPaidPlan() { if (IS_SELF_HOSTED) return { isPending: false, hasPaidPlan: true }; - const { data: hasTeamPlan, isPending: isPendingTeamQuery } = trpc.viewer.teams.hasTeamPlan.useQuery(); + const { data, isPending: isPendingTeamQuery } = trpc.viewer.teams.hasTeamPlan.useQuery(); const { data: user, isPending: isPendingUserQuery } = trpc.viewer.me.get.useQuery(); @@ -15,9 +15,9 @@ export function useHasPaidPlan() { const isCurrentUsernamePremium = user && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false; - const hasPaidPlan = hasTeamPlan?.hasTeamPlan || isCurrentUsernamePremium; + const hasPaidPlan = data?.hasTeamPlan || isCurrentUsernamePremium; - return { isPending, hasPaidPlan }; + return { isPending, hasPaidPlan, plan: data?.plan }; } export function useTeamInvites() { diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index 6063f14136..aad4979c60 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -1,7 +1,6 @@ - import { availabilityUserSelect, prisma, type PrismaTransaction } from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; import type { Prisma, Membership, PrismaClient } from "@calcom/prisma/client"; +import { MembershipRole } from "@calcom/prisma/enums"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; import logger from "../../logger"; @@ -315,6 +314,41 @@ export class MembershipRepository { }); } + async findAllMembershipsByUserIdForBilling({ userId }: { userId: number }) { + return this.prismaClient.membership.findMany({ + where: { userId }, + select: { + accepted: true, + user: { + select: { + isPlatformManaged: true, + }, + }, + team: { + select: { + slug: true, + isOrganization: true, + isPlatform: true, + metadata: true, + platformBilling: { + select: { + plan: true, + }, + }, + parent: { + select: { + isOrganization: true, + slug: true, + metadata: true, + isPlatform: true, + }, + }, + }, + }, + }, + }); + } + static async findByTeamIdForAvailability({ teamId }: { teamId: number }) { const memberships = await prisma.membership.findMany({ where: { teamId }, diff --git a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts index a6f270af1f..8b9aca4497 100644 --- a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts @@ -66,6 +66,7 @@ export const publishHandler = async ({ ctx }: PublishOptions) => { const { requestedSlug, ...newMetadata } = metadata.data; let updatedTeam: Awaited>; + try { updatedTeam = await prisma.team.update({ where: { id: orgId }, diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts index 29b1421a61..ac5e2be89d 100644 --- a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts @@ -1,4 +1,6 @@ +import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans"; import { MembershipRepository } from "@calcom/lib/server/repository/membership"; +import { prisma } from "@calcom/prisma"; type HasTeamPlanOptions = { ctx: { @@ -9,9 +11,15 @@ type HasTeamPlanOptions = { export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => { const userId = ctx.user.id; - const hasTeamPlan = await MembershipRepository.findFirstAcceptedMembershipByUserId(userId); + const membershipRepository = new MembershipRepository(prisma); + const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId }); + const hasTeamPlan = memberships.some( + (membership) => membership.accepted === true && membership.team.slug !== null + ); + const billingPlanService = new BillingPlanService(); + const plan = await billingPlanService.getUserPlanByMemberships(memberships); - return { hasTeamPlan: !!hasTeamPlan }; + return { hasTeamPlan: !!hasTeamPlan, plan }; }; export default hasTeamPlanHandler; diff --git a/turbo.json b/turbo.json index 4652a9ff15..a4adb2a77a 100644 --- a/turbo.json +++ b/turbo.json @@ -281,7 +281,9 @@ "ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE", "INTERCOM_API_TOKEN", "NEXT_PUBLIC_INTERCOM_APP_ID", - "_CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS" + "_CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS", + "ENTERPRISE_SLUGS", + "PLATFORM_ENTERPRISE_SLUGS" ], "tasks": { "@calcom/web#copy-app-store-static": {