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:
co-authored by
amit@cal.com
Keith Williams
Devin AI
parent
752cfa6bf9
commit
2ed0380e4f
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user