feat: google ads conversion tracking (#25198)
Post release / release (push) Canceled after 0s
Release Docker / Release AMD64 (push) Canceled after 0s
Release Docker / Release ARM (push) Canceled after 0s

* feat: google ads conversion tracking

* gaClientid

* store gclid in stripe metadata

* tracking only in the us

* track google campaign id as well

* rename gclid -> google ads

* fix: build

* fix

* refactor

* fix: type check

* fix: type check

* fix: type check

* fix: type check

* fix: store it in cookie

* refactor

* fix

* cleanup

* linked ads tracking

* refactor checkout session tracking
This commit is contained in:
Amit Sharma
2025-11-25 12:58:10 +00:00
committed by GitHub
parent 700d8e7f30
commit 81224f324a
22 changed files with 181 additions and 95 deletions
+2 -2
View File
@@ -115,8 +115,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<head nonce={nonce}>
<style>{`
:root {
--font-inter: ${interFont.style.fontFamily.replace(/\'/g, "")};
--font-cal: ${calFont.style.fontFamily.replace(/\'/g, "")};
--font-inter: ${interFont.style.fontFamily.replace(/'/g, "")};
--font-cal: ${calFont.style.fontFamily.replace(/'/g, "")};
}
`}</style>
</head>
@@ -46,7 +46,6 @@ function PageWrapper(props: PageWrapperProps) {
id="page-status"
// It is strictly not necessary to disable, but in a future update of react/no-danger this will error.
// And we don't want it to error here anyways
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
/>
{props.requiresLicense ? (
@@ -9,6 +9,7 @@ import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
import { checkUsername } from "@calcom/features/profile/lib/checkUsername";
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import { getTrackingFromCookies, type TrackingData } from "@calcom/lib/tracking";
import { prisma } from "@calcom/prisma";
import { z } from "zod";
@@ -20,9 +21,9 @@ const Params = z.object({
export const getServerSideProps = async ({ req, query }: GetServerSidePropsContext) => {
const {
provider: providerParam,
email: emailParam,
const {
provider: providerParam,
email: emailParam,
username: usernameParam,
} = Params.parse(query);
@@ -40,11 +41,13 @@ export const getServerSideProps = async ({ req, query }: GetServerSidePropsConte
if (usernameParam && session.user.email) {
const availability = await checkUsername(usernameParam, currentOrgDomain);
if (availability.available && availability.premium && IS_PREMIUM_USERNAME_ENABLED) {
const tracking = getTrackingFromCookies(req.cookies);
const stripePremiumUrl = await getStripePremiumUsernameUrl({
userId: session.user.id.toString(),
userEmail: session.user.email,
username: usernameParam,
successDestination,
tracking,
});
if (stripePremiumUrl) {
return {
@@ -112,6 +115,7 @@ type GetStripePremiumUsernameUrl = {
userEmail: string;
username: string;
successDestination: string;
tracking?: TrackingData;
};
const getStripePremiumUsernameUrl = async ({
@@ -119,6 +123,7 @@ const getStripePremiumUsernameUrl = async ({
userEmail,
username,
successDestination,
tracking,
}: GetStripePremiumUsernameUrl): Promise<string | null> => {
// @TODO: probably want to check if stripe user email already exists? or not
const customer = await stripe.customers.create({
@@ -143,6 +148,8 @@ const getStripePremiumUsernameUrl = async ({
allow_promotion_codes: true,
metadata: {
dubCustomerId: userId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
...(tracking?.googleAds?.gclid && { gclid: tracking.googleAds.gclid, campaignId: tracking.googleAds.campaignId }),
...(tracking?.linkedInAds?.liFatId && { liFatId: tracking.linkedInAds.liFatId, linkedInCampaignId: tracking.linkedInAds?.campaignId }),
},
});
@@ -5,6 +5,7 @@ import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/li
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getTrackingFromCookies } from "@calcom/lib/tracking";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -39,6 +40,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
const tracking = getTrackingFromCookies(req.cookies);
const return_url = `${WEBAPP_URL}/api/integrations/stripepayment/paymentCallback?checkoutSessionId={CHECKOUT_SESSION_ID}&callbackUrl=${callbackUrl}`;
const createSessionParams: Stripe.Checkout.SessionCreateParams = {
mode: "subscription",
@@ -52,6 +55,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
customer: customerId,
success_url: return_url,
cancel_url: return_url,
metadata: {
userId: userId.toString(),
intentUsername,
...(tracking?.googleAds?.gclid && { gclid: tracking.googleAds.gclid, campaignId: tracking.googleAds.campaignId }),
...(tracking?.linkedInAds?.liFatId && { liFatId: tracking.linkedInAds.liFatId, linkedInCampaignId: tracking.linkedInAds?.campaignId }),
},
};
const checkPremiumResult = await checkPremiumUsername(intentUsername);
@@ -1,5 +1,7 @@
import type { Logger } from "tslog";
import type { TrackingData } from "@calcom/lib/tracking";
import type { RetellAIPhoneServiceProviderTypeMap } from "../providers/retellAI";
/**
@@ -243,6 +245,7 @@ export interface AIPhoneServiceProvider<T extends AIPhoneServiceProviderType = A
teamId?: number;
agentId?: string | null;
workflowId?: string;
tracking?: TrackingData;
}): Promise<{ url: string; message: string }>;
/**
@@ -1,3 +1,5 @@
import type { TrackingData } from "@calcom/lib/tracking";
import type {
AIPhoneServiceProvider,
AIPhoneServiceProviderType,
@@ -169,6 +171,7 @@ export class RetellAIPhoneServiceProvider
teamId?: number;
agentId?: string | null;
workflowId?: string;
tracking?: TrackingData;
}): Promise<{ url: string; message: string }> {
return await this.service.generatePhoneNumberCheckoutSession(params);
}
@@ -1,3 +1,5 @@
import type { TrackingData } from "@calcom/lib/tracking";
import type {
AIPhoneServiceUpdateModelParams,
AIPhoneServiceCreatePhoneNumberParams,
@@ -265,6 +267,7 @@ export class RetellAIService {
teamId?: number;
agentId?: string | null;
workflowId?: string;
tracking?: TrackingData;
}) {
return this.billingService.generatePhoneNumberCheckoutSession(params);
}
@@ -7,6 +7,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
import { WEBAPP_URL, IS_PRODUCTION } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import type { TrackingData } from "@calcom/lib/tracking";
import { PhoneNumberSubscriptionStatus } from "@calcom/prisma/enums";
import type { PhoneNumberRepositoryInterface } from "../../interfaces/PhoneNumberRepositoryInterface";
@@ -25,18 +26,20 @@ export class BillingService {
phoneNumberRepository: PhoneNumberRepositoryInterface;
retellRepository: RetellAIRepository;
}
) {}
) { }
async generatePhoneNumberCheckoutSession({
userId,
teamId,
agentId,
workflowId,
tracking,
}: {
userId: number;
teamId?: number;
agentId?: string | null;
workflowId?: string;
tracking?: TrackingData;
}) {
const phoneNumberPriceId = getPhoneNumberMonthlyPriceId();
@@ -80,6 +83,8 @@ export class BillingService {
agentId: agentId || "",
workflowId: workflowId || "",
type: CHECKOUT_SESSION_TYPES.PHONE_NUMBER_SUBSCRIPTION,
...(tracking?.googleAds?.gclid && { gclid: tracking.googleAds.gclid, campaignId: tracking.googleAds.campaignId }),
...(tracking?.linkedInAds?.liFatId && { liFatId: tracking.linkedInAds.liFatId, linkedInCampaignId: tracking.linkedInAds?.campaignId }),
},
subscription_data: {
metadata: {
@@ -114,14 +119,14 @@ export class BillingService {
// Find phone number with proper team authorization
const phoneNumber = teamId
? await this.deps.phoneNumberRepository.findByIdWithTeamAccess({
id: phoneNumberId,
teamId,
userId,
})
id: phoneNumberId,
teamId,
userId,
})
: await this.deps.phoneNumberRepository.findByIdAndUserId({
id: phoneNumberId,
userId,
});
id: phoneNumberId,
userId,
});
if (!phoneNumber) {
throw new HttpError({
@@ -1,5 +1,6 @@
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
import stripe from "@calcom/features/ee/payments/server/stripe";
import logger from "@calcom/lib/logger";
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import { CreditsRepository } from "@calcom/lib/server/repository/credits";
@@ -10,9 +11,36 @@ import { CHECKOUT_SESSION_TYPES } from "../../constants";
import type { SWHMap } from "./__handler";
import { HttpCode } from "./__handler";
const log = logger.getSubLogger({ prefix: ["checkout.session.completed"] });
const handler = async (data: SWHMap["checkout.session.completed"]["data"]) => {
const session = data.object;
// Store ad tracking data in Stripe customer metadata for Zapier tracking
if (session.customer && session.metadata) {
try {
const trackingMetadata = {
gclid: session.metadata?.gclid,
campaignId: session.metadata?.campaignId,
liFatId: session.metadata?.liFatId,
linkedInCampaignId: session.metadata?.linkedInCampaignId,
};
const cleanedMetadata = Object.fromEntries(
Object.entries(trackingMetadata).filter(([_, value]) => value)
);
if (Object.keys(cleanedMetadata).length > 0) {
const customerId = typeof session.customer === "string" ? session.customer : session.customer.id;
await stripe.customers.update(customerId, {
metadata: cleanedMetadata,
});
}
} catch (error) {
log.error("Failed to update Stripe customer metadata with ad tracking data", { error });
}
}
if (session.metadata?.type === CHECKOUT_SESSION_TYPES.PHONE_NUMBER_SUBSCRIPTION) {
return await handleCalAIPhoneNumberSubscription(session);
}
@@ -1,7 +1,6 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useLayoutEffect, useState } from "react";
@@ -32,7 +31,6 @@ import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
import { subdomainSuffix } from "../../../organizations/lib/orgDomains";
@@ -54,7 +52,6 @@ const OtherTeamProfileView = () => {
const { t } = useLocale();
const router = useRouter();
const utils = trpc.useUtils();
const session = useSession();
const [firstRender, setFirstRender] = useState(true);
useLayoutEffect(() => {
@@ -133,19 +130,6 @@ const OtherTeamProfileView = () => {
},
});
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
async onSuccess() {
await utils.viewer.teams.get.invalidate();
await utils.viewer.teams.list.invalidate();
revalidateTeamsList();
await utils.viewer.eventTypes.invalidate();
showToast(t("success"), "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
const publishMutation = trpc.viewer.teams.publish.useMutation({
async onSuccess(data: { url?: string }) {
if (data.url) {
@@ -161,14 +145,6 @@ const OtherTeamProfileView = () => {
if (team?.id) deleteTeamMutation.mutate({ teamId: team.id });
}
function leaveTeam() {
if (team?.id && session.data)
removeMemberMutation.mutate({
teamIds: [team.id],
memberIds: [session.data.user.id],
});
}
if (!team) return null;
return (
@@ -291,7 +267,6 @@ const OtherTeamProfileView = () => {
<Label className="text-emphasis mt-5">{t("about")}</Label>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(team.bio) }}
/>
</>
+18 -9
View File
@@ -17,6 +17,7 @@ import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import { BillingPeriod } from "@calcom/prisma/zod-utils";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TrackingData } from "@calcom/lib/tracking";
const log = logger.getSubLogger({ prefix: ["teams/lib/payments"] });
const teamPaymentMetadataSchema = z.object({
@@ -51,11 +52,13 @@ export const generateTeamCheckoutSession = async ({
teamSlug,
userId,
isOnboarding,
tracking,
}: {
teamName: string;
teamSlug: string;
userId: number;
isOnboarding?: boolean;
tracking?: TrackingData;
}) => {
const [customer, dubCustomer] = await Promise.all([
getStripeCustomerIdFromUserId(userId),
@@ -66,15 +69,15 @@ export const generateTeamCheckoutSession = async ({
mode: "subscription",
...(dubCustomer?.discount?.couponId
? {
discounts: [
{
coupon:
process.env.NODE_ENV !== "production" && dubCustomer.discount.couponTestId
? dubCustomer.discount.couponTestId
: dubCustomer.discount.couponId,
},
],
}
discounts: [
{
coupon:
process.env.NODE_ENV !== "production" && dubCustomer.discount.couponTestId
? dubCustomer.discount.couponTestId
: dubCustomer.discount.couponId,
},
],
}
: { allow_promotion_codes: true }),
success_url: `${WEBAPP_URL}/api/teams/create?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${WEBAPP_URL}/settings/my-account/profile`,
@@ -102,6 +105,8 @@ export const generateTeamCheckoutSession = async ({
userId,
dubCustomerId: userId, // pass the userId during checkout creation for sales conversion tracking: https://d.to/conversions/stripe
...(isOnboarding !== undefined && { isOnboarding: isOnboarding.toString() }),
...(tracking?.googleAds?.gclid && { gclid: tracking.googleAds.gclid, campaignId: tracking.googleAds?.campaignId }),
...(tracking?.linkedInAds?.liFatId && { liFatId: tracking.linkedInAds.liFatId, linkedInCampaignId: tracking.linkedInAds?.campaignId }),
},
});
return session;
@@ -127,6 +132,7 @@ export const purchaseTeamOrOrgSubscription = async (input: {
isOrg?: boolean;
pricePerSeat: number | null;
billingPeriod?: BillingPeriod;
tracking?: TrackingData;
}) => {
const {
teamId,
@@ -136,6 +142,7 @@ export const purchaseTeamOrOrgSubscription = async (input: {
isOrg,
pricePerSeat,
billingPeriod = BillingPeriod.MONTHLY,
tracking,
} = input;
const { url } = await checkIfTeamPaymentRequired({ teamId });
if (url) return { url };
@@ -193,6 +200,8 @@ export const purchaseTeamOrOrgSubscription = async (input: {
},
metadata: {
teamId,
...(tracking?.googleAds?.gclid && { gclid: tracking.googleAds.gclid, campaignId: tracking.googleAds.campaignId }),
...(tracking?.linkedInAds?.liFatId && { liFatId: tracking.linkedInAds.liFatId, linkedInCampaignId: tracking.linkedInAds?.campaignId }),
},
subscription_data: {
metadata: {
+7
View File
@@ -7,3 +7,10 @@ export const getCookie = (name: string) => {
.find((cookie) => cookie.trim().startsWith(`${name}=`))
?.split("=")[1];
};
export const setCookie = (name: string, value: string, options: string) => {
if (typeof document === "undefined") {
return;
}
document.cookie = `${name}=${value}; ${options}`;
};
+7
View File
@@ -0,0 +1,7 @@
export {
getTrackingFromCookies,
type TrackingData,
type GoogleAdsTrackingData,
type LinkedInAdsTrackingData,
} from "./server";
+39
View File
@@ -0,0 +1,39 @@
import type { NextApiRequest } from "next";
export type GoogleAdsTrackingData = {
gclid: string;
campaignId?: string;
};
export type LinkedInAdsTrackingData = {
liFatId: string;
campaignId?: string;
};
export type TrackingData = {
googleAds?: GoogleAdsTrackingData;
linkedInAds?: LinkedInAdsTrackingData;
};
export function getTrackingFromCookies(cookies?: NextApiRequest["cookies"]): TrackingData {
const tracking: TrackingData = {};
if (!cookies) return tracking;
if (process.env.GOOGLE_ADS_ENABLED === "1" && cookies.gclid) {
tracking.googleAds = {
gclid: cookies.gclid,
...(cookies.gad_campaignid && { campaignId: cookies.gad_campaignid }),
};
}
if (process.env.LINKEDIN_ADS_ENABLED === "1" && cookies.li_fat_id) {
tracking.linkedInAds = {
liFatId: cookies.li_fat_id,
...(cookies.li_campaignid && { campaignId: cookies.li_campaignid }),
};
}
return tracking;
}
@@ -1,3 +1,4 @@
import type { NextApiRequest } from "next";
import { createPhoneCallSchema } from "@calcom/features/calAIPhone/zod-utils";
import { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
@@ -77,9 +78,9 @@ export const viewerOrganizationsRouter = router({
const { default: handler } = await import("./checkIfOrgNeedsUpgrade.handler");
return handler(opts);
}),
publish: authedProcedure.mutation(async (opts) => {
publish: authedProcedure.mutation(async ({ ctx }) => {
const { default: handler } = await import("./publish.handler");
return handler(opts);
return handler({ ctx: { ...ctx, req: ctx.req as NextApiRequest } });
}),
setPassword: authedProcedure.input(ZSetPasswordSchema).mutation(async (opts) => {
const { default: handler } = await import("./setPassword.handler");
@@ -1,7 +1,10 @@
import type { NextApiRequest } from "next";
import { getRequestedSlugError } from "@calcom/app-store/stripepayment/lib/team-billing";
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { getTrackingFromCookies } from "@calcom/lib/tracking";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
@@ -13,6 +16,7 @@ import type { TrpcSessionUser } from "../../../types";
type PublishOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
req?: NextApiRequest;
};
};
@@ -51,6 +55,8 @@ export const publishHandler = async ({ ctx }: PublishOptions) => {
// Since this is an ORG we need to make sure ORG members are scyned with the team. Every time a user is added to the TEAM, we need to add them to the ORG
if (IS_TEAM_BILLING_ENABLED) {
const tracking = getTrackingFromCookies(ctx.req?.cookies);
const checkoutSession = await purchaseTeamOrOrgSubscription({
teamId: prevTeam.id,
seatsUsed: prevTeam.members.length,
@@ -61,6 +67,7 @@ export const publishHandler = async ({ ctx }: PublishOptions) => {
isOrg: true,
pricePerSeat: metadata.data?.orgPricePerSeat ?? null,
billingPeriod: metadata.data?.billingPeriod ?? undefined,
tracking,
});
if (!checkoutSession.url)
@@ -79,10 +86,9 @@ 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({
await prisma.team.update({
where: { id: orgId },
data: {
slug: requestedSlug,
@@ -1,3 +1,5 @@
import type { NextApiRequest } from "next";
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZBuyInputSchema } from "./buy.schema";
@@ -21,7 +23,7 @@ export const phoneNumberRouter = router({
const { buyHandler } = await import("./buy.handler");
return buyHandler({
ctx,
ctx: { ...ctx, req: ctx.req as NextApiRequest },
input,
});
}),
@@ -1,4 +1,7 @@
import type { NextApiRequest } from "next";
import { createDefaultAIPhoneServiceProvider } from "@calcom/features/calAIPhone";
import { getTrackingFromCookies } from "@calcom/lib/tracking";
import { TRPCError } from "@trpc/server";
@@ -8,6 +11,7 @@ import type { TBuyInputSchema } from "./buy.schema";
type BuyHandlerOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
req?: NextApiRequest;
};
input: TBuyInputSchema;
};
@@ -15,12 +19,14 @@ type BuyHandlerOptions = {
export const buyHandler = async ({ ctx, input }: BuyHandlerOptions) => {
const userId = ctx.user.id;
const aiService = createDefaultAIPhoneServiceProvider();
const tracking = getTrackingFromCookies(ctx.req?.cookies);
const checkoutSession = await aiService.generatePhoneNumberCheckoutSession({
userId,
teamId: input?.teamId ?? undefined,
agentId: input.agentId,
workflowId: input.workflowId,
tracking,
});
if (checkoutSession) {
@@ -1,3 +1,5 @@
import type { NextApiRequest } from "next";
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZAcceptOrLeaveInputSchema } from "./acceptOrLeave.schema";
@@ -34,9 +36,6 @@ import { ZUpdateInputSchema } from "./update.schema";
import { ZUpdateInternalNotesPresetsInputSchema } from "./updateInternalNotesPresets.schema";
import { ZUpdateMembershipInputSchema } from "./updateMembership.schema";
const NAMESPACE = "teams";
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
export const viewerTeamsRouter = router({
// Retrieves team by id
get: authedProcedure.input(ZGetSchema).query(async (opts) => {
@@ -53,9 +52,9 @@ export const viewerTeamsRouter = router({
const { default: handler } = await import("./list.handler");
return handler(opts);
}),
create: authedProcedure.input(ZCreateInputSchema).mutation(async (opts) => {
create: authedProcedure.input(ZCreateInputSchema).mutation(async ({ ctx, input }) => {
const { default: handler } = await import("./create.handler");
return handler(opts);
return handler({ ctx: { ...ctx, req: ctx.req as NextApiRequest }, input });
}),
// Allows team owner to update team metadata
update: authedProcedure.input(ZUpdateInputSchema).mutation(async (opts) => {
@@ -1,8 +1,12 @@
import type { NextApiRequest } from "next";
import { generateTeamCheckoutSession } from "@calcom/features/ee/teams/lib/payments";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { uploadLogo } from "@calcom/lib/server/avatar";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import { getTrackingFromCookies } from "@calcom/lib/tracking";
import type { TrackingData } from "@calcom/lib/tracking";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -14,6 +18,7 @@ import type { TCreateInputSchema } from "./create.schema";
type CreateOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
req?: NextApiRequest;
};
input: TCreateInputSchema;
};
@@ -23,11 +28,13 @@ const generateCheckoutSession = async ({
teamName,
userId,
isOnboarding,
tracking,
}: {
teamSlug: string;
teamName: string;
userId: number;
isOnboarding?: boolean;
tracking?: TrackingData;
}) => {
if (!IS_TEAM_BILLING_ENABLED) {
console.info("Team billing is disabled, not generating a checkout session.");
@@ -39,6 +46,7 @@ const generateCheckoutSession = async ({
teamName,
userId,
isOnboarding,
tracking,
});
if (!checkoutSession.url)
@@ -79,11 +87,14 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => {
// If the user is not a part of an org, then make them pay before creating the team
if (!isOrgChildTeam) {
const tracking = getTrackingFromCookies(ctx.req?.cookies);
const checkoutSession = await generateCheckoutSession({
teamSlug: slug,
teamName: name,
userId: user.id,
isOnboarding,
tracking,
});
// If there is a checkout session, return it. Otherwise, it means it's disabled.
@@ -1,11 +1,8 @@
import { purchaseTeamOrOrgSubscription } from "@calcom/features/ee/teams/lib/payments";
import { TeamService } from "@calcom/features/ee/teams/services/teamService";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { Redirect } from "@calcom/lib/redirect";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@trpc/server";
@@ -19,39 +16,6 @@ type PublishOptions = {
input: TPublishInputSchema;
};
const parseMetadataOrThrow = (metadata: Prisma.JsonValue) => {
const parsedMetadata = teamMetadataSchema.safeParse(metadata);
if (!parsedMetadata.success || !parsedMetadata.data)
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid team metadata" });
return parsedMetadata.data;
};
const generateCheckoutSession = async ({
teamId,
seats,
userId,
}: {
teamId: number;
seats: number;
userId: number;
}) => {
if (!IS_TEAM_BILLING_ENABLED) return;
const checkoutSession = await purchaseTeamOrOrgSubscription({
teamId,
seatsUsed: seats,
userId,
pricePerSeat: null,
});
if (!checkoutSession.url)
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed retrieving a checkout session URL.",
});
return { url: checkoutSession.url, message: "Payment required to publish team" };
};
async function checkPermissions({ ctx, input }: PublishOptions) {
const { profile } = ctx.user;
const permissionCheckService = new PermissionCheckService();
+3
View File
@@ -284,6 +284,9 @@
"ENTERPRISE_SLUGS",
"PLATFORM_ENTERPRISE_SLUGS",
"USE_POOL",
"STRIPE_ORG_PRODUCT_ID",
"GOOGLE_ADS_ENABLED",
"LINKEDIN_ADS_ENABLED",
"SEED_PLATFORM_OAUTH_CLIENT_ID",
"SEED_PLATFORM_OAUTH_CLIENT_SECRET"
],