From b8ac087a3a28608f61a20dfc1d8f331f878429cd Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Fri, 12 Sep 2025 17:10:03 +0530 Subject: [PATCH 01/14] feat: adds user plan info in `useHasPaidPlan` for intercom --- .../ee/billing/billing-plan-service.ts | 46 +++++++++++++++++++ packages/features/ee/billing/constants.ts | 15 ++++++ .../ee/support/lib/intercom/useIntercom.ts | 4 +- packages/lib/hooks/useHasPaidPlan.ts | 6 +-- packages/lib/server/repository/membership.ts | 39 +++++++++++++++- .../viewer/teams/hasTeamPlan.handler.ts | 4 +- 6 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 packages/features/ee/billing/billing-plan-service.ts diff --git a/packages/features/ee/billing/billing-plan-service.ts b/packages/features/ee/billing/billing-plan-service.ts new file mode 100644 index 00000000000000..907b4b8b561a81 --- /dev/null +++ b/packages/features/ee/billing/billing-plan-service.ts @@ -0,0 +1,46 @@ +import { BillingPlans, ENTERPRISE_SLUGS, PLATFORM_ENTERPRISE_SLUGS } from "@calcom/ee/billing/constants"; +import { MembershipRepository } from "@calcom/lib/server/repository/membership"; + +export class BillingPlanService { + static async getUserPlanByUserId(userId: number) { + const memberships = await MembershipRepository.findAllMembershipsByUserIdForBilling({ userId }); + + if (memberships.length === 0) return BillingPlans.INDIVIDUALS; + + for (const { team, user } of memberships) { + if (team.isPlatform || user.isPlatformManaged) { + if (PLATFORM_ENTERPRISE_SLUGS.includes(team.slug ?? "")) return BillingPlans.PLATFORM_ENTERPRISE; + if (!team.platformBilling) continue; + + switch (team.platformBilling.plan) { + case "FREE": + case "STARTER": + return BillingPlans.PLATFORM_STARTER; + case "ESSENTIALS": + return BillingPlans.PLATFORM_ESSENTIALS; + case "SCALE": + return BillingPlans.PLATFORM_SCALE; + case "ENTERPRISE": + return BillingPlans.PLATFORM_ENTERPRISE; + default: + return team.platformBilling.plan; + } + } else { + if (team.parent && team.parent.isOrganization && team.parentId && !team.parent.isPlatform) { + return ENTERPRISE_SLUGS.includes(team.parent.slug ?? "") + ? BillingPlans.ENTERPRISE + : BillingPlans.ORGANIZATIONS; + } + + if (team.isOrganization) { + return ENTERPRISE_SLUGS.includes(team.slug ?? "") + ? BillingPlans.ENTERPRISE + : BillingPlans.ORGANIZATIONS; + } else { + return BillingPlans.TEAMS; + } + } + } + return BillingPlans.UNKNOWN; + } +} diff --git a/packages/features/ee/billing/constants.ts b/packages/features/ee/billing/constants.ts index 3cffd1ce06ebcc..b7992f83e4011c 100644 --- a/packages/features/ee/billing/constants.ts +++ b/packages/features/ee/billing/constants.ts @@ -7,3 +7,18 @@ export const CHECKOUT_SESSION_TYPES = { } as const; export type CheckoutSessionType = (typeof CHECKOUT_SESSION_TYPES)[keyof typeof CHECKOUT_SESSION_TYPES]; + +export enum BillingPlans { + 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 ENTERPRISE_SLUGS = ["i", "deel", "virtahealth", "cevidentia"]; +export const PLATFORM_ENTERPRISE_SLUGS = ["duda"]; 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 cfa60ae3f37264..8c97d73a6c9b85 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,42 @@ export class MembershipRepository { }); } + static async findAllMembershipsByUserIdForBilling({ userId }: { userId: number }) { + return await prisma.membership.findMany({ + where: { userId }, + select: { + user: { + select: { + isPlatformManaged: true, + }, + }, + team: { + select: { + id: true, + slug: true, + isOrganization: true, + isPlatform: true, + parentId: true, + metadata: true, + platformBilling: { + select: { + plan: true, + }, + }, + parent: { + select: { + slug: true, + isOrganization: true, + isPlatform: true, + metadata: true, + }, + }, + }, + }, + }, + }); + } + static async findByTeamIdForAvailability({ teamId }: { teamId: number }) { const memberships = await prisma.membership.findMany({ where: { teamId }, diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts index 29b1421a619925..e42e30e4bcde8d 100644 --- a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts @@ -1,3 +1,4 @@ +import { BillingPlanService } from "@calcom/features/ee/billing/billing-plan-service"; import { MembershipRepository } from "@calcom/lib/server/repository/membership"; type HasTeamPlanOptions = { @@ -10,8 +11,9 @@ export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => { const userId = ctx.user.id; const hasTeamPlan = await MembershipRepository.findFirstAcceptedMembershipByUserId(userId); + const plan = await BillingPlanService.getUserPlanByUserId(userId); - return { hasTeamPlan: !!hasTeamPlan }; + return { hasTeamPlan: !!hasTeamPlan, plan }; }; export default hasTeamPlanHandler; From 334776a901311974b8c86298da84406ea4098766 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Fri, 12 Sep 2025 17:15:22 +0530 Subject: [PATCH 02/14] add to support api route --- apps/web/app/api/support/conversation/route.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/app/api/support/conversation/route.ts b/apps/web/app/api/support/conversation/route.ts index d2f1cc2930bcbd..e5dc08ef0d0f05 100644 --- a/apps/web/app/api/support/conversation/route.ts +++ b/apps/web/app/api/support/conversation/route.ts @@ -3,6 +3,7 @@ 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/billing-plan-service"; 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"; @@ -43,6 +44,7 @@ export async function POST(req: NextRequest) { const { user } = session; + const plan = await BillingPlanService.getUserPlanByUserId(user.id); if (!existingContact.data) { const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id }); const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce( @@ -72,6 +74,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, }, }); From fd3c72828a4b69b9eb40c8d704358ec7614faae1 Mon Sep 17 00:00:00 2001 From: Keith Williams Date: Fri, 12 Sep 2025 08:48:42 -0300 Subject: [PATCH 03/14] Update constants.ts --- packages/features/ee/billing/constants.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/features/ee/billing/constants.ts b/packages/features/ee/billing/constants.ts index b7992f83e4011c..95987aa2d2089f 100644 --- a/packages/features/ee/billing/constants.ts +++ b/packages/features/ee/billing/constants.ts @@ -19,6 +19,3 @@ export enum BillingPlans { PLATFORM_ENTERPRISE = "PLATFORM_ENTERPRISE", UNKNOWN = "Unknown", } - -export const ENTERPRISE_SLUGS = ["i", "deel", "virtahealth", "cevidentia"]; -export const PLATFORM_ENTERPRISE_SLUGS = ["duda"]; From 80bbb8a089a6192615009d100e31797cc4359633 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Mon, 15 Sep 2025 17:47:07 +0530 Subject: [PATCH 04/14] sql migration to backfill plans and create/change plans on upgrade/downgrade/create of teams and orgs --- .../web/app/api/support/conversation/route.ts | 6 ++- .../web/app/api/teams/[team]/upgrade/route.ts | 24 ++++++++- apps/web/app/api/teams/create/route.ts | 3 +- .../ee/billing/billing-plan-service.ts | 46 ----------------- packages/features/ee/billing/constants.ts | 3 -- .../ee/billing/domain/billing-plans.ts | 51 +++++++++++++++++++ .../ee/billing/teams/internal-team-billing.ts | 6 +-- .../createOrganizationFromOnboarding.test.ts | 21 ++++++-- packages/lib/server/repository/membership.ts | 8 ++- .../lib/server/repository/organization.ts | 4 +- .../migration.sql | 22 ++++++++ packages/prisma/schema.prisma | 8 +++ .../__tests__/createTeams.handler.test.ts | 5 +- .../organizations/createTeams.handler.ts | 4 +- .../viewer/organizations/publish.handler.ts | 14 +++-- .../routers/viewer/teams/create.handler.ts | 6 ++- .../viewer/teams/hasTeamPlan.handler.ts | 9 ++-- 17 files changed, 163 insertions(+), 77 deletions(-) delete mode 100644 packages/features/ee/billing/billing-plan-service.ts create mode 100644 packages/features/ee/billing/domain/billing-plans.ts create mode 100644 packages/prisma/migrations/20250912155739_add_plan_to_teams_table/migration.sql diff --git a/apps/web/app/api/support/conversation/route.ts b/apps/web/app/api/support/conversation/route.ts index e5dc08ef0d0f05..1289ce1786e380 100644 --- a/apps/web/app/api/support/conversation/route.ts +++ b/apps/web/app/api/support/conversation/route.ts @@ -3,12 +3,13 @@ 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/billing-plan-service"; +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"; @@ -44,7 +45,8 @@ export async function POST(req: NextRequest) { const { user } = session; - const plan = await BillingPlanService.getUserPlanByUserId(user.id); + const memberships = await MembershipRepository.findAllMembershipsByUserIdForBilling({ userId: user.id }); + 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( diff --git a/apps/web/app/api/teams/[team]/upgrade/route.ts b/apps/web/app/api/teams/[team]/upgrade/route.ts index 57bd3264986b8a..386f38468d5e9f 100644 --- a/apps/web/app/api/teams/[team]/upgrade/route.ts +++ b/apps/web/app/api/teams/[team]/upgrade/route.ts @@ -12,6 +12,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import prisma from "@calcom/prisma"; +import { Plans } from "@calcom/prisma/enums"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -43,12 +44,20 @@ async function getHandler(req: NextRequest, { params }: { params: Promise { expect(organization.name).toBe(organizationOnboarding.name); expect(organization.slug).toBe(organizationOnboarding.slug); + //parent team plan and sub teams plan should be updated to organizations + expect(organization.plan).toBe(Plans.ORGANIZATIONS); + + const teams = await prismock.team.findMany({ + where: { + id: { + in: organizationOnboarding.teams?.map((t) => t.id), + }, + }, + }); + + expect(teams.every((t) => t.plan === Plans.ORGANIZATIONS)).toBe(true); + // Verify owner is the existing user expect(owner.id).toBe(existingUser.id); expect(owner.email).toBe(existingUser.email); diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index 8c97d73a6c9b85..1ce37587906e25 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -318,6 +318,7 @@ export class MembershipRepository { return await prisma.membership.findMany({ where: { userId }, select: { + accepted: true, user: { select: { isPlatformManaged: true, @@ -325,12 +326,10 @@ export class MembershipRepository { }, team: { select: { - id: true, slug: true, + plan: true, isOrganization: true, isPlatform: true, - parentId: true, - metadata: true, platformBilling: { select: { plan: true, @@ -338,10 +337,9 @@ export class MembershipRepository { }, parent: { select: { - slug: true, + plan: true, isOrganization: true, isPlatform: true, - metadata: true, }, }, }, diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index fb6dceaf7e47bb..251ade5a7c45f5 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -4,7 +4,7 @@ import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getO import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { prisma } from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { MembershipRole, Plans } from "@calcom/prisma/enums"; import type { CreationSource } from "@calcom/prisma/enums"; import type { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -141,6 +141,7 @@ export class OrganizationRepository { name: orgData.name, isOrganization: true, slug: orgData.slug, + plan: !orgData.isPlatform ? Plans.ORGANIZATIONS : null, // This is huge and causes issues, we need to have the logic to convert logo to logoUrl and then use that url ehre. // logoUrl: orgData.logoUrl, bio: orgData.bio, @@ -257,6 +258,7 @@ export class OrganizationRepository { }, }, include: { + //eslint-disable-next-line @calcom/eslint/no-prisma-include-true team: true, }, }); diff --git a/packages/prisma/migrations/20250912155739_add_plan_to_teams_table/migration.sql b/packages/prisma/migrations/20250912155739_add_plan_to_teams_table/migration.sql new file mode 100644 index 00000000000000..75af2744b709e0 --- /dev/null +++ b/packages/prisma/migrations/20250912155739_add_plan_to_teams_table/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum +CREATE TYPE "Plans" AS ENUM ('ENTERPRISE', 'ORGANIZATIONS', 'TEAMS'); + +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "plan" "Plans"; + +-- Update data for existing records +UPDATE public."Team" AS child_team +SET "plan" = CASE + WHEN parent_team."id" IS NOT NULL + AND parent_team."isOrganization" = true + AND parent_team."isPlatform" = false + THEN 'ORGANIZATIONS'::"Plans" + WHEN child_team."isOrganization" = true AND child_team."isPlatform" = false + THEN 'ORGANIZATIONS'::"Plans" + WHEN child_team."isOrganization" = false + THEN 'TEAMS'::"Plans" + ELSE NULL +END +FROM public."Team" AS parent_team +WHERE parent_team."id" = child_team."parentId" + OR child_team."parentId" IS NULL; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 0ea9c358a758bb..36d1b5fdee5a00 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -61,6 +61,12 @@ enum CreationSource { WEBAPP @map("webapp") } +enum Plans { + ENTERPRISE + ORGANIZATIONS + TEAMS +} + model Host { user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int @@ -587,6 +593,8 @@ model Team { managedOrganizations ManagedOrganization[] @relation("ManagerOrganization") filterSegments FilterSegment[] + plan Plans? + @@unique([slug, parentId]) @@index([parentId]) } diff --git a/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts index 0f96c23825cfff..950934b7fb8b9a 100644 --- a/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts @@ -3,7 +3,7 @@ import prismock from "../../../../../../../tests/libs/__mocks__/prisma"; import { describe, expect, it, beforeEach } from "vitest"; import slugify from "@calcom/lib/slugify"; -import { MembershipRole, UserPermissionRole, CreationSource } from "@calcom/prisma/enums"; +import { MembershipRole, UserPermissionRole, CreationSource, Plans } from "@calcom/prisma/enums"; import { createTeamsHandler } from "../createTeams.handler"; @@ -72,6 +72,7 @@ type CreateScenarioOptions = { slug?: string; metadata?: Record; addToParentId: "createdOrganization" | number | null; + plan?: Plans; }>; }; @@ -159,6 +160,8 @@ describe("createTeams handler", () => { expect(createdTeams[0].slug).toBe("team-1"); expect(createdTeams[1].name).toBe("Team 2"); expect(createdTeams[1].slug).toBe("team-2"); + expect(createdTeams[0].plan).toBe(Plans.ORGANIZATIONS); + expect(createdTeams[1].plan).toBe(Plans.ORGANIZATIONS); }); it("should handle creation of team in Organization that has same slug as a team already in the same org", async () => { diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts index b3faf504a80489..ca3db03e28ce7c 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts @@ -7,7 +7,7 @@ import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import type { CreationSource } from "@calcom/prisma/enums"; -import { MembershipRole, RedirectType } from "@calcom/prisma/enums"; +import { MembershipRole, Plans, RedirectType } from "@calcom/prisma/enums"; import { teamMetadataSchema, teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; @@ -119,6 +119,7 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => name, parentId: orgId, slug: slugify(name), + plan: Plans.ORGANIZATIONS, members: { create: { userId: ctx.user.id, role: MembershipRole.OWNER, accepted: true }, }, @@ -231,6 +232,7 @@ async function moveTeam({ data: { slug: newSlug, parentId: org.id, + plan: Plans.ORGANIZATIONS, }, }); } catch (error) { diff --git a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts index a6f270af1fec10..39a4b02c6b5aed 100644 --- a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts @@ -3,6 +3,7 @@ import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/pay import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import { prisma } from "@calcom/prisma"; +import { Plans } from "@calcom/prisma/enums"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; @@ -26,7 +27,14 @@ export const publishHandler = async ({ ctx }: PublishOptions) => { where: { id: orgId, }, - include: { members: true }, + select: { + metadata: true, + id: true, + members: { + select: { id: true }, + }, + }, + // include: { members: true }, }); if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found." }); @@ -64,13 +72,13 @@ export const publishHandler = async ({ ctx }: PublishOptions) => { } const { requestedSlug, ...newMetadata } = metadata.data; - let updatedTeam: Awaited>; try { - updatedTeam = await prisma.team.update({ + await prisma.team.update({ where: { id: orgId }, data: { slug: requestedSlug, + plan: Plans.ORGANIZATIONS, metadata: { ...newMetadata }, }, }); diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index 96f0e551cc11da..977383e7118fe5 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -4,7 +4,7 @@ import { uploadLogo } from "@calcom/lib/server/avatar"; import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; import { prisma } from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/enums"; +import { MembershipRole, Plans } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; @@ -102,7 +102,9 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { accepted: true, }, }, - ...(isOrgChildTeam && { parentId: user.profile?.organizationId }), + ...(isOrgChildTeam + ? { parentId: user.profile?.organizationId, plan: Plans.ORGANIZATIONS } + : { plan: Plans.TEAMS }), }, }); // Upload logo, create doesn't allow logo removal diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts index e42e30e4bcde8d..b6a7164acaac58 100644 --- a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts @@ -1,4 +1,4 @@ -import { BillingPlanService } from "@calcom/features/ee/billing/billing-plan-service"; +import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans"; import { MembershipRepository } from "@calcom/lib/server/repository/membership"; type HasTeamPlanOptions = { @@ -10,8 +10,11 @@ type HasTeamPlanOptions = { export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => { const userId = ctx.user.id; - const hasTeamPlan = await MembershipRepository.findFirstAcceptedMembershipByUserId(userId); - const plan = await BillingPlanService.getUserPlanByUserId(userId); + const memberships = await MembershipRepository.findAllMembershipsByUserIdForBilling({ userId }); + const hasTeamPlan = memberships.some( + (membership) => membership.accepted === true && membership.team.slug !== null + ); + const plan = await BillingPlanService.getUserPlanByMemberships(memberships); return { hasTeamPlan: !!hasTeamPlan, plan }; }; From 2e22d69771f1a3ab127df7e42a264e22405d3e47 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Mon, 15 Sep 2025 17:58:53 +0530 Subject: [PATCH 05/14] fix: breaking unit tests --- packages/features/ee/billing/teams/internal-team-billing.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/features/ee/billing/teams/internal-team-billing.test.ts b/packages/features/ee/billing/teams/internal-team-billing.test.ts index 4fbe17241305bc..4e0ccc978e7c15 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.test.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -58,6 +58,7 @@ describe("InternalTeamBilling", () => { where: { id: 1 }, data: { metadata: {}, + plan: null, }, }); }); From 58e511f15caf8757c3ec45f6d026caf96ee1a75e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 13:50:04 +0000 Subject: [PATCH 06/14] 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 --- .../ee/billing/domain/billing-plans.test.ts | 371 ++++++++++++++++++ .../team-billing-upgrade-downgrade.test.ts | 369 +++++++++++++++++ .../repository/__tests__/membership.test.ts | 304 ++++++++++++++ .../teams/__tests__/create.handler.test.ts | 218 ++++++++++ .../__tests__/hasTeamPlan.handler.test.ts | 251 ++++++++++++ 5 files changed, 1513 insertions(+) create mode 100644 packages/features/ee/billing/domain/billing-plans.test.ts create mode 100644 packages/features/ee/billing/teams/__tests__/team-billing-upgrade-downgrade.test.ts create mode 100644 packages/lib/server/repository/__tests__/membership.test.ts create mode 100644 packages/trpc/server/routers/viewer/teams/__tests__/create.handler.test.ts create mode 100644 packages/trpc/server/routers/viewer/teams/__tests__/hasTeamPlan.handler.test.ts diff --git a/packages/features/ee/billing/domain/billing-plans.test.ts b/packages/features/ee/billing/domain/billing-plans.test.ts new file mode 100644 index 00000000000000..a369bfa2a0d143 --- /dev/null +++ b/packages/features/ee/billing/domain/billing-plans.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect } from "vitest"; + +import { BillingPlans } from "@calcom/features/ee/billing/constants"; +import { Plans } from "@calcom/prisma/enums"; + +import { BillingPlanService } from "./billing-plans"; + +describe("BillingPlanService", () => { + describe("getUserPlanByMemberships", () => { + it("should return INDIVIDUALS when no memberships exist", async () => { + const result = await BillingPlanService.getUserPlanByMemberships([]); + expect(result).toBe(BillingPlans.INDIVIDUALS); + }); + + it("should return TEAMS plan for regular team membership", async () => { + const memberships = [ + { + team: { + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(Plans.TEAMS); + }); + + it("should return ORGANIZATIONS plan for organization membership", async () => { + const memberships = [ + { + team: { + plan: Plans.ORGANIZATIONS, + isOrganization: true, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(Plans.ORGANIZATIONS); + }); + + it("should return ENTERPRISE plan for enterprise membership", async () => { + const memberships = [ + { + team: { + plan: Plans.ENTERPRISE, + isOrganization: true, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(Plans.ENTERPRISE); + }); + + it("should return parent plan when team has no plan but parent does", async () => { + const memberships = [ + { + team: { + plan: null, + isOrganization: false, + isPlatform: false, + parent: { + plan: Plans.ORGANIZATIONS, + isOrganization: true, + isPlatform: false, + }, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(Plans.ORGANIZATIONS); + }); + + it("should return PLATFORM_STARTER for platform team with FREE plan", async () => { + const memberships = [ + { + team: { + plan: null, + isOrganization: false, + isPlatform: true, + parent: null, + platformBilling: { + plan: "FREE", + }, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(BillingPlans.PLATFORM_STARTER); + }); + + it("should return PLATFORM_STARTER for platform team with STARTER plan", async () => { + const memberships = [ + { + team: { + plan: null, + isOrganization: false, + isPlatform: true, + parent: null, + platformBilling: { + plan: "STARTER", + }, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(BillingPlans.PLATFORM_STARTER); + }); + + it("should return PLATFORM_ESSENTIALS for platform team with ESSENTIALS plan", async () => { + const memberships = [ + { + team: { + plan: null, + isOrganization: false, + isPlatform: true, + parent: null, + platformBilling: { + plan: "ESSENTIALS", + }, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(BillingPlans.PLATFORM_ESSENTIALS); + }); + + it("should return PLATFORM_SCALE for platform team with SCALE plan", async () => { + const memberships = [ + { + team: { + plan: null, + isOrganization: false, + isPlatform: true, + parent: null, + platformBilling: { + plan: "SCALE", + }, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(BillingPlans.PLATFORM_SCALE); + }); + + it("should return PLATFORM_ENTERPRISE for platform team with ENTERPRISE plan", async () => { + const memberships = [ + { + team: { + plan: null, + isOrganization: false, + isPlatform: true, + parent: null, + platformBilling: { + plan: "ENTERPRISE", + }, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(BillingPlans.PLATFORM_ENTERPRISE); + }); + + it("should return platform plan string for unknown platform plan", async () => { + const memberships = [ + { + team: { + plan: null, + isOrganization: false, + isPlatform: true, + parent: null, + platformBilling: { + plan: "CUSTOM_PLAN", + }, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe("CUSTOM_PLAN"); + }); + + it("should handle platform managed user correctly", async () => { + const memberships = [ + { + team: { + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + parent: null, + platformBilling: { + plan: "ESSENTIALS", + }, + }, + user: { + isPlatformManaged: true, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(BillingPlans.PLATFORM_ESSENTIALS); + }); + + it("should skip platform teams without platformBilling and continue to next membership", async () => { + const memberships = [ + { + team: { + plan: null, + isOrganization: false, + isPlatform: true, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + { + team: { + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(Plans.TEAMS); + }); + + it("should return UNKNOWN when no valid plan is found", async () => { + const memberships = [ + { + team: { + plan: null, + isOrganization: false, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(BillingPlans.UNKNOWN); + }); + + it("should prioritize platform billing over self-serve plans", async () => { + const memberships = [ + { + team: { + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: true, + parent: null, + platformBilling: { + plan: "SCALE", + }, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(BillingPlans.PLATFORM_SCALE); + }); + + it("should return first valid plan found in multiple memberships", async () => { + const memberships = [ + { + team: { + plan: null, + isOrganization: false, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + { + team: { + plan: Plans.ORGANIZATIONS, + isOrganization: true, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + { + team: { + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + const result = await BillingPlanService.getUserPlanByMemberships(memberships); + expect(result).toBe(Plans.ORGANIZATIONS); + }); + }); +}); diff --git a/packages/features/ee/billing/teams/__tests__/team-billing-upgrade-downgrade.test.ts b/packages/features/ee/billing/teams/__tests__/team-billing-upgrade-downgrade.test.ts new file mode 100644 index 00000000000000..809b0effeff5d0 --- /dev/null +++ b/packages/features/ee/billing/teams/__tests__/team-billing-upgrade-downgrade.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; + +import { Plans } from "@calcom/prisma/enums"; + +import { InternalTeamBilling } from "../internal-team-billing"; + +vi.mock("@calcom/prisma", () => ({ + prisma: { + team: { + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, + }, +})); + +vi.mock("../../index", () => ({ + default: { + handleSubscriptionCancel: vi.fn(), + checkoutSessionIsPaid: vi.fn(), + handleSubscriptionUpdate: vi.fn(), + getSubscriptionStatus: vi.fn(), + handleEndTrial: vi.fn(), + }, +})); + +const mockPrisma = vi.mocked(await import("@calcom/prisma")).prisma; +const mockBilling = vi.mocked(await import("../../index")).default; + +async function createTestTeam(data: { + name: string; + slug: string; + plan?: Plans | null; + isOrganization?: boolean; + metadata?: Record; +}) { + const mockTeam = { + id: Math.floor(Math.random() * 1000), + name: data.name, + slug: data.slug, + plan: data.plan ?? null, + isOrganization: data.isOrganization ?? false, + metadata: data.metadata || {}, + }; + + mockPrisma.team.create.mockResolvedValue(mockTeam); + return mockTeam; +} + +describe("Team Billing Upgrade/Downgrade Flows", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockBilling.handleSubscriptionCancel.mockResolvedValue(undefined); + mockBilling.checkoutSessionIsPaid.mockResolvedValue(true); + }); + + describe("Team downgrade (cancel)", () => { + it("should downgrade team from TEAMS plan to null", async () => { + const team = await createTestTeam({ + name: "Test Team", + slug: "test-team", + plan: Plans.TEAMS, + isOrganization: false, + metadata: { + subscriptionId: "sub_123", + subscriptionItemId: "si_456", + paymentId: "cs_789", + }, + }); + + const internalTeamBilling = new InternalTeamBilling(team); + await internalTeamBilling.cancel(); + + expect(mockBilling.handleSubscriptionCancel).toHaveBeenCalledWith("sub_123"); + expect(mockPrisma.team.update).toHaveBeenCalledWith({ + where: { id: team.id }, + data: { + plan: null, + metadata: {}, + }, + }); + }); + + it("should downgrade organization from ORGANIZATIONS plan to null", async () => { + const organization = await createTestTeam({ + name: "Test Organization", + slug: "test-org", + plan: Plans.ORGANIZATIONS, + isOrganization: true, + metadata: { + subscriptionId: "sub_123", + subscriptionItemId: "si_456", + paymentId: "cs_789", + }, + }); + + const internalTeamBilling = new InternalTeamBilling(organization); + await internalTeamBilling.cancel(); + + expect(mockBilling.handleSubscriptionCancel).toHaveBeenCalledWith("sub_123"); + expect(mockPrisma.team.update).toHaveBeenCalledWith({ + where: { id: organization.id }, + data: { + plan: null, + metadata: {}, + }, + }); + }); + + it("should downgrade enterprise organization from ENTERPRISE plan to null", async () => { + const enterpriseOrg = await createTestTeam({ + name: "Enterprise Organization", + slug: "enterprise-org", + plan: Plans.ENTERPRISE, + isOrganization: true, + metadata: { + subscriptionId: "sub_enterprise", + subscriptionItemId: "si_enterprise", + paymentId: "cs_enterprise", + }, + }); + + const internalTeamBilling = new InternalTeamBilling(enterpriseOrg); + await internalTeamBilling.cancel(); + + expect(mockBilling.handleSubscriptionCancel).toHaveBeenCalledWith("sub_enterprise"); + expect(mockPrisma.team.update).toHaveBeenCalledWith({ + where: { id: enterpriseOrg.id }, + data: { + plan: null, + metadata: {}, + }, + }); + }); + }); + + describe("Team upgrade scenarios", () => { + it("should handle team upgrade from null to TEAMS plan", async () => { + const team = await createTestTeam({ + name: "Upgrading Team", + slug: "upgrading-team", + plan: null, + isOrganization: false, + }); + + const updatedTeamData = { + ...team, + plan: Plans.TEAMS, + metadata: { + subscriptionId: "sub_new", + subscriptionItemId: "si_new", + paymentId: "cs_new", + }, + }; + + mockPrisma.team.update.mockResolvedValue(updatedTeamData); + + await mockPrisma.team.update({ + where: { id: team.id }, + data: { + plan: Plans.TEAMS, + metadata: { + subscriptionId: "sub_new", + subscriptionItemId: "si_new", + paymentId: "cs_new", + }, + }, + }); + + expect(mockPrisma.team.update).toHaveBeenCalledWith({ + where: { id: team.id }, + data: { + plan: Plans.TEAMS, + metadata: { + subscriptionId: "sub_new", + subscriptionItemId: "si_new", + paymentId: "cs_new", + }, + }, + }); + }); + + it("should handle organization upgrade from null to ORGANIZATIONS plan", async () => { + const organization = await createTestTeam({ + name: "Upgrading Organization", + slug: "upgrading-org", + plan: null, + isOrganization: true, + }); + + const updatedOrgData = { + ...organization, + plan: Plans.ORGANIZATIONS, + metadata: { + subscriptionId: "sub_org_new", + subscriptionItemId: "si_org_new", + paymentId: "cs_org_new", + }, + }; + + mockPrisma.team.update.mockResolvedValue(updatedOrgData); + + await mockPrisma.team.update({ + where: { id: organization.id }, + data: { + plan: Plans.ORGANIZATIONS, + metadata: { + subscriptionId: "sub_org_new", + subscriptionItemId: "si_org_new", + paymentId: "cs_org_new", + }, + }, + }); + + expect(mockPrisma.team.update).toHaveBeenCalledWith({ + where: { id: organization.id }, + data: { + plan: Plans.ORGANIZATIONS, + metadata: { + subscriptionId: "sub_org_new", + subscriptionItemId: "si_org_new", + paymentId: "cs_org_new", + }, + }, + }); + }); + + it("should handle organization upgrade from ORGANIZATIONS to ENTERPRISE plan", async () => { + const organization = await createTestTeam({ + name: "Enterprise Upgrading Organization", + slug: "enterprise-upgrading-org", + plan: Plans.ORGANIZATIONS, + isOrganization: true, + metadata: { + subscriptionId: "sub_org", + subscriptionItemId: "si_org", + paymentId: "cs_org", + }, + }); + + const updatedOrgData = { + ...organization, + plan: Plans.ENTERPRISE, + metadata: { + subscriptionId: "sub_enterprise_upgrade", + subscriptionItemId: "si_enterprise_upgrade", + paymentId: "cs_enterprise_upgrade", + }, + }; + + mockPrisma.team.update.mockResolvedValue(updatedOrgData); + + await mockPrisma.team.update({ + where: { id: organization.id }, + data: { + plan: Plans.ENTERPRISE, + metadata: { + subscriptionId: "sub_enterprise_upgrade", + subscriptionItemId: "si_enterprise_upgrade", + paymentId: "cs_enterprise_upgrade", + }, + }, + }); + + expect(mockPrisma.team.update).toHaveBeenCalledWith({ + where: { id: organization.id }, + data: { + plan: Plans.ENTERPRISE, + metadata: { + subscriptionId: "sub_enterprise_upgrade", + subscriptionItemId: "si_enterprise_upgrade", + paymentId: "cs_enterprise_upgrade", + }, + }, + }); + }); + }); + + describe("Child team plan inheritance", () => { + it("should maintain child team plan when parent organization is upgraded", async () => { + const parentOrg = await createTestTeam({ + name: "Parent Organization", + slug: "parent-org", + plan: Plans.ORGANIZATIONS, + isOrganization: true, + metadata: { + subscriptionId: "sub_parent_upgrade", + subscriptionItemId: "si_parent_upgrade", + paymentId: "cs_parent_upgrade", + }, + }); + + const childTeam = await createTestTeam({ + name: "Child Team", + slug: "child-team", + plan: Plans.ORGANIZATIONS, + isOrganization: false, + }); + + const updatedChildTeam = { ...childTeam, parentId: parentOrg.id }; + const updatedParentOrg = { ...parentOrg, plan: Plans.ENTERPRISE }; + + mockPrisma.team.update.mockResolvedValueOnce(updatedChildTeam).mockResolvedValueOnce(updatedParentOrg); + + await mockPrisma.team.update({ + where: { id: childTeam.id }, + data: { parentId: parentOrg.id }, + }); + + await mockPrisma.team.update({ + where: { id: parentOrg.id }, + data: { plan: Plans.ENTERPRISE }, + }); + + expect(mockPrisma.team.update).toHaveBeenCalledWith({ + where: { id: childTeam.id }, + data: { parentId: parentOrg.id }, + }); + + expect(mockPrisma.team.update).toHaveBeenCalledWith({ + where: { id: parentOrg.id }, + data: { plan: Plans.ENTERPRISE }, + }); + }); + + it("should maintain child team plan when parent organization is downgraded", async () => { + const parentOrg = await createTestTeam({ + name: "Parent Organization", + slug: "parent-org", + plan: Plans.ORGANIZATIONS, + isOrganization: true, + metadata: { + subscriptionId: "sub_parent_org", + subscriptionItemId: "si_parent_org", + paymentId: "cs_parent_org", + }, + }); + + const childTeam = await createTestTeam({ + name: "Child Team", + slug: "child-team", + plan: Plans.ORGANIZATIONS, + isOrganization: false, + }); + + const updatedChildTeam = { ...childTeam, parentId: parentOrg.id }; + const updatedParentOrg = { ...parentOrg, plan: null, metadata: {} }; + + mockPrisma.team.update.mockResolvedValueOnce(updatedChildTeam).mockResolvedValueOnce(updatedParentOrg); + + await mockPrisma.team.update({ + where: { id: childTeam.id }, + data: { parentId: parentOrg.id }, + }); + + const internalTeamBilling = new InternalTeamBilling(parentOrg); + await internalTeamBilling.cancel(); + + expect(mockBilling.handleSubscriptionCancel).toHaveBeenCalledWith("sub_parent_org"); + expect(mockPrisma.team.update).toHaveBeenCalledWith({ + where: { id: parentOrg.id }, + data: { + plan: null, + metadata: {}, + }, + }); + }); + }); +}); diff --git a/packages/lib/server/repository/__tests__/membership.test.ts b/packages/lib/server/repository/__tests__/membership.test.ts new file mode 100644 index 00000000000000..821e9c64c742ea --- /dev/null +++ b/packages/lib/server/repository/__tests__/membership.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; + +import { Plans } from "@calcom/prisma/enums"; + +import { MembershipRepository } from "../membership"; + +vi.mock("@calcom/prisma", () => ({ + prisma: { + membership: { + findMany: vi.fn(), + }, + }, +})); + +const mockPrisma = vi.mocked(await import("@calcom/prisma")).prisma; + +describe("MembershipRepository", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("findAllMembershipsByUserIdForBilling", () => { + it("should return empty array when user has no memberships", async () => { + mockPrisma.membership.findMany.mockResolvedValue([]); + + const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ + userId: 123, + }); + + expect(result).toEqual([]); + expect(mockPrisma.membership.findMany).toHaveBeenCalledWith({ + where: { userId: 123 }, + select: { + accepted: true, + user: { + select: { + isPlatformManaged: true, + }, + }, + team: { + select: { + slug: true, + plan: true, + isOrganization: true, + isPlatform: true, + platformBilling: { + select: { + plan: true, + }, + }, + parent: { + select: { + plan: true, + isOrganization: true, + isPlatform: true, + }, + }, + }, + }, + }, + }); + }); + + it("should return membership with team plan data", async () => { + const mockMembership = { + accepted: true, + user: { + isPlatformManaged: false, + }, + team: { + slug: "test-team", + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + platformBilling: null, + parent: null, + }, + }; + + mockPrisma.membership.findMany.mockResolvedValue([mockMembership]); + + const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ + userId: 123, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + accepted: true, + user: { + isPlatformManaged: false, + }, + team: { + slug: "test-team", + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + platformBilling: null, + parent: null, + }, + }); + }); + + it("should return membership with organization data", async () => { + const mockMembership = { + accepted: true, + user: { + isPlatformManaged: false, + }, + team: { + slug: "test-org", + plan: Plans.ORGANIZATIONS, + isOrganization: true, + isPlatform: false, + platformBilling: null, + parent: null, + }, + }; + + mockPrisma.membership.findMany.mockResolvedValue([mockMembership]); + + const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ + userId: 123, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + accepted: true, + user: { + isPlatformManaged: false, + }, + team: { + slug: "test-org", + plan: Plans.ORGANIZATIONS, + isOrganization: true, + isPlatform: false, + platformBilling: null, + parent: null, + }, + }); + }); + + it("should return membership with parent organization data", async () => { + const mockMembership = { + accepted: true, + user: { + isPlatformManaged: false, + }, + team: { + slug: "child-team", + plan: Plans.ORGANIZATIONS, + isOrganization: false, + isPlatform: false, + platformBilling: null, + parent: { + plan: Plans.ORGANIZATIONS, + isOrganization: true, + isPlatform: false, + }, + }, + }; + + mockPrisma.membership.findMany.mockResolvedValue([mockMembership]); + + const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ + userId: 123, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + accepted: true, + user: { + isPlatformManaged: false, + }, + team: { + slug: "child-team", + plan: Plans.ORGANIZATIONS, + isOrganization: false, + isPlatform: false, + platformBilling: null, + parent: { + plan: Plans.ORGANIZATIONS, + isOrganization: true, + isPlatform: false, + }, + }, + }); + }); + + it("should return membership with platform billing data", async () => { + const mockMembership = { + accepted: true, + user: { + isPlatformManaged: true, + }, + team: { + slug: "platform-team", + plan: null, + isOrganization: false, + isPlatform: true, + platformBilling: { + plan: "ESSENTIALS", + }, + parent: null, + }, + }; + + mockPrisma.membership.findMany.mockResolvedValue([mockMembership]); + + const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ + userId: 123, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + accepted: true, + user: { + isPlatformManaged: true, + }, + team: { + slug: "platform-team", + plan: null, + isOrganization: false, + isPlatform: true, + platformBilling: { + plan: "ESSENTIALS", + }, + parent: null, + }, + }); + }); + + it("should return multiple memberships for user", async () => { + const mockMemberships = [ + { + accepted: true, + user: { + isPlatformManaged: false, + }, + team: { + slug: "team-1", + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + platformBilling: null, + parent: null, + }, + }, + { + accepted: false, + user: { + isPlatformManaged: false, + }, + team: { + slug: "team-2", + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + platformBilling: null, + parent: null, + }, + }, + ]; + + mockPrisma.membership.findMany.mockResolvedValue(mockMemberships); + + const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ + userId: 123, + }); + + expect(result).toHaveLength(2); + expect(result[0].accepted).toBe(true); + expect(result[1].accepted).toBe(false); + expect(result[0].team.slug).toBe("team-1"); + expect(result[1].team.slug).toBe("team-2"); + }); + + it("should handle teams without slugs", async () => { + const mockMembership = { + accepted: true, + user: { + isPlatformManaged: false, + }, + team: { + slug: null, + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + platformBilling: null, + parent: null, + }, + }; + + mockPrisma.membership.findMany.mockResolvedValue([mockMembership]); + + const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ + userId: 123, + }); + + expect(result).toHaveLength(1); + expect(result[0].team.slug).toBeNull(); + expect(result[0].team.plan).toBe(Plans.TEAMS); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/teams/__tests__/create.handler.test.ts b/packages/trpc/server/routers/viewer/teams/__tests__/create.handler.test.ts new file mode 100644 index 00000000000000..fd0bd42481eb71 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/__tests__/create.handler.test.ts @@ -0,0 +1,218 @@ +import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock"; + +import { describe, expect, it, beforeEach, vi } from "vitest"; + +import { MembershipRole, Plans } from "@calcom/prisma/enums"; + +import { createHandler } from "../create.handler"; + +vi.mock("@calcom/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IS_TEAM_BILLING_ENABLED: false, + WEBAPP_URL: "http://localhost:3000", + }; +}); + +vi.mock("@calcom/lib/server/repository/profile", () => ({ + ProfileRepository: { + findByOrgIdAndUsername: vi.fn(), + }, +})); + +describe("Team create handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should create standalone team with TEAMS plan", async () => { + prismaMock.team.findFirst.mockResolvedValue(null); + + const mockCreatedTeam = { + id: 1, + name: "Test Team", + slug: "test-team", + plan: Plans.TEAMS, + parentId: null, + }; + prismaMock.team.create.mockResolvedValue(mockCreatedTeam); + + const result = await createHandler({ + ctx: { + user: { + id: 123, + profile: { + organizationId: null, + }, + }, + }, + input: { + name: "Test Team", + slug: "test-team", + }, + }); + + expect(result.team).toEqual(mockCreatedTeam); + expect(result.url).toContain("/settings/teams/1/onboard-members"); + + expect(prismaMock.team.create).toHaveBeenCalledWith({ + data: { + slug: "test-team", + name: "Test Team", + members: { + create: { + userId: 123, + role: MembershipRole.OWNER, + accepted: true, + }, + }, + plan: Plans.TEAMS, + }, + }); + }); + + it("should create team under organization with ORGANIZATIONS plan", async () => { + prismaMock.team.findFirst.mockResolvedValue(null); + + const mockCreatedTeam = { + id: 2, + name: "Org Team", + slug: "org-team", + plan: Plans.ORGANIZATIONS, + parentId: 456, + }; + prismaMock.team.create.mockResolvedValue(mockCreatedTeam); + + const result = await createHandler({ + ctx: { + user: { + id: 123, + profile: { + organizationId: 456, + }, + organization: { + isOrgAdmin: true, + }, + }, + }, + input: { + name: "Org Team", + slug: "org-team", + }, + }); + + expect(result.team).toEqual(mockCreatedTeam); + expect(result.url).toContain("/settings/teams/2/onboard-members"); + + expect(prismaMock.team.create).toHaveBeenCalledWith({ + data: { + slug: "org-team", + name: "Org Team", + members: { + create: { + userId: 123, + role: MembershipRole.OWNER, + accepted: true, + }, + }, + parentId: 456, + plan: Plans.ORGANIZATIONS, + }, + }); + }); + + it("should throw error when slug is already taken", async () => { + prismaMock.team.findFirst.mockResolvedValue({ + id: 999, + slug: "taken-slug", + }); + + await expect( + createHandler({ + ctx: { + user: { + id: 123, + profile: { + organizationId: null, + }, + }, + }, + input: { + name: "Test Team", + slug: "taken-slug", + }, + }) + ).rejects.toThrow("team_url_taken"); + + expect(prismaMock.team.create).not.toHaveBeenCalled(); + }); + + it("should throw error when non-org admin tries to create team in organization", async () => { + await expect( + createHandler({ + ctx: { + user: { + id: 123, + profile: { + organizationId: 456, + }, + organization: { + isOrgAdmin: false, + }, + }, + }, + input: { + name: "Org Team", + slug: "org-team", + }, + }) + ).rejects.toThrow("org_admins_can_create_new_teams"); + + expect(prismaMock.team.findFirst).not.toHaveBeenCalled(); + expect(prismaMock.team.create).not.toHaveBeenCalled(); + }); + + it("should verify membership creation with OWNER role", async () => { + prismaMock.team.findFirst.mockResolvedValue(null); + + const mockCreatedTeam = { + id: 3, + name: "Test Team", + slug: "test-team", + plan: Plans.TEAMS, + parentId: null, + }; + prismaMock.team.create.mockResolvedValue(mockCreatedTeam); + + await createHandler({ + ctx: { + user: { + id: 789, + profile: { + organizationId: null, + }, + }, + }, + input: { + name: "Test Team", + slug: "test-team", + }, + }); + + expect(prismaMock.team.create).toHaveBeenCalledWith({ + data: { + slug: "test-team", + name: "Test Team", + members: { + create: { + userId: 789, + role: MembershipRole.OWNER, + accepted: true, + }, + }, + plan: Plans.TEAMS, + }, + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/teams/__tests__/hasTeamPlan.handler.test.ts b/packages/trpc/server/routers/viewer/teams/__tests__/hasTeamPlan.handler.test.ts new file mode 100644 index 00000000000000..f8788b509a9d30 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/__tests__/hasTeamPlan.handler.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; + +import { BillingPlans } from "@calcom/ee/billing/constants"; +import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans"; +import { MembershipRepository } from "@calcom/lib/server/repository/membership"; +import { Plans } from "@calcom/prisma/enums"; + +import { hasTeamPlanHandler } from "../hasTeamPlan.handler"; + +vi.mock("@calcom/features/ee/billing/domain/billing-plans"); +vi.mock("@calcom/lib/server/repository/membership"); + +describe("hasTeamPlan handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return false for hasTeamPlan when user has no memberships", async () => { + vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue([]); + vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(BillingPlans.INDIVIDUALS); + + const result = await hasTeamPlanHandler({ + ctx: { user: { id: 123 } }, + }); + + expect(result.hasTeamPlan).toBe(false); + expect(result.plan).toBe(BillingPlans.INDIVIDUALS); + + expect(MembershipRepository.findAllMembershipsByUserIdForBilling).toHaveBeenCalledWith({ + userId: 123, + }); + }); + + it("should return true for hasTeamPlan when user has accepted team membership", async () => { + const mockMemberships = [ + { + accepted: true, + team: { + slug: "test-team", + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); + vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(Plans.TEAMS); + + const result = await hasTeamPlanHandler({ + ctx: { user: { id: 123 } }, + }); + + expect(result.hasTeamPlan).toBe(true); + expect(result.plan).toBe(Plans.TEAMS); + }); + + it("should return false for hasTeamPlan when user has unaccepted team membership", async () => { + const mockMemberships = [ + { + accepted: false, + team: { + slug: "test-team", + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); + vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(BillingPlans.INDIVIDUALS); + + const result = await hasTeamPlanHandler({ + ctx: { user: { id: 123 } }, + }); + + expect(result.hasTeamPlan).toBe(false); + expect(result.plan).toBe(BillingPlans.INDIVIDUALS); + }); + + it("should return false for hasTeamPlan when team has no slug", async () => { + const mockMemberships = [ + { + accepted: true, + team: { + slug: null, + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); + vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(BillingPlans.INDIVIDUALS); + + const result = await hasTeamPlanHandler({ + ctx: { user: { id: 123 } }, + }); + + expect(result.hasTeamPlan).toBe(false); + expect(result.plan).toBe(BillingPlans.INDIVIDUALS); + }); + + it("should return ORGANIZATIONS plan for organization membership", async () => { + const mockMemberships = [ + { + accepted: true, + team: { + slug: "test-org", + plan: Plans.ORGANIZATIONS, + isOrganization: true, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); + vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(Plans.ORGANIZATIONS); + + const result = await hasTeamPlanHandler({ + ctx: { user: { id: 123 } }, + }); + + expect(result.hasTeamPlan).toBe(true); + expect(result.plan).toBe(Plans.ORGANIZATIONS); + }); + + it("should handle multiple memberships correctly", async () => { + const mockMemberships = [ + { + accepted: true, + team: { + slug: "team-1", + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + { + accepted: true, + team: { + slug: "organization", + plan: Plans.ORGANIZATIONS, + isOrganization: true, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); + vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(Plans.ORGANIZATIONS); + + const result = await hasTeamPlanHandler({ + ctx: { user: { id: 123 } }, + }); + + expect(result.hasTeamPlan).toBe(true); + expect(result.plan).toBe(Plans.ORGANIZATIONS); + }); + + it("should handle platform teams correctly", async () => { + const mockMemberships = [ + { + accepted: true, + team: { + slug: "platform-team", + plan: null, + isOrganization: false, + isPlatform: true, + parent: null, + platformBilling: { + plan: "STARTER", + }, + }, + user: { + isPlatformManaged: true, + }, + }, + ]; + + vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); + vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(BillingPlans.PLATFORM_STARTER); + + const result = await hasTeamPlanHandler({ + ctx: { user: { id: 123 } }, + }); + + expect(result.hasTeamPlan).toBe(true); + expect(result.plan).toBe(BillingPlans.PLATFORM_STARTER); + }); + + it("should call BillingPlanService with correct membership data", async () => { + const mockMemberships = [ + { + accepted: true, + team: { + slug: "test-team", + plan: Plans.TEAMS, + isOrganization: false, + isPlatform: false, + parent: null, + platformBilling: null, + }, + user: { + isPlatformManaged: false, + }, + }, + ]; + + vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); + vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(Plans.TEAMS); + + await hasTeamPlanHandler({ + ctx: { user: { id: 123 } }, + }); + + expect(BillingPlanService.getUserPlanByMemberships).toHaveBeenCalledWith(mockMemberships); + }); +}); From af952189391b8edf1b086d332e1b0c06462e9198 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:24:57 +0530 Subject: [PATCH 07/14] Revert "test: add comprehensive tests for billing plan service and team/org flows" This reverts commit 58e511f15caf8757c3ec45f6d026caf96ee1a75e. --- .../ee/billing/domain/billing-plans.test.ts | 371 ------------------ .../team-billing-upgrade-downgrade.test.ts | 369 ----------------- .../repository/__tests__/membership.test.ts | 304 -------------- .../teams/__tests__/create.handler.test.ts | 218 ---------- .../__tests__/hasTeamPlan.handler.test.ts | 251 ------------ 5 files changed, 1513 deletions(-) delete mode 100644 packages/features/ee/billing/domain/billing-plans.test.ts delete mode 100644 packages/features/ee/billing/teams/__tests__/team-billing-upgrade-downgrade.test.ts delete mode 100644 packages/lib/server/repository/__tests__/membership.test.ts delete mode 100644 packages/trpc/server/routers/viewer/teams/__tests__/create.handler.test.ts delete mode 100644 packages/trpc/server/routers/viewer/teams/__tests__/hasTeamPlan.handler.test.ts diff --git a/packages/features/ee/billing/domain/billing-plans.test.ts b/packages/features/ee/billing/domain/billing-plans.test.ts deleted file mode 100644 index a369bfa2a0d143..00000000000000 --- a/packages/features/ee/billing/domain/billing-plans.test.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { BillingPlans } from "@calcom/features/ee/billing/constants"; -import { Plans } from "@calcom/prisma/enums"; - -import { BillingPlanService } from "./billing-plans"; - -describe("BillingPlanService", () => { - describe("getUserPlanByMemberships", () => { - it("should return INDIVIDUALS when no memberships exist", async () => { - const result = await BillingPlanService.getUserPlanByMemberships([]); - expect(result).toBe(BillingPlans.INDIVIDUALS); - }); - - it("should return TEAMS plan for regular team membership", async () => { - const memberships = [ - { - team: { - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(Plans.TEAMS); - }); - - it("should return ORGANIZATIONS plan for organization membership", async () => { - const memberships = [ - { - team: { - plan: Plans.ORGANIZATIONS, - isOrganization: true, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(Plans.ORGANIZATIONS); - }); - - it("should return ENTERPRISE plan for enterprise membership", async () => { - const memberships = [ - { - team: { - plan: Plans.ENTERPRISE, - isOrganization: true, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(Plans.ENTERPRISE); - }); - - it("should return parent plan when team has no plan but parent does", async () => { - const memberships = [ - { - team: { - plan: null, - isOrganization: false, - isPlatform: false, - parent: { - plan: Plans.ORGANIZATIONS, - isOrganization: true, - isPlatform: false, - }, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(Plans.ORGANIZATIONS); - }); - - it("should return PLATFORM_STARTER for platform team with FREE plan", async () => { - const memberships = [ - { - team: { - plan: null, - isOrganization: false, - isPlatform: true, - parent: null, - platformBilling: { - plan: "FREE", - }, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(BillingPlans.PLATFORM_STARTER); - }); - - it("should return PLATFORM_STARTER for platform team with STARTER plan", async () => { - const memberships = [ - { - team: { - plan: null, - isOrganization: false, - isPlatform: true, - parent: null, - platformBilling: { - plan: "STARTER", - }, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(BillingPlans.PLATFORM_STARTER); - }); - - it("should return PLATFORM_ESSENTIALS for platform team with ESSENTIALS plan", async () => { - const memberships = [ - { - team: { - plan: null, - isOrganization: false, - isPlatform: true, - parent: null, - platformBilling: { - plan: "ESSENTIALS", - }, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(BillingPlans.PLATFORM_ESSENTIALS); - }); - - it("should return PLATFORM_SCALE for platform team with SCALE plan", async () => { - const memberships = [ - { - team: { - plan: null, - isOrganization: false, - isPlatform: true, - parent: null, - platformBilling: { - plan: "SCALE", - }, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(BillingPlans.PLATFORM_SCALE); - }); - - it("should return PLATFORM_ENTERPRISE for platform team with ENTERPRISE plan", async () => { - const memberships = [ - { - team: { - plan: null, - isOrganization: false, - isPlatform: true, - parent: null, - platformBilling: { - plan: "ENTERPRISE", - }, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(BillingPlans.PLATFORM_ENTERPRISE); - }); - - it("should return platform plan string for unknown platform plan", async () => { - const memberships = [ - { - team: { - plan: null, - isOrganization: false, - isPlatform: true, - parent: null, - platformBilling: { - plan: "CUSTOM_PLAN", - }, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe("CUSTOM_PLAN"); - }); - - it("should handle platform managed user correctly", async () => { - const memberships = [ - { - team: { - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - parent: null, - platformBilling: { - plan: "ESSENTIALS", - }, - }, - user: { - isPlatformManaged: true, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(BillingPlans.PLATFORM_ESSENTIALS); - }); - - it("should skip platform teams without platformBilling and continue to next membership", async () => { - const memberships = [ - { - team: { - plan: null, - isOrganization: false, - isPlatform: true, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - { - team: { - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(Plans.TEAMS); - }); - - it("should return UNKNOWN when no valid plan is found", async () => { - const memberships = [ - { - team: { - plan: null, - isOrganization: false, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(BillingPlans.UNKNOWN); - }); - - it("should prioritize platform billing over self-serve plans", async () => { - const memberships = [ - { - team: { - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: true, - parent: null, - platformBilling: { - plan: "SCALE", - }, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(BillingPlans.PLATFORM_SCALE); - }); - - it("should return first valid plan found in multiple memberships", async () => { - const memberships = [ - { - team: { - plan: null, - isOrganization: false, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - { - team: { - plan: Plans.ORGANIZATIONS, - isOrganization: true, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - { - team: { - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - const result = await BillingPlanService.getUserPlanByMemberships(memberships); - expect(result).toBe(Plans.ORGANIZATIONS); - }); - }); -}); diff --git a/packages/features/ee/billing/teams/__tests__/team-billing-upgrade-downgrade.test.ts b/packages/features/ee/billing/teams/__tests__/team-billing-upgrade-downgrade.test.ts deleted file mode 100644 index 809b0effeff5d0..00000000000000 --- a/packages/features/ee/billing/teams/__tests__/team-billing-upgrade-downgrade.test.ts +++ /dev/null @@ -1,369 +0,0 @@ -import { describe, expect, it, beforeEach, vi } from "vitest"; - -import { Plans } from "@calcom/prisma/enums"; - -import { InternalTeamBilling } from "../internal-team-billing"; - -vi.mock("@calcom/prisma", () => ({ - prisma: { - team: { - create: vi.fn(), - findFirst: vi.fn(), - update: vi.fn(), - }, - }, -})); - -vi.mock("../../index", () => ({ - default: { - handleSubscriptionCancel: vi.fn(), - checkoutSessionIsPaid: vi.fn(), - handleSubscriptionUpdate: vi.fn(), - getSubscriptionStatus: vi.fn(), - handleEndTrial: vi.fn(), - }, -})); - -const mockPrisma = vi.mocked(await import("@calcom/prisma")).prisma; -const mockBilling = vi.mocked(await import("../../index")).default; - -async function createTestTeam(data: { - name: string; - slug: string; - plan?: Plans | null; - isOrganization?: boolean; - metadata?: Record; -}) { - const mockTeam = { - id: Math.floor(Math.random() * 1000), - name: data.name, - slug: data.slug, - plan: data.plan ?? null, - isOrganization: data.isOrganization ?? false, - metadata: data.metadata || {}, - }; - - mockPrisma.team.create.mockResolvedValue(mockTeam); - return mockTeam; -} - -describe("Team Billing Upgrade/Downgrade Flows", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockBilling.handleSubscriptionCancel.mockResolvedValue(undefined); - mockBilling.checkoutSessionIsPaid.mockResolvedValue(true); - }); - - describe("Team downgrade (cancel)", () => { - it("should downgrade team from TEAMS plan to null", async () => { - const team = await createTestTeam({ - name: "Test Team", - slug: "test-team", - plan: Plans.TEAMS, - isOrganization: false, - metadata: { - subscriptionId: "sub_123", - subscriptionItemId: "si_456", - paymentId: "cs_789", - }, - }); - - const internalTeamBilling = new InternalTeamBilling(team); - await internalTeamBilling.cancel(); - - expect(mockBilling.handleSubscriptionCancel).toHaveBeenCalledWith("sub_123"); - expect(mockPrisma.team.update).toHaveBeenCalledWith({ - where: { id: team.id }, - data: { - plan: null, - metadata: {}, - }, - }); - }); - - it("should downgrade organization from ORGANIZATIONS plan to null", async () => { - const organization = await createTestTeam({ - name: "Test Organization", - slug: "test-org", - plan: Plans.ORGANIZATIONS, - isOrganization: true, - metadata: { - subscriptionId: "sub_123", - subscriptionItemId: "si_456", - paymentId: "cs_789", - }, - }); - - const internalTeamBilling = new InternalTeamBilling(organization); - await internalTeamBilling.cancel(); - - expect(mockBilling.handleSubscriptionCancel).toHaveBeenCalledWith("sub_123"); - expect(mockPrisma.team.update).toHaveBeenCalledWith({ - where: { id: organization.id }, - data: { - plan: null, - metadata: {}, - }, - }); - }); - - it("should downgrade enterprise organization from ENTERPRISE plan to null", async () => { - const enterpriseOrg = await createTestTeam({ - name: "Enterprise Organization", - slug: "enterprise-org", - plan: Plans.ENTERPRISE, - isOrganization: true, - metadata: { - subscriptionId: "sub_enterprise", - subscriptionItemId: "si_enterprise", - paymentId: "cs_enterprise", - }, - }); - - const internalTeamBilling = new InternalTeamBilling(enterpriseOrg); - await internalTeamBilling.cancel(); - - expect(mockBilling.handleSubscriptionCancel).toHaveBeenCalledWith("sub_enterprise"); - expect(mockPrisma.team.update).toHaveBeenCalledWith({ - where: { id: enterpriseOrg.id }, - data: { - plan: null, - metadata: {}, - }, - }); - }); - }); - - describe("Team upgrade scenarios", () => { - it("should handle team upgrade from null to TEAMS plan", async () => { - const team = await createTestTeam({ - name: "Upgrading Team", - slug: "upgrading-team", - plan: null, - isOrganization: false, - }); - - const updatedTeamData = { - ...team, - plan: Plans.TEAMS, - metadata: { - subscriptionId: "sub_new", - subscriptionItemId: "si_new", - paymentId: "cs_new", - }, - }; - - mockPrisma.team.update.mockResolvedValue(updatedTeamData); - - await mockPrisma.team.update({ - where: { id: team.id }, - data: { - plan: Plans.TEAMS, - metadata: { - subscriptionId: "sub_new", - subscriptionItemId: "si_new", - paymentId: "cs_new", - }, - }, - }); - - expect(mockPrisma.team.update).toHaveBeenCalledWith({ - where: { id: team.id }, - data: { - plan: Plans.TEAMS, - metadata: { - subscriptionId: "sub_new", - subscriptionItemId: "si_new", - paymentId: "cs_new", - }, - }, - }); - }); - - it("should handle organization upgrade from null to ORGANIZATIONS plan", async () => { - const organization = await createTestTeam({ - name: "Upgrading Organization", - slug: "upgrading-org", - plan: null, - isOrganization: true, - }); - - const updatedOrgData = { - ...organization, - plan: Plans.ORGANIZATIONS, - metadata: { - subscriptionId: "sub_org_new", - subscriptionItemId: "si_org_new", - paymentId: "cs_org_new", - }, - }; - - mockPrisma.team.update.mockResolvedValue(updatedOrgData); - - await mockPrisma.team.update({ - where: { id: organization.id }, - data: { - plan: Plans.ORGANIZATIONS, - metadata: { - subscriptionId: "sub_org_new", - subscriptionItemId: "si_org_new", - paymentId: "cs_org_new", - }, - }, - }); - - expect(mockPrisma.team.update).toHaveBeenCalledWith({ - where: { id: organization.id }, - data: { - plan: Plans.ORGANIZATIONS, - metadata: { - subscriptionId: "sub_org_new", - subscriptionItemId: "si_org_new", - paymentId: "cs_org_new", - }, - }, - }); - }); - - it("should handle organization upgrade from ORGANIZATIONS to ENTERPRISE plan", async () => { - const organization = await createTestTeam({ - name: "Enterprise Upgrading Organization", - slug: "enterprise-upgrading-org", - plan: Plans.ORGANIZATIONS, - isOrganization: true, - metadata: { - subscriptionId: "sub_org", - subscriptionItemId: "si_org", - paymentId: "cs_org", - }, - }); - - const updatedOrgData = { - ...organization, - plan: Plans.ENTERPRISE, - metadata: { - subscriptionId: "sub_enterprise_upgrade", - subscriptionItemId: "si_enterprise_upgrade", - paymentId: "cs_enterprise_upgrade", - }, - }; - - mockPrisma.team.update.mockResolvedValue(updatedOrgData); - - await mockPrisma.team.update({ - where: { id: organization.id }, - data: { - plan: Plans.ENTERPRISE, - metadata: { - subscriptionId: "sub_enterprise_upgrade", - subscriptionItemId: "si_enterprise_upgrade", - paymentId: "cs_enterprise_upgrade", - }, - }, - }); - - expect(mockPrisma.team.update).toHaveBeenCalledWith({ - where: { id: organization.id }, - data: { - plan: Plans.ENTERPRISE, - metadata: { - subscriptionId: "sub_enterprise_upgrade", - subscriptionItemId: "si_enterprise_upgrade", - paymentId: "cs_enterprise_upgrade", - }, - }, - }); - }); - }); - - describe("Child team plan inheritance", () => { - it("should maintain child team plan when parent organization is upgraded", async () => { - const parentOrg = await createTestTeam({ - name: "Parent Organization", - slug: "parent-org", - plan: Plans.ORGANIZATIONS, - isOrganization: true, - metadata: { - subscriptionId: "sub_parent_upgrade", - subscriptionItemId: "si_parent_upgrade", - paymentId: "cs_parent_upgrade", - }, - }); - - const childTeam = await createTestTeam({ - name: "Child Team", - slug: "child-team", - plan: Plans.ORGANIZATIONS, - isOrganization: false, - }); - - const updatedChildTeam = { ...childTeam, parentId: parentOrg.id }; - const updatedParentOrg = { ...parentOrg, plan: Plans.ENTERPRISE }; - - mockPrisma.team.update.mockResolvedValueOnce(updatedChildTeam).mockResolvedValueOnce(updatedParentOrg); - - await mockPrisma.team.update({ - where: { id: childTeam.id }, - data: { parentId: parentOrg.id }, - }); - - await mockPrisma.team.update({ - where: { id: parentOrg.id }, - data: { plan: Plans.ENTERPRISE }, - }); - - expect(mockPrisma.team.update).toHaveBeenCalledWith({ - where: { id: childTeam.id }, - data: { parentId: parentOrg.id }, - }); - - expect(mockPrisma.team.update).toHaveBeenCalledWith({ - where: { id: parentOrg.id }, - data: { plan: Plans.ENTERPRISE }, - }); - }); - - it("should maintain child team plan when parent organization is downgraded", async () => { - const parentOrg = await createTestTeam({ - name: "Parent Organization", - slug: "parent-org", - plan: Plans.ORGANIZATIONS, - isOrganization: true, - metadata: { - subscriptionId: "sub_parent_org", - subscriptionItemId: "si_parent_org", - paymentId: "cs_parent_org", - }, - }); - - const childTeam = await createTestTeam({ - name: "Child Team", - slug: "child-team", - plan: Plans.ORGANIZATIONS, - isOrganization: false, - }); - - const updatedChildTeam = { ...childTeam, parentId: parentOrg.id }; - const updatedParentOrg = { ...parentOrg, plan: null, metadata: {} }; - - mockPrisma.team.update.mockResolvedValueOnce(updatedChildTeam).mockResolvedValueOnce(updatedParentOrg); - - await mockPrisma.team.update({ - where: { id: childTeam.id }, - data: { parentId: parentOrg.id }, - }); - - const internalTeamBilling = new InternalTeamBilling(parentOrg); - await internalTeamBilling.cancel(); - - expect(mockBilling.handleSubscriptionCancel).toHaveBeenCalledWith("sub_parent_org"); - expect(mockPrisma.team.update).toHaveBeenCalledWith({ - where: { id: parentOrg.id }, - data: { - plan: null, - metadata: {}, - }, - }); - }); - }); -}); diff --git a/packages/lib/server/repository/__tests__/membership.test.ts b/packages/lib/server/repository/__tests__/membership.test.ts deleted file mode 100644 index 821e9c64c742ea..00000000000000 --- a/packages/lib/server/repository/__tests__/membership.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { describe, expect, it, beforeEach, vi } from "vitest"; - -import { Plans } from "@calcom/prisma/enums"; - -import { MembershipRepository } from "../membership"; - -vi.mock("@calcom/prisma", () => ({ - prisma: { - membership: { - findMany: vi.fn(), - }, - }, -})); - -const mockPrisma = vi.mocked(await import("@calcom/prisma")).prisma; - -describe("MembershipRepository", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("findAllMembershipsByUserIdForBilling", () => { - it("should return empty array when user has no memberships", async () => { - mockPrisma.membership.findMany.mockResolvedValue([]); - - const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ - userId: 123, - }); - - expect(result).toEqual([]); - expect(mockPrisma.membership.findMany).toHaveBeenCalledWith({ - where: { userId: 123 }, - select: { - accepted: true, - user: { - select: { - isPlatformManaged: true, - }, - }, - team: { - select: { - slug: true, - plan: true, - isOrganization: true, - isPlatform: true, - platformBilling: { - select: { - plan: true, - }, - }, - parent: { - select: { - plan: true, - isOrganization: true, - isPlatform: true, - }, - }, - }, - }, - }, - }); - }); - - it("should return membership with team plan data", async () => { - const mockMembership = { - accepted: true, - user: { - isPlatformManaged: false, - }, - team: { - slug: "test-team", - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - platformBilling: null, - parent: null, - }, - }; - - mockPrisma.membership.findMany.mockResolvedValue([mockMembership]); - - const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ - userId: 123, - }); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - accepted: true, - user: { - isPlatformManaged: false, - }, - team: { - slug: "test-team", - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - platformBilling: null, - parent: null, - }, - }); - }); - - it("should return membership with organization data", async () => { - const mockMembership = { - accepted: true, - user: { - isPlatformManaged: false, - }, - team: { - slug: "test-org", - plan: Plans.ORGANIZATIONS, - isOrganization: true, - isPlatform: false, - platformBilling: null, - parent: null, - }, - }; - - mockPrisma.membership.findMany.mockResolvedValue([mockMembership]); - - const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ - userId: 123, - }); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - accepted: true, - user: { - isPlatformManaged: false, - }, - team: { - slug: "test-org", - plan: Plans.ORGANIZATIONS, - isOrganization: true, - isPlatform: false, - platformBilling: null, - parent: null, - }, - }); - }); - - it("should return membership with parent organization data", async () => { - const mockMembership = { - accepted: true, - user: { - isPlatformManaged: false, - }, - team: { - slug: "child-team", - plan: Plans.ORGANIZATIONS, - isOrganization: false, - isPlatform: false, - platformBilling: null, - parent: { - plan: Plans.ORGANIZATIONS, - isOrganization: true, - isPlatform: false, - }, - }, - }; - - mockPrisma.membership.findMany.mockResolvedValue([mockMembership]); - - const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ - userId: 123, - }); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - accepted: true, - user: { - isPlatformManaged: false, - }, - team: { - slug: "child-team", - plan: Plans.ORGANIZATIONS, - isOrganization: false, - isPlatform: false, - platformBilling: null, - parent: { - plan: Plans.ORGANIZATIONS, - isOrganization: true, - isPlatform: false, - }, - }, - }); - }); - - it("should return membership with platform billing data", async () => { - const mockMembership = { - accepted: true, - user: { - isPlatformManaged: true, - }, - team: { - slug: "platform-team", - plan: null, - isOrganization: false, - isPlatform: true, - platformBilling: { - plan: "ESSENTIALS", - }, - parent: null, - }, - }; - - mockPrisma.membership.findMany.mockResolvedValue([mockMembership]); - - const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ - userId: 123, - }); - - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - accepted: true, - user: { - isPlatformManaged: true, - }, - team: { - slug: "platform-team", - plan: null, - isOrganization: false, - isPlatform: true, - platformBilling: { - plan: "ESSENTIALS", - }, - parent: null, - }, - }); - }); - - it("should return multiple memberships for user", async () => { - const mockMemberships = [ - { - accepted: true, - user: { - isPlatformManaged: false, - }, - team: { - slug: "team-1", - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - platformBilling: null, - parent: null, - }, - }, - { - accepted: false, - user: { - isPlatformManaged: false, - }, - team: { - slug: "team-2", - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - platformBilling: null, - parent: null, - }, - }, - ]; - - mockPrisma.membership.findMany.mockResolvedValue(mockMemberships); - - const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ - userId: 123, - }); - - expect(result).toHaveLength(2); - expect(result[0].accepted).toBe(true); - expect(result[1].accepted).toBe(false); - expect(result[0].team.slug).toBe("team-1"); - expect(result[1].team.slug).toBe("team-2"); - }); - - it("should handle teams without slugs", async () => { - const mockMembership = { - accepted: true, - user: { - isPlatformManaged: false, - }, - team: { - slug: null, - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - platformBilling: null, - parent: null, - }, - }; - - mockPrisma.membership.findMany.mockResolvedValue([mockMembership]); - - const result = await MembershipRepository.findAllMembershipsByUserIdForBilling({ - userId: 123, - }); - - expect(result).toHaveLength(1); - expect(result[0].team.slug).toBeNull(); - expect(result[0].team.plan).toBe(Plans.TEAMS); - }); - }); -}); diff --git a/packages/trpc/server/routers/viewer/teams/__tests__/create.handler.test.ts b/packages/trpc/server/routers/viewer/teams/__tests__/create.handler.test.ts deleted file mode 100644 index fd0bd42481eb71..00000000000000 --- a/packages/trpc/server/routers/viewer/teams/__tests__/create.handler.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import prismaMock from "../../../../../../../tests/libs/__mocks__/prismaMock"; - -import { describe, expect, it, beforeEach, vi } from "vitest"; - -import { MembershipRole, Plans } from "@calcom/prisma/enums"; - -import { createHandler } from "../create.handler"; - -vi.mock("@calcom/lib/constants", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - IS_TEAM_BILLING_ENABLED: false, - WEBAPP_URL: "http://localhost:3000", - }; -}); - -vi.mock("@calcom/lib/server/repository/profile", () => ({ - ProfileRepository: { - findByOrgIdAndUsername: vi.fn(), - }, -})); - -describe("Team create handler", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should create standalone team with TEAMS plan", async () => { - prismaMock.team.findFirst.mockResolvedValue(null); - - const mockCreatedTeam = { - id: 1, - name: "Test Team", - slug: "test-team", - plan: Plans.TEAMS, - parentId: null, - }; - prismaMock.team.create.mockResolvedValue(mockCreatedTeam); - - const result = await createHandler({ - ctx: { - user: { - id: 123, - profile: { - organizationId: null, - }, - }, - }, - input: { - name: "Test Team", - slug: "test-team", - }, - }); - - expect(result.team).toEqual(mockCreatedTeam); - expect(result.url).toContain("/settings/teams/1/onboard-members"); - - expect(prismaMock.team.create).toHaveBeenCalledWith({ - data: { - slug: "test-team", - name: "Test Team", - members: { - create: { - userId: 123, - role: MembershipRole.OWNER, - accepted: true, - }, - }, - plan: Plans.TEAMS, - }, - }); - }); - - it("should create team under organization with ORGANIZATIONS plan", async () => { - prismaMock.team.findFirst.mockResolvedValue(null); - - const mockCreatedTeam = { - id: 2, - name: "Org Team", - slug: "org-team", - plan: Plans.ORGANIZATIONS, - parentId: 456, - }; - prismaMock.team.create.mockResolvedValue(mockCreatedTeam); - - const result = await createHandler({ - ctx: { - user: { - id: 123, - profile: { - organizationId: 456, - }, - organization: { - isOrgAdmin: true, - }, - }, - }, - input: { - name: "Org Team", - slug: "org-team", - }, - }); - - expect(result.team).toEqual(mockCreatedTeam); - expect(result.url).toContain("/settings/teams/2/onboard-members"); - - expect(prismaMock.team.create).toHaveBeenCalledWith({ - data: { - slug: "org-team", - name: "Org Team", - members: { - create: { - userId: 123, - role: MembershipRole.OWNER, - accepted: true, - }, - }, - parentId: 456, - plan: Plans.ORGANIZATIONS, - }, - }); - }); - - it("should throw error when slug is already taken", async () => { - prismaMock.team.findFirst.mockResolvedValue({ - id: 999, - slug: "taken-slug", - }); - - await expect( - createHandler({ - ctx: { - user: { - id: 123, - profile: { - organizationId: null, - }, - }, - }, - input: { - name: "Test Team", - slug: "taken-slug", - }, - }) - ).rejects.toThrow("team_url_taken"); - - expect(prismaMock.team.create).not.toHaveBeenCalled(); - }); - - it("should throw error when non-org admin tries to create team in organization", async () => { - await expect( - createHandler({ - ctx: { - user: { - id: 123, - profile: { - organizationId: 456, - }, - organization: { - isOrgAdmin: false, - }, - }, - }, - input: { - name: "Org Team", - slug: "org-team", - }, - }) - ).rejects.toThrow("org_admins_can_create_new_teams"); - - expect(prismaMock.team.findFirst).not.toHaveBeenCalled(); - expect(prismaMock.team.create).not.toHaveBeenCalled(); - }); - - it("should verify membership creation with OWNER role", async () => { - prismaMock.team.findFirst.mockResolvedValue(null); - - const mockCreatedTeam = { - id: 3, - name: "Test Team", - slug: "test-team", - plan: Plans.TEAMS, - parentId: null, - }; - prismaMock.team.create.mockResolvedValue(mockCreatedTeam); - - await createHandler({ - ctx: { - user: { - id: 789, - profile: { - organizationId: null, - }, - }, - }, - input: { - name: "Test Team", - slug: "test-team", - }, - }); - - expect(prismaMock.team.create).toHaveBeenCalledWith({ - data: { - slug: "test-team", - name: "Test Team", - members: { - create: { - userId: 789, - role: MembershipRole.OWNER, - accepted: true, - }, - }, - plan: Plans.TEAMS, - }, - }); - }); -}); diff --git a/packages/trpc/server/routers/viewer/teams/__tests__/hasTeamPlan.handler.test.ts b/packages/trpc/server/routers/viewer/teams/__tests__/hasTeamPlan.handler.test.ts deleted file mode 100644 index f8788b509a9d30..00000000000000 --- a/packages/trpc/server/routers/viewer/teams/__tests__/hasTeamPlan.handler.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { describe, expect, it, beforeEach, vi } from "vitest"; - -import { BillingPlans } from "@calcom/ee/billing/constants"; -import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans"; -import { MembershipRepository } from "@calcom/lib/server/repository/membership"; -import { Plans } from "@calcom/prisma/enums"; - -import { hasTeamPlanHandler } from "../hasTeamPlan.handler"; - -vi.mock("@calcom/features/ee/billing/domain/billing-plans"); -vi.mock("@calcom/lib/server/repository/membership"); - -describe("hasTeamPlan handler", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should return false for hasTeamPlan when user has no memberships", async () => { - vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue([]); - vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(BillingPlans.INDIVIDUALS); - - const result = await hasTeamPlanHandler({ - ctx: { user: { id: 123 } }, - }); - - expect(result.hasTeamPlan).toBe(false); - expect(result.plan).toBe(BillingPlans.INDIVIDUALS); - - expect(MembershipRepository.findAllMembershipsByUserIdForBilling).toHaveBeenCalledWith({ - userId: 123, - }); - }); - - it("should return true for hasTeamPlan when user has accepted team membership", async () => { - const mockMemberships = [ - { - accepted: true, - team: { - slug: "test-team", - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); - vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(Plans.TEAMS); - - const result = await hasTeamPlanHandler({ - ctx: { user: { id: 123 } }, - }); - - expect(result.hasTeamPlan).toBe(true); - expect(result.plan).toBe(Plans.TEAMS); - }); - - it("should return false for hasTeamPlan when user has unaccepted team membership", async () => { - const mockMemberships = [ - { - accepted: false, - team: { - slug: "test-team", - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); - vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(BillingPlans.INDIVIDUALS); - - const result = await hasTeamPlanHandler({ - ctx: { user: { id: 123 } }, - }); - - expect(result.hasTeamPlan).toBe(false); - expect(result.plan).toBe(BillingPlans.INDIVIDUALS); - }); - - it("should return false for hasTeamPlan when team has no slug", async () => { - const mockMemberships = [ - { - accepted: true, - team: { - slug: null, - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); - vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(BillingPlans.INDIVIDUALS); - - const result = await hasTeamPlanHandler({ - ctx: { user: { id: 123 } }, - }); - - expect(result.hasTeamPlan).toBe(false); - expect(result.plan).toBe(BillingPlans.INDIVIDUALS); - }); - - it("should return ORGANIZATIONS plan for organization membership", async () => { - const mockMemberships = [ - { - accepted: true, - team: { - slug: "test-org", - plan: Plans.ORGANIZATIONS, - isOrganization: true, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); - vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(Plans.ORGANIZATIONS); - - const result = await hasTeamPlanHandler({ - ctx: { user: { id: 123 } }, - }); - - expect(result.hasTeamPlan).toBe(true); - expect(result.plan).toBe(Plans.ORGANIZATIONS); - }); - - it("should handle multiple memberships correctly", async () => { - const mockMemberships = [ - { - accepted: true, - team: { - slug: "team-1", - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - { - accepted: true, - team: { - slug: "organization", - plan: Plans.ORGANIZATIONS, - isOrganization: true, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); - vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(Plans.ORGANIZATIONS); - - const result = await hasTeamPlanHandler({ - ctx: { user: { id: 123 } }, - }); - - expect(result.hasTeamPlan).toBe(true); - expect(result.plan).toBe(Plans.ORGANIZATIONS); - }); - - it("should handle platform teams correctly", async () => { - const mockMemberships = [ - { - accepted: true, - team: { - slug: "platform-team", - plan: null, - isOrganization: false, - isPlatform: true, - parent: null, - platformBilling: { - plan: "STARTER", - }, - }, - user: { - isPlatformManaged: true, - }, - }, - ]; - - vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); - vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(BillingPlans.PLATFORM_STARTER); - - const result = await hasTeamPlanHandler({ - ctx: { user: { id: 123 } }, - }); - - expect(result.hasTeamPlan).toBe(true); - expect(result.plan).toBe(BillingPlans.PLATFORM_STARTER); - }); - - it("should call BillingPlanService with correct membership data", async () => { - const mockMemberships = [ - { - accepted: true, - team: { - slug: "test-team", - plan: Plans.TEAMS, - isOrganization: false, - isPlatform: false, - parent: null, - platformBilling: null, - }, - user: { - isPlatformManaged: false, - }, - }, - ]; - - vi.mocked(MembershipRepository.findAllMembershipsByUserIdForBilling).mockResolvedValue(mockMemberships); - vi.mocked(BillingPlanService.getUserPlanByMemberships).mockResolvedValue(Plans.TEAMS); - - await hasTeamPlanHandler({ - ctx: { user: { id: 123 } }, - }); - - expect(BillingPlanService.getUserPlanByMemberships).toHaveBeenCalledWith(mockMemberships); - }); -}); From ae1ff8f15b725566b828864a217d8d0e308b520f Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:50:42 +0530 Subject: [PATCH 08/14] fix: make `BillingPlanService` instantiable and use `TeamRepository` --- .../web/app/api/support/conversation/route.ts | 3 +- .../web/app/api/teams/[team]/upgrade/route.ts | 34 ++++++++----------- .../ee/billing/domain/billing-plans.ts | 2 +- packages/lib/server/repository/team.ts | 15 ++++++++ .../viewer/teams/hasTeamPlan.handler.ts | 3 +- 5 files changed, 34 insertions(+), 23 deletions(-) diff --git a/apps/web/app/api/support/conversation/route.ts b/apps/web/app/api/support/conversation/route.ts index 1289ce1786e380..74ae61ecad3f06 100644 --- a/apps/web/app/api/support/conversation/route.ts +++ b/apps/web/app/api/support/conversation/route.ts @@ -46,7 +46,8 @@ export async function POST(req: NextRequest) { const { user } = session; const memberships = await MembershipRepository.findAllMembershipsByUserIdForBilling({ userId: user.id }); - const plan = await BillingPlanService.getUserPlanByMemberships(memberships); + 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( diff --git a/apps/web/app/api/teams/[team]/upgrade/route.ts b/apps/web/app/api/teams/[team]/upgrade/route.ts index 386f38468d5e9f..25396ee6de82ba 100644 --- a/apps/web/app/api/teams/[team]/upgrade/route.ts +++ b/apps/web/app/api/teams/[team]/upgrade/route.ts @@ -11,7 +11,8 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; -import prisma from "@calcom/prisma"; +import { TeamRepository } from "@calcom/lib/server/repository/team"; +import { prisma } from "@calcom/prisma"; import { Plans } from "@calcom/prisma/enums"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; @@ -42,22 +43,15 @@ async function getHandler(req: NextRequest, { params }: { params: Promise { const hasTeamPlan = memberships.some( (membership) => membership.accepted === true && membership.team.slug !== null ); - const plan = await BillingPlanService.getUserPlanByMemberships(memberships); + const billingPlanService = new BillingPlanService(); + const plan = await billingPlanService.getUserPlanByMemberships(memberships); return { hasTeamPlan: !!hasTeamPlan, plan }; }; From e8f1316c7c3e95642e9442f97432e12ee108d0ed Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:26:16 +0530 Subject: [PATCH 09/14] Revert "fix: make `BillingPlanService` instantiable and use `TeamRepository`" This reverts commit ae1ff8f15b725566b828864a217d8d0e308b520f. --- .../web/app/api/support/conversation/route.ts | 3 +- .../web/app/api/teams/[team]/upgrade/route.ts | 34 +++++++++++-------- .../ee/billing/domain/billing-plans.ts | 2 +- packages/lib/server/repository/team.ts | 15 -------- .../viewer/teams/hasTeamPlan.handler.ts | 3 +- 5 files changed, 23 insertions(+), 34 deletions(-) diff --git a/apps/web/app/api/support/conversation/route.ts b/apps/web/app/api/support/conversation/route.ts index 74ae61ecad3f06..1289ce1786e380 100644 --- a/apps/web/app/api/support/conversation/route.ts +++ b/apps/web/app/api/support/conversation/route.ts @@ -46,8 +46,7 @@ export async function POST(req: NextRequest) { const { user } = session; const memberships = await MembershipRepository.findAllMembershipsByUserIdForBilling({ userId: user.id }); - const billingPlanService = new BillingPlanService(); - const plan = await billingPlanService.getUserPlanByMemberships(memberships); + 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( diff --git a/apps/web/app/api/teams/[team]/upgrade/route.ts b/apps/web/app/api/teams/[team]/upgrade/route.ts index 25396ee6de82ba..386f38468d5e9f 100644 --- a/apps/web/app/api/teams/[team]/upgrade/route.ts +++ b/apps/web/app/api/teams/[team]/upgrade/route.ts @@ -11,8 +11,7 @@ import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; -import { TeamRepository } from "@calcom/lib/server/repository/team"; -import { prisma } from "@calcom/prisma"; +import prisma from "@calcom/prisma"; import { Plans } from "@calcom/prisma/enums"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; @@ -43,15 +42,22 @@ async function getHandler(req: NextRequest, { params }: { params: Promise { const hasTeamPlan = memberships.some( (membership) => membership.accepted === true && membership.team.slug !== null ); - const billingPlanService = new BillingPlanService(); - const plan = await billingPlanService.getUserPlanByMemberships(memberships); + const plan = await BillingPlanService.getUserPlanByMemberships(memberships); return { hasTeamPlan: !!hasTeamPlan, plan }; }; From 1970c05cb372dd81a3a60cabcf42546aca4c7577 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:00:30 +0530 Subject: [PATCH 10/14] revert to runtime calculations. review fixes --- .../web/app/api/teams/[team]/upgrade/route.ts | 24 +----------- apps/web/app/api/teams/create/route.ts | 3 +- packages/features/ee/billing/constants.ts | 5 ++- .../ee/billing/domain/billing-plans.ts | 37 ++++++++++++------- .../createOrganizationFromOnboarding.test.ts | 21 ++--------- .../ee/support/lib/intercom/useIntercom.ts | 2 + packages/lib/hooks/useHasPaidPlan.ts | 3 +- packages/lib/server/repository/membership.ts | 5 +-- .../lib/server/repository/organization.ts | 3 +- .../migration.sql | 22 ----------- packages/prisma/schema.prisma | 8 ---- .../__tests__/createTeams.handler.test.ts | 4 +- .../organizations/createTeams.handler.ts | 4 +- .../viewer/organizations/publish.handler.ts | 2 - .../routers/viewer/teams/create.handler.ts | 6 +-- .../viewer/teams/hasTeamPlan.handler.ts | 6 ++- turbo.json | 4 +- 17 files changed, 51 insertions(+), 108 deletions(-) delete mode 100644 packages/prisma/migrations/20250912155739_add_plan_to_teams_table/migration.sql diff --git a/apps/web/app/api/teams/[team]/upgrade/route.ts b/apps/web/app/api/teams/[team]/upgrade/route.ts index 386f38468d5e9f..57bd3264986b8a 100644 --- a/apps/web/app/api/teams/[team]/upgrade/route.ts +++ b/apps/web/app/api/teams/[team]/upgrade/route.ts @@ -12,7 +12,6 @@ import stripe from "@calcom/features/ee/payments/server/stripe"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import prisma from "@calcom/prisma"; -import { Plans } from "@calcom/prisma/enums"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -44,20 +43,12 @@ async function getHandler(req: NextRequest, { params }: { params: Promise { expect(organization.name).toBe(organizationOnboarding.name); expect(organization.slug).toBe(organizationOnboarding.slug); - //parent team plan and sub teams plan should be updated to organizations - expect(organization.plan).toBe(Plans.ORGANIZATIONS); - - const teams = await prismock.team.findMany({ - where: { - id: { - in: organizationOnboarding.teams?.map((t) => t.id), - }, - }, - }); - - expect(teams.every((t) => t.plan === Plans.ORGANIZATIONS)).toBe(true); - // Verify owner is the existing user expect(owner.id).toBe(existingUser.id); expect(owner.email).toBe(existingUser.email); diff --git a/packages/features/ee/support/lib/intercom/useIntercom.ts b/packages/features/ee/support/lib/intercom/useIntercom.ts index 7b08f4ad56bd13..7d82cf11563738 100644 --- a/packages/features/ee/support/lib/intercom/useIntercom.ts +++ b/packages/features/ee/support/lib/intercom/useIntercom.ts @@ -51,6 +51,7 @@ export const useIntercom = () => { if (res?.hash) { userHash = res.hash; } + console.log("plan in boot: ", plan); hookData.boot({ ...(data && data?.name && { name: data.name }), @@ -97,6 +98,7 @@ export const useIntercom = () => { userHash = res.hash; } + console.log("plan in open: ", plan); hookData.boot({ ...(data && data?.name && { name: data.name }), ...(data && data?.email && { email: data.email }), diff --git a/packages/lib/hooks/useHasPaidPlan.ts b/packages/lib/hooks/useHasPaidPlan.ts index fa08470a3abe4f..7b09e62902f471 100644 --- a/packages/lib/hooks/useHasPaidPlan.ts +++ b/packages/lib/hooks/useHasPaidPlan.ts @@ -4,9 +4,10 @@ import { IS_SELF_HOSTED } from "../constants"; import hasKeyInMetadata from "../hasKeyInMetadata"; export function useHasPaidPlan() { - if (IS_SELF_HOSTED) return { isPending: false, hasPaidPlan: true }; + // if (IS_SELF_HOSTED) return { isPending: false, hasPaidPlan: true }; const { data, isPending: isPendingTeamQuery } = trpc.viewer.teams.hasTeamPlan.useQuery(); + console.log("data: ", data); const { data: user, isPending: isPendingUserQuery } = trpc.viewer.me.get.useQuery(); diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index ca85a4a631247b..4b77b57a8acf87 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -314,7 +314,7 @@ export class MembershipRepository { }); } - static async findAllMembershipsByUserIdForBilling({ userId }: { userId: number }) { + async findAllMembershipsByUserIdForBilling({ userId }: { userId: number }) { return await prisma.membership.findMany({ where: { userId }, select: { @@ -327,7 +327,6 @@ export class MembershipRepository { team: { select: { slug: true, - plan: true, isOrganization: true, isPlatform: true, platformBilling: { @@ -337,8 +336,8 @@ export class MembershipRepository { }, parent: { select: { - plan: true, isOrganization: true, + slug: true, isPlatform: true, }, }, diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index 251ade5a7c45f5..94513c959e3842 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -4,7 +4,7 @@ import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getO import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { prisma } from "@calcom/prisma"; -import { MembershipRole, Plans } from "@calcom/prisma/enums"; +import { MembershipRole } from "@calcom/prisma/enums"; import type { CreationSource } from "@calcom/prisma/enums"; import type { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; @@ -141,7 +141,6 @@ export class OrganizationRepository { name: orgData.name, isOrganization: true, slug: orgData.slug, - plan: !orgData.isPlatform ? Plans.ORGANIZATIONS : null, // This is huge and causes issues, we need to have the logic to convert logo to logoUrl and then use that url ehre. // logoUrl: orgData.logoUrl, bio: orgData.bio, diff --git a/packages/prisma/migrations/20250912155739_add_plan_to_teams_table/migration.sql b/packages/prisma/migrations/20250912155739_add_plan_to_teams_table/migration.sql deleted file mode 100644 index 75af2744b709e0..00000000000000 --- a/packages/prisma/migrations/20250912155739_add_plan_to_teams_table/migration.sql +++ /dev/null @@ -1,22 +0,0 @@ --- CreateEnum -CREATE TYPE "Plans" AS ENUM ('ENTERPRISE', 'ORGANIZATIONS', 'TEAMS'); - --- AlterTable -ALTER TABLE "Team" ADD COLUMN "plan" "Plans"; - --- Update data for existing records -UPDATE public."Team" AS child_team -SET "plan" = CASE - WHEN parent_team."id" IS NOT NULL - AND parent_team."isOrganization" = true - AND parent_team."isPlatform" = false - THEN 'ORGANIZATIONS'::"Plans" - WHEN child_team."isOrganization" = true AND child_team."isPlatform" = false - THEN 'ORGANIZATIONS'::"Plans" - WHEN child_team."isOrganization" = false - THEN 'TEAMS'::"Plans" - ELSE NULL -END -FROM public."Team" AS parent_team -WHERE parent_team."id" = child_team."parentId" - OR child_team."parentId" IS NULL; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 69a7f68415d078..6f08cb9cd6613d 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -61,12 +61,6 @@ enum CreationSource { WEBAPP @map("webapp") } -enum Plans { - ENTERPRISE - ORGANIZATIONS - TEAMS -} - model Host { user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int @@ -593,8 +587,6 @@ model Team { managedOrganizations ManagedOrganization[] @relation("ManagerOrganization") filterSegments FilterSegment[] - plan Plans? - @@unique([slug, parentId]) @@index([parentId]) } diff --git a/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts index 950934b7fb8b9a..7e38a3978ee947 100644 --- a/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts @@ -3,7 +3,7 @@ import prismock from "../../../../../../../tests/libs/__mocks__/prisma"; import { describe, expect, it, beforeEach } from "vitest"; import slugify from "@calcom/lib/slugify"; -import { MembershipRole, UserPermissionRole, CreationSource, Plans } from "@calcom/prisma/enums"; +import { MembershipRole, UserPermissionRole, CreationSource } from "@calcom/prisma/enums"; import { createTeamsHandler } from "../createTeams.handler"; @@ -160,8 +160,6 @@ describe("createTeams handler", () => { expect(createdTeams[0].slug).toBe("team-1"); expect(createdTeams[1].name).toBe("Team 2"); expect(createdTeams[1].slug).toBe("team-2"); - expect(createdTeams[0].plan).toBe(Plans.ORGANIZATIONS); - expect(createdTeams[1].plan).toBe(Plans.ORGANIZATIONS); }); it("should handle creation of team in Organization that has same slug as a team already in the same org", async () => { diff --git a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts index ca3db03e28ce7c..b3faf504a80489 100644 --- a/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts @@ -7,7 +7,7 @@ import slugify from "@calcom/lib/slugify"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import type { CreationSource } from "@calcom/prisma/enums"; -import { MembershipRole, Plans, RedirectType } from "@calcom/prisma/enums"; +import { MembershipRole, RedirectType } from "@calcom/prisma/enums"; import { teamMetadataSchema, teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; @@ -119,7 +119,6 @@ export const createTeamsHandler = async ({ ctx, input }: CreateTeamsOptions) => name, parentId: orgId, slug: slugify(name), - plan: Plans.ORGANIZATIONS, members: { create: { userId: ctx.user.id, role: MembershipRole.OWNER, accepted: true }, }, @@ -232,7 +231,6 @@ async function moveTeam({ data: { slug: newSlug, parentId: org.id, - plan: Plans.ORGANIZATIONS, }, }); } catch (error) { diff --git a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts index 39a4b02c6b5aed..77c8e01df8be25 100644 --- a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts @@ -3,7 +3,6 @@ import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/pay import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import { prisma } from "@calcom/prisma"; -import { Plans } from "@calcom/prisma/enums"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; import { TRPCError } from "@trpc/server"; @@ -78,7 +77,6 @@ export const publishHandler = async ({ ctx }: PublishOptions) => { where: { id: orgId }, data: { slug: requestedSlug, - plan: Plans.ORGANIZATIONS, metadata: { ...newMetadata }, }, }); diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index 977383e7118fe5..96f0e551cc11da 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -4,7 +4,7 @@ import { uploadLogo } from "@calcom/lib/server/avatar"; import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; import { prisma } from "@calcom/prisma"; -import { MembershipRole, Plans } from "@calcom/prisma/enums"; +import { MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; @@ -102,9 +102,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { accepted: true, }, }, - ...(isOrgChildTeam - ? { parentId: user.profile?.organizationId, plan: Plans.ORGANIZATIONS } - : { plan: Plans.TEAMS }), + ...(isOrgChildTeam && { parentId: user.profile?.organizationId }), }, }); // Upload logo, create doesn't allow logo removal diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts index b6a7164acaac58..c52833ea6ee204 100644 --- a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts @@ -10,11 +10,13 @@ type HasTeamPlanOptions = { export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => { const userId = ctx.user.id; - const memberships = await MembershipRepository.findAllMembershipsByUserIdForBilling({ userId }); + const membershipRepository = new MembershipRepository(); + const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId }); const hasTeamPlan = memberships.some( (membership) => membership.accepted === true && membership.team.slug !== null ); - const plan = await BillingPlanService.getUserPlanByMemberships(memberships); + const billingPlanService = new BillingPlanService(); + const plan = await billingPlanService.getUserPlanByMemberships(memberships); return { hasTeamPlan: !!hasTeamPlan, plan }; }; diff --git a/turbo.json b/turbo.json index 354a54b469b361..4edb4c2abb53bf 100644 --- a/turbo.json +++ b/turbo.json @@ -280,7 +280,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": { From 36072d67704263cb9373e0bdacc43de335109497 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:06:44 +0530 Subject: [PATCH 11/14] remove uneccessary changes and logs --- .../ee/billing/teams/internal-team-billing.test.ts | 1 - .../ee/billing/teams/internal-team-billing.ts | 6 +++++- .../features/ee/support/lib/intercom/useIntercom.ts | 2 -- packages/lib/hooks/useHasPaidPlan.ts | 3 +-- packages/lib/server/repository/organization.ts | 1 - .../__tests__/createTeams.handler.test.ts | 1 - .../routers/viewer/organizations/publish.handler.ts | 13 ++++--------- 7 files changed, 10 insertions(+), 17 deletions(-) diff --git a/packages/features/ee/billing/teams/internal-team-billing.test.ts b/packages/features/ee/billing/teams/internal-team-billing.test.ts index 4e0ccc978e7c15..4fbe17241305bc 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.test.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.test.ts @@ -58,7 +58,6 @@ describe("InternalTeamBilling", () => { where: { id: 1 }, data: { metadata: {}, - plan: null, }, }); }); diff --git a/packages/features/ee/billing/teams/internal-team-billing.ts b/packages/features/ee/billing/teams/internal-team-billing.ts index 76c8e077edc1fb..ea62553baa42f3 100644 --- a/packages/features/ee/billing/teams/internal-team-billing.ts +++ b/packages/features/ee/billing/teams/internal-team-billing.ts @@ -7,6 +7,7 @@ import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers"; import logger from "@calcom/lib/logger"; import { Redirect } from "@calcom/lib/redirect"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; @@ -112,7 +113,7 @@ export class InternalTeamBilling implements TeamBilling { subscriptionId: undefined, subscriptionItemId: undefined, }); - await prisma.team.update({ where: { id: this.team.id }, data: { metadata, plan: null } }); + await prisma.team.update({ where: { id: this.team.id }, data: { metadata } }); log.info(`Downgraded team ${this.team.id}`); } catch (error) { this.logErrorFromUnknown(error); @@ -124,6 +125,9 @@ export class InternalTeamBilling implements TeamBilling { const { id: teamId, metadata, isOrganization } = this.team; const { url } = await this.checkIfTeamPaymentRequired(); + const organizationOnboarding = await OrganizationOnboardingRepository.findByOrganizationId( + this.team.id + ); log.debug("updateQuantity", safeStringify({ url, team: this.team })); /** diff --git a/packages/features/ee/support/lib/intercom/useIntercom.ts b/packages/features/ee/support/lib/intercom/useIntercom.ts index 7d82cf11563738..7b08f4ad56bd13 100644 --- a/packages/features/ee/support/lib/intercom/useIntercom.ts +++ b/packages/features/ee/support/lib/intercom/useIntercom.ts @@ -51,7 +51,6 @@ export const useIntercom = () => { if (res?.hash) { userHash = res.hash; } - console.log("plan in boot: ", plan); hookData.boot({ ...(data && data?.name && { name: data.name }), @@ -98,7 +97,6 @@ export const useIntercom = () => { userHash = res.hash; } - console.log("plan in open: ", plan); hookData.boot({ ...(data && data?.name && { name: data.name }), ...(data && data?.email && { email: data.email }), diff --git a/packages/lib/hooks/useHasPaidPlan.ts b/packages/lib/hooks/useHasPaidPlan.ts index 7b09e62902f471..fa08470a3abe4f 100644 --- a/packages/lib/hooks/useHasPaidPlan.ts +++ b/packages/lib/hooks/useHasPaidPlan.ts @@ -4,10 +4,9 @@ import { IS_SELF_HOSTED } from "../constants"; import hasKeyInMetadata from "../hasKeyInMetadata"; export function useHasPaidPlan() { - // if (IS_SELF_HOSTED) return { isPending: false, hasPaidPlan: true }; + if (IS_SELF_HOSTED) return { isPending: false, hasPaidPlan: true }; const { data, isPending: isPendingTeamQuery } = trpc.viewer.teams.hasTeamPlan.useQuery(); - console.log("data: ", data); const { data: user, isPending: isPendingUserQuery } = trpc.viewer.me.get.useQuery(); diff --git a/packages/lib/server/repository/organization.ts b/packages/lib/server/repository/organization.ts index 94513c959e3842..fb6dceaf7e47bb 100644 --- a/packages/lib/server/repository/organization.ts +++ b/packages/lib/server/repository/organization.ts @@ -257,7 +257,6 @@ export class OrganizationRepository { }, }, include: { - //eslint-disable-next-line @calcom/eslint/no-prisma-include-true team: true, }, }); diff --git a/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts index 7e38a3978ee947..0f96c23825cfff 100644 --- a/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/__tests__/createTeams.handler.test.ts @@ -72,7 +72,6 @@ type CreateScenarioOptions = { slug?: string; metadata?: Record; addToParentId: "createdOrganization" | number | null; - plan?: Plans; }>; }; diff --git a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts index 77c8e01df8be25..8b9aca449707e4 100644 --- a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts @@ -26,14 +26,7 @@ export const publishHandler = async ({ ctx }: PublishOptions) => { where: { id: orgId, }, - select: { - metadata: true, - id: true, - members: { - select: { id: true }, - }, - }, - // include: { members: true }, + include: { members: true }, }); if (!prevTeam) throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found." }); @@ -71,9 +64,11 @@ export const publishHandler = async ({ ctx }: PublishOptions) => { } const { requestedSlug, ...newMetadata } = metadata.data; + let updatedTeam: Awaited>; + try { - await prisma.team.update({ + updatedTeam = await prisma.team.update({ where: { id: orgId }, data: { slug: requestedSlug, From 7d6f05094283a0b2114c6906dac112abcb2cbe8a Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Wed, 24 Sep 2025 20:24:45 +0530 Subject: [PATCH 12/14] review fixes --- .../web/app/api/support/conversation/route.ts | 7 ++- packages/features/ee/billing/constants.ts | 8 ++++ .../ee/billing/domain/billing-plans.ts | 47 +++++++++++++------ packages/lib/server/repository/membership.ts | 2 + 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/apps/web/app/api/support/conversation/route.ts b/apps/web/app/api/support/conversation/route.ts index 1289ce1786e380..9ebc9cd9026774 100644 --- a/apps/web/app/api/support/conversation/route.ts +++ b/apps/web/app/api/support/conversation/route.ts @@ -45,8 +45,11 @@ export async function POST(req: NextRequest) { const { user } = session; - const memberships = await MembershipRepository.findAllMembershipsByUserIdForBilling({ userId: user.id }); - const plan = await BillingPlanService.getUserPlanByMemberships(memberships); + const membershipRepository = new MembershipRepository(); + 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( diff --git a/packages/features/ee/billing/constants.ts b/packages/features/ee/billing/constants.ts index a5fd061da8cbe7..ca52b2d2248466 100644 --- a/packages/features/ee/billing/constants.ts +++ b/packages/features/ee/billing/constants.ts @@ -20,5 +20,13 @@ export enum BillingPlan { 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 index b15d38e71dd84d..03e170a5b3a7c4 100644 --- a/packages/features/ee/billing/domain/billing-plans.ts +++ b/packages/features/ee/billing/domain/billing-plans.ts @@ -1,4 +1,11 @@ -import { BillingPlan, ENTERPRISE_SLUGS, PLATFORM_ENTERPRISE_SLUGS } from "@calcom/ee/billing/constants"; +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( @@ -7,10 +14,12 @@ export class BillingPlanService { isOrganization: boolean; isPlatform: boolean; slug: string | null; + metadata: JsonValue; parent: { isOrganization: boolean; slug: string | null; isPlatform: boolean; + metadata: JsonValue; } | null; platformBilling: { plan: string; @@ -28,26 +37,34 @@ export class BillingPlanService { if (PLATFORM_ENTERPRISE_SLUGS.includes(team.slug ?? "")) return BillingPlan.PLATFORM_ENTERPRISE; if (!team.platformBilling) continue; - switch (team.platformBilling.plan) { - case "FREE": - case "STARTER": - return BillingPlan.PLATFORM_STARTER; - case "ESSENTIALS": - return BillingPlan.PLATFORM_ESSENTIALS; - case "SCALE": - return BillingPlan.PLATFORM_SCALE; - case "ENTERPRISE": - return BillingPlan.PLATFORM_ENTERPRISE; - default: - return team.platformBilling.plan; - } + return PLATFORM_PLANS_MAP[team.platformBilling.plan] ?? team.platformBilling.plan; } else { - if (team.parent && team.parent.isOrganization && !team.parent.isPlatform) { + 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 diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index 4b77b57a8acf87..c1c7fef2713c4d 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -329,6 +329,7 @@ export class MembershipRepository { slug: true, isOrganization: true, isPlatform: true, + metadata: true, platformBilling: { select: { plan: true, @@ -338,6 +339,7 @@ export class MembershipRepository { select: { isOrganization: true, slug: true, + metadata: true, isPlatform: true, }, }, From 7e224a26bdb5d634fadd4b2bd2102c93399c9a93 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Wed, 24 Sep 2025 20:44:51 +0530 Subject: [PATCH 13/14] review fixes --- apps/web/app/api/support/conversation/route.ts | 4 ++-- packages/lib/server/repository/membership.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/app/api/support/conversation/route.ts b/apps/web/app/api/support/conversation/route.ts index 9ebc9cd9026774..671bd39b27da2c 100644 --- a/apps/web/app/api/support/conversation/route.ts +++ b/apps/web/app/api/support/conversation/route.ts @@ -11,7 +11,7 @@ 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"; @@ -45,7 +45,7 @@ export async function POST(req: NextRequest) { const { user } = session; - const membershipRepository = new MembershipRepository(); + const membershipRepository = new MembershipRepository(prisma); const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId: user.id }); const billingPlanService = new BillingPlanService(); const plan = await billingPlanService.getUserPlanByMemberships(memberships); diff --git a/packages/lib/server/repository/membership.ts b/packages/lib/server/repository/membership.ts index c1c7fef2713c4d..aad4979c6016d6 100644 --- a/packages/lib/server/repository/membership.ts +++ b/packages/lib/server/repository/membership.ts @@ -315,7 +315,7 @@ export class MembershipRepository { } async findAllMembershipsByUserIdForBilling({ userId }: { userId: number }) { - return await prisma.membership.findMany({ + return this.prismaClient.membership.findMany({ where: { userId }, select: { accepted: true, From 4f950f29c596427e96ec30b88277d805cd41f069 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Wed, 24 Sep 2025 20:45:37 +0530 Subject: [PATCH 14/14] fix: type check --- .../trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts index c52833ea6ee204..ac5e2be89d1f24 100644 --- a/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts @@ -1,5 +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: { @@ -10,7 +11,7 @@ type HasTeamPlanOptions = { export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => { const userId = ctx.user.id; - const membershipRepository = new MembershipRepository(); + const membershipRepository = new MembershipRepository(prisma); const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId }); const hasTeamPlan = memberships.some( (membership) => membership.accepted === true && membership.team.slug !== null