Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/app/api/support/conversation/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +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/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";

Expand Down Expand Up @@ -43,6 +45,9 @@ 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);
if (!existingContact.data) {
const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id });
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
Expand Down Expand Up @@ -72,6 +77,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,
},
});

Expand Down
32 changes: 24 additions & 8 deletions apps/web/app/api/teams/[team]/upgrade/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ 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";

import { buildLegacyRequest } from "@lib/buildLegacyCtx";
Expand Down Expand Up @@ -41,23 +43,24 @@ async function getHandler(req: NextRequest, { params }: { params: Promise<Params
throw new HttpError({ statusCode: 402, message: "Payment required" });
}

let team = await prisma.team.findFirst({
where: { metadata: { path: ["paymentId"], equals: checkoutSession.id } },
});
const teamRepository = new TeamRepository(prisma);
let team = await teamRepository.findFirstByMetadataPaymentId({ paymentId: checkoutSession.id });

let metadata;

if (!team) {
const prevTeam = await prisma.team.findFirstOrThrow({ where: { id } });
const prevTeam = await teamRepository.findById({ id });

if (!prevTeam) throw new Error("Prev team not found");

metadata = teamMetadataStrictSchema.safeParse(prevTeam.metadata);
if (!metadata.success) {
throw new HttpError({ statusCode: 400, message: "Invalid team metadata" });
}

const { requestedSlug, ...newMetadata } = metadata.data || {};
team = await prisma.team.update({
where: { id },
team = await teamRepository.updateById({
id,
data: {
metadata: {
...newMetadata,
Expand All @@ -71,8 +74,14 @@ async function getHandler(req: NextRequest, { params }: { params: Promise<Params
const slug = prevTeam.slug || requestedSlug;
if (slug) {
try {
team = await prisma.team.update({ where: { id }, data: { slug } });
team = await teamRepository.updateById({ id, data: { slug } });
} catch (error) {
await teamRepository.updateById({
id,
data: {
plan: team.isOrganization ? Plans.ORGANIZATIONS : Plans.TEAMS,
},
});
const { message, statusCode } = getRequestedSlugError(error, slug);
return NextResponse.json({ message }, { status: statusCode });
}
Expand All @@ -86,6 +95,13 @@ async function getHandler(req: NextRequest, { params }: { params: Promise<Params
}
}

await teamRepository.updateById({
id,
data: {
plan: team.isOrganization ? Plans.ORGANIZATIONS : Plans.TEAMS,
},
});

const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });

if (!session) {
Expand Down
3 changes: 2 additions & 1 deletion apps/web/app/api/teams/create/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { z } from "zod";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { MembershipRole, Plans } from "@calcom/prisma/enums";

const querySchema = z.object({
session_id: z.string().min(1),
Expand Down Expand Up @@ -69,6 +69,7 @@ async function getHandler(req: NextRequest) {
data: {
name: checkoutSessionMetadata.teamName,
slug: checkoutSessionMetadata.teamSlug,
plan: Plans.TEAMS,
members: {
create: {
userId: checkoutSessionMetadata.userId as number,
Expand Down
12 changes: 12 additions & 0 deletions packages/features/ee/billing/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,15 @@ 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",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are mapping needed for the workflows that is created inside Intercom. based on user plan they are adding tags and routing them

}
51 changes: 51 additions & 0 deletions packages/features/ee/billing/domain/billing-plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { BillingPlans } from "@calcom/ee/billing/constants";
import type { Plans } from "@calcom/prisma/enums";

export class BillingPlanService {
async getUserPlanByMemberships(
memberships: {
team: {
plan: Plans | null;
isOrganization: boolean;
isPlatform: boolean;
parent: {
isOrganization: boolean;
isPlatform: boolean;
plan: Plans | null;
} | null;
platformBilling: {
plan: string;
} | null;
};
user: {
isPlatformManaged: boolean;
};
}[]
) {
if (memberships.length === 0) return BillingPlans.INDIVIDUALS;

for (const { team, user } of memberships) {
if (team.isPlatform || user.isPlatformManaged) {
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?.plan) return team.parent.plan;
if (team.plan) return team.plan;
}
}
return BillingPlans.UNKNOWN;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe("InternalTeamBilling", () => {
where: { id: 1 },
data: {
metadata: {},
plan: null,
},
});
});
Expand Down
6 changes: 1 addition & 5 deletions packages/features/ee/billing/teams/internal-team-billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ 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";
Expand Down Expand Up @@ -113,7 +112,7 @@ export class InternalTeamBilling implements TeamBilling {
subscriptionId: undefined,
subscriptionItemId: undefined,
});
await prisma.team.update({ where: { id: this.team.id }, data: { metadata } });
await prisma.team.update({ where: { id: this.team.id }, data: { metadata, plan: null } });
log.info(`Downgraded team ${this.team.id}`);
} catch (error) {
this.logErrorFromUnknown(error);
Expand All @@ -125,9 +124,6 @@ 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 }));

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService"
import * as constants from "@calcom/lib/constants";
import { createDomain } from "@calcom/lib/domainManager/organization";
import type { OrganizationOnboarding } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { MembershipRole, Plans } from "@calcom/prisma/enums";
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";

Expand Down Expand Up @@ -66,8 +66,8 @@ const mockOrganizationOnboarding = {
createdById: 1,
invitedMembers: [{ email: "[email protected]" }, { email: "[email protected]" }],
teams: [
{ id: 1, name: "Team To Move", isBeingMigrated: true, slug: "new-team-slug" },
{ id: 2, name: "New Team", isBeingMigrated: false, slug: null },
{ id: 1, name: "Team To Move", isBeingMigrated: true, slug: "new-team-slug", plan: Plans.TEAMS },
{ id: 2, name: "New Team", isBeingMigrated: false, slug: null, plan: Plans.TEAMS },
],
logo: null,
bio: null,
Expand All @@ -81,6 +81,7 @@ async function createTestUser(data: {
email: string;
name?: string;
username?: string;
//eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata?: any;
onboardingCompleted?: boolean;
emailVerified?: Date | null;
Expand All @@ -101,6 +102,7 @@ async function createTestOrganization(data: {
name: string;
slug: string;
isOrganization?: boolean;
//eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata?: any;
}) {
return prismock.team.create({
Expand Down Expand Up @@ -244,6 +246,19 @@ describe("createOrganizationFromOnboarding", () => {
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);
Expand Down
4 changes: 3 additions & 1 deletion packages/features/ee/support/lib/intercom/useIntercom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const useIntercom = () => {
},
},
});
const { hasPaidPlan } = useHasPaidPlan();
const { hasPaidPlan, plan } = useHasPaidPlan();
const { hasTeamPlan } = useHasTeamPlan();

const boot = async () => {
Expand Down Expand Up @@ -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,
},
});
};
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions packages/lib/hooks/useHasPaidPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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() {
Expand Down
37 changes: 35 additions & 2 deletions packages/lib/server/repository/membership.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -315,6 +314,40 @@ export class MembershipRepository {
});
}

static async findAllMembershipsByUserIdForBilling({ userId }: { userId: number }) {
return await prisma.membership.findMany({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need to be static and no need the await here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

where: { userId },
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,
},
},
},
},
},
});
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Filter at the source: return only accepted memberships for billing

Prevents pending invites from skewing plan derivation across all call sites.

-    return await prisma.membership.findMany({
-      where: { userId },
+    return await prisma.membership.findMany({
+      where: { userId, accepted: true },
       select: {
-        accepted: true,
+        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,
               },
             },
           },
         },
       },
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static async findAllMembershipsByUserIdForBilling({ userId }: { userId: number }) {
return await prisma.membership.findMany({
where: { userId },
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,
},
},
},
},
},
});
}
static async findAllMembershipsByUserIdForBilling({ userId }: { userId: number }) {
return await prisma.membership.findMany({
where: { userId, accepted: true },
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,
},
},
},
},
},
});
}
🤖 Prompt for AI Agents
In packages/lib/server/repository/membership.ts around lines 317 to 349, the
method returns all memberships for a user but should only return accepted
memberships for billing; update the prisma.membership.findMany call to include
accepted: true in the where clause (e.g., where: { userId, accepted: true }) so
pending invites are excluded while keeping the current select projection
unchanged.


static async findByTeamIdForAvailability({ teamId }: { teamId: number }) {
const memberships = await prisma.membership.findMany({
where: { teamId },
Expand Down
Loading
Loading