diff --git a/apps/web/app/api/support/conversation/route.ts b/apps/web/app/api/support/conversation/route.ts index d2f1cc2930bcbd..671bd39b27da2c 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 3cffd1ce06ebcc..ca52b2d2248466 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 00000000000000..03e170a5b3a7c4 --- /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 30d60f7a6adff8..7b08f4ad56bd13 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 e57de06e77d91f..fa08470a3abe4f 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 6063f141367942..aad4979c6016d6 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 a6f270af1fec10..8b9aca449707e4 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 29b1421a619925..ac5e2be89d1f24 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 4652a9ff153115..a4adb2a77ae2a8 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": {