feat: adds user plan info in useHasPaidPlan for intercom (#23790)

* feat: adds user plan info in `useHasPaidPlan` for intercom

* add to support api route

* Update constants.ts

* sql migration to backfill plans and create/change plans on upgrade/downgrade/create of teams and orgs

* fix: breaking unit tests

* test: add comprehensive tests for billing plan service and team/org flows

- Add unit tests for BillingPlanService.getUserPlanByMemberships() covering all plan determination scenarios
- Add tests for team creation handler verifying TEAMS vs ORGANIZATIONS plan assignment
- Add tests for hasTeamPlan handler integration with BillingPlanService
- Add tests for MembershipRepository.findAllMembershipsByUserIdForBilling() data fetching
- Add tests for InternalTeamBilling upgrade/downgrade flows with proper mocking
- All tests follow existing vitest patterns with proper Prisma and service mocking
- Covers both self-serve and platform billing scenarios with comprehensive edge cases

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

* Revert "test: add comprehensive tests for billing plan service and team/org flows"

This reverts commit 58e511f15caf8757c3ec45f6d026caf96ee1a75e.

* fix: make `BillingPlanService` instantiable and use `TeamRepository`

* Revert "fix: make `BillingPlanService` instantiable and use `TeamRepository`"

This reverts commit ae1ff8f15b725566b828864a217d8d0e308b520f.

* revert to runtime calculations. review fixes

* remove uneccessary changes and logs

* review fixes

* review fixes

* fix: type check

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Amit Sharma
2025-09-25 13:00:19 +05:30
committed by GitHub
co-authored by amit@cal.com Keith Williams Devin AI
parent 752cfa6bf9
commit 2ed0380e4f
9 changed files with 167 additions and 10 deletions
@@ -3,13 +3,15 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans";
import type { Contact } from "@calcom/features/ee/support/lib/intercom/intercom";
import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -43,6 +45,11 @@ export async function POST(req: NextRequest) {
const { user } = session;
const membershipRepository = new MembershipRepository(prisma);
const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId: user.id });
const billingPlanService = new BillingPlanService();
const plan = await billingPlanService.getUserPlanByMemberships(memberships);
if (!existingContact.data) {
const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id });
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
@@ -72,6 +79,7 @@ export async function POST(req: NextRequest) {
sum_of_teams: additionalUserInfo?._count?.teams,
sum_of_event_types: additionalUserInfo?._count?.eventTypes,
sum_of_team_event_types: sumOfTeamEventTypes,
Plan: plan,
},
});
+23
View File
@@ -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(",") ?? [];
@@ -0,0 +1,79 @@
import {
BillingPlan,
ENTERPRISE_SLUGS,
PLATFORM_ENTERPRISE_SLUGS,
PLATFORM_PLANS_MAP,
} from "@calcom/features/ee/billing/constants";
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
import type { JsonValue } from "@calcom/types/Json";
export class BillingPlanService {
async getUserPlanByMemberships(
memberships: {
team: {
isOrganization: boolean;
isPlatform: boolean;
slug: string | null;
metadata: JsonValue;
parent: {
isOrganization: boolean;
slug: string | null;
isPlatform: boolean;
metadata: JsonValue;
} | null;
platformBilling: {
plan: string;
} | null;
};
user: {
isPlatformManaged: boolean;
};
}[]
) {
if (memberships.length === 0) return BillingPlan.INDIVIDUALS;
for (const { team, user } of memberships) {
if (team.isPlatform || user.isPlatformManaged) {
if (PLATFORM_ENTERPRISE_SLUGS.includes(team.slug ?? "")) return BillingPlan.PLATFORM_ENTERPRISE;
if (!team.platformBilling) continue;
return PLATFORM_PLANS_MAP[team.platformBilling.plan] ?? team.platformBilling.plan;
} else {
let teamMetadata;
try {
teamMetadata = teamMetadataStrictSchema.parse(team.metadata ?? {});
} catch {
teamMetadata = null;
}
let parentTeamMetadata;
try {
parentTeamMetadata = teamMetadataStrictSchema.parse(team.parent?.metadata ?? {});
} catch {
parentTeamMetadata = null;
}
if (
team.parent &&
team.parent.isOrganization &&
parentTeamMetadata?.subscriptionId &&
!team.parent.isPlatform
) {
return ENTERPRISE_SLUGS.includes(team.parent.slug ?? "")
? BillingPlan.ENTERPRISE
: BillingPlan.ORGANIZATIONS;
}
if (!teamMetadata?.subscriptionId) continue;
if (team.isOrganization) {
return ENTERPRISE_SLUGS.includes(team.slug ?? "")
? BillingPlan.ENTERPRISE
: BillingPlan.ORGANIZATIONS;
} else {
return BillingPlan.TEAMS;
}
}
}
return BillingPlan.UNKNOWN;
}
}
@@ -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();
+3 -3
View File
@@ -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() {
+36 -2
View File
@@ -1,7 +1,6 @@
import { availabilityUserSelect, prisma, type PrismaTransaction } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { Prisma, Membership, PrismaClient } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import logger from "../../logger";
@@ -315,6 +314,41 @@ export class MembershipRepository {
});
}
async findAllMembershipsByUserIdForBilling({ userId }: { userId: number }) {
return this.prismaClient.membership.findMany({
where: { userId },
select: {
accepted: true,
user: {
select: {
isPlatformManaged: true,
},
},
team: {
select: {
slug: true,
isOrganization: true,
isPlatform: true,
metadata: true,
platformBilling: {
select: {
plan: true,
},
},
parent: {
select: {
isOrganization: true,
slug: true,
metadata: true,
isPlatform: true,
},
},
},
},
},
});
}
static async findByTeamIdForAvailability({ teamId }: { teamId: number }) {
const memberships = await prisma.membership.findMany({
where: { teamId },
@@ -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 },
@@ -1,4 +1,6 @@
import { BillingPlanService } from "@calcom/features/ee/billing/domain/billing-plans";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import { prisma } from "@calcom/prisma";
type HasTeamPlanOptions = {
ctx: {
@@ -9,9 +11,15 @@ type HasTeamPlanOptions = {
export const hasTeamPlanHandler = async ({ ctx }: HasTeamPlanOptions) => {
const userId = ctx.user.id;
const hasTeamPlan = await MembershipRepository.findFirstAcceptedMembershipByUserId(userId);
const membershipRepository = new MembershipRepository(prisma);
const memberships = await membershipRepository.findAllMembershipsByUserIdForBilling({ userId });
const hasTeamPlan = memberships.some(
(membership) => membership.accepted === true && membership.team.slug !== null
);
const billingPlanService = new BillingPlanService();
const plan = await billingPlanService.getUserPlanByMemberships(memberships);
return { hasTeamPlan: !!hasTeamPlan };
return { hasTeamPlan: !!hasTeamPlan, plan };
};
export default hasTeamPlanHandler;
+3 -1
View File
@@ -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": {