Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
10 changes: 9 additions & 1 deletion apps/web/app/api/support/conversation/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";

import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans";
import type { Contact } from "@calcom/features/ee/support/lib/intercom/intercom";
import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";

import { buildLegacyRequest } from "@lib/buildLegacyCtx";

Expand Down Expand Up @@ -43,6 +45,11 @@ export async function POST(req: NextRequest) {

const { user } = session;

const membershipRepository = new MembershipRepository(prisma);
const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId: user.id });
const billingPlanService = new BillingPlanService();
const plan = await billingPlanService.getUserPlanByMemberships(memberships);

if (!existingContact.data) {
const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id });
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
Expand Down Expand Up @@ -72,6 +79,7 @@ export async function POST(req: NextRequest) {
sum_of_teams: additionalUserInfo?._count?.teams,
sum_of_event_types: additionalUserInfo?._count?.eventTypes,
sum_of_team_event_types: sumOfTeamEventTypes,
Plan: plan,
},
});

Expand Down
23 changes: 23 additions & 0 deletions packages/features/ee/billing/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,26 @@ export const CHECKOUT_SESSION_TYPES = {
} as const;

export type CheckoutSessionType = (typeof CHECKOUT_SESSION_TYPES)[keyof typeof CHECKOUT_SESSION_TYPES];

export enum BillingPlan {
INDIVIDUALS = "INDIVIDUALS",
TEAMS = "TEAMS",
ORGANIZATIONS = "ORGANIZATIONS",
ENTERPRISE = "ENTERPRISE",
PLATFORM_STARTER = "PLATFORM_STARTER",
PLATFORM_ESSENTIALS = "PLATFORM_ESSENTIALS",
PLATFORM_SCALE = "PLATFORM_SCALE",
PLATFORM_ENTERPRISE = "PLATFORM_ENTERPRISE",
UNKNOWN = "Unknown",
}

export const PLATFORM_PLANS_MAP: Record<string, BillingPlan> = {
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(",") ?? [];
79 changes: 79 additions & 0 deletions packages/features/ee/billing/domain/billing-plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {
BillingPlan,
ENTERPRISE_SLUGS,
PLATFORM_ENTERPRISE_SLUGS,
PLATFORM_PLANS_MAP,
} from "@calcom/features/ee/billing/constants";
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
import type { JsonValue } from "@calcom/types/Json";

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

for (const { team, user } of memberships) {
if (team.isPlatform || user.isPlatformManaged) {
if (PLATFORM_ENTERPRISE_SLUGS.includes(team.slug ?? "")) return BillingPlan.PLATFORM_ENTERPRISE;
if (!team.platformBilling) continue;

return PLATFORM_PLANS_MAP[team.platformBilling.plan] ?? team.platformBilling.plan;
} else {
Comment on lines +40 to +41
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

Keep return type strictly BillingPlan (don’t return raw strings)

Fallback currently returns a string which breaks type guarantees and can leak unknown values downstream.

Apply this diff:

-        return PLATFORM_PLANS_MAP[team.platformBilling.plan] ?? team.platformBilling.plan;
+        const planKey = team.platformBilling.plan?.toUpperCase?.() ?? "";
+        return PLATFORM_PLANS_MAP[planKey] ?? BillingPlan.UNKNOWN;
📝 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
return PLATFORM_PLANS_MAP[team.platformBilling.plan] ?? team.platformBilling.plan;
} else {
const planKey = team.platformBilling.plan?.toUpperCase?.() ?? "";
return PLATFORM_PLANS_MAP[planKey] ?? BillingPlan.UNKNOWN;
} else {
🤖 Prompt for AI Agents
In packages/features/ee/billing/domain/billing-plans.ts around lines 40-41, the
fallback currently returns the raw string team.platformBilling.plan which breaks
the function's BillingPlan return type; replace the raw-string fallback with a
strict BillingPlan value by adding a small type-guard isBillingPlan(value: any):
value is BillingPlan that checks Object.values(BillingPlan).includes(value),
then return PLATFORM_PLANS_MAP[team.platformBilling.plan] if defined, otherwise
if isBillingPlan(team.platformBilling.plan) return team.platformBilling.plan,
else throw a descriptive error (or return an explicit safe BillingPlan default
if your codebase prefers) so the function never returns an untyped string.

let teamMetadata;
try {
teamMetadata = teamMetadataStrictSchema.parse(team.metadata ?? {});
} catch {
teamMetadata = null;
}

let parentTeamMetadata;
try {
parentTeamMetadata = teamMetadataStrictSchema.parse(team.parent?.metadata ?? {});
} catch {
parentTeamMetadata = null;
}

if (
team.parent &&
team.parent.isOrganization &&
parentTeamMetadata?.subscriptionId &&
!team.parent.isPlatform
) {
return ENTERPRISE_SLUGS.includes(team.parent.slug ?? "")
? BillingPlan.ENTERPRISE
: BillingPlan.ORGANIZATIONS;
}

if (!teamMetadata?.subscriptionId) continue;
if (team.isOrganization) {
return ENTERPRISE_SLUGS.includes(team.slug ?? "")
? BillingPlan.ENTERPRISE
: BillingPlan.ORGANIZATIONS;
} else {
return BillingPlan.TEAMS;
}
}
}
return BillingPlan.UNKNOWN;
}
}
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
38 changes: 36 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,41 @@ export class MembershipRepository {
});
}

async findAllMembershipsByUserIdForBilling({ userId }: { userId: number }) {
return this.prismaClient.membership.findMany({
where: { userId },
select: {
accepted: true,
user: {
select: {
isPlatformManaged: true,
},
},
team: {
select: {
slug: true,
isOrganization: true,
isPlatform: true,
metadata: true,
platformBilling: {
select: {
plan: true,
},
},
parent: {
select: {
isOrganization: true,
slug: true,
metadata: true,
isPlatform: true,
},
},
},
},
},
});
}

static async findByTeamIdForAvailability({ teamId }: { teamId: number }) {
const memberships = await prisma.membership.findMany({
where: { teamId },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const publishHandler = async ({ ctx }: PublishOptions) => {
const { requestedSlug, ...newMetadata } = metadata.data;
let updatedTeam: Awaited<ReturnType<typeof prisma.team.update>>;


try {
updatedTeam = await prisma.team.update({
where: { id: orgId },
Expand Down
12 changes: 10 additions & 2 deletions packages/trpc/server/routers/viewer/teams/hasTeamPlan.handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { prisma } from "@calcom/prisma";

type HasTeamPlanOptions = {
ctx: {
Expand All @@ -9,9 +11,15 @@ type HasTeamPlanOptions = {
export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => {
const userId = ctx.user.id;

const hasTeamPlan = await MembershipRepository.findFirstAcceptedMembershipByUserId(userId);
const membershipRepository = new MembershipRepository(prisma);
const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId });
const hasTeamPlan = memberships.some(
(membership) => membership.accepted === true && membership.team.slug !== null
);
const billingPlanService = new BillingPlanService();
const plan = await billingPlanService.getUserPlanByMemberships(memberships);

return { hasTeamPlan: !!hasTeamPlan };
return { hasTeamPlan: !!hasTeamPlan, plan };
};

export default hasTeamPlanHandler;
4 changes: 3 additions & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,9 @@
"ATOMS_E2E_APPLE_CONNECT_APP_SPECIFIC_PASSCODE",
"INTERCOM_API_TOKEN",
"NEXT_PUBLIC_INTERCOM_APP_ID",
"_CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS"
"_CAL_INTERNAL_PAST_BOOKING_RESCHEDULE_CHANGE_TEAM_IDS",
"ENTERPRISE_SLUGS",
"PLATFORM_ENTERPRISE_SLUGS"
],
"tasks": {
"@calcom/web#copy-app-store-static": {
Expand Down
Loading