From 81224f324ada684cd47a6be44388447b12dea1f2 Mon Sep 17 00:00:00 2001 From: Amit Sharma <74371312+Amit91848@users.noreply.github.com> Date: Tue, 25 Nov 2025 18:28:10 +0530 Subject: [PATCH] feat: google ads conversion tracking (#25198) * 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 --- apps/web/app/layout.tsx | 4 +- apps/web/components/PageWrapperAppDir.tsx | 1 - .../sso/[provider]/getServerSideProps.tsx | 13 +++++-- .../stripepayment/api/subscription.ts | 9 +++++ .../interfaces/AIPhoneService.interface.ts | 3 ++ .../retellAI/RetellAIPhoneServiceProvider.ts | 3 ++ .../providers/retellAI/RetellAIService.ts | 3 ++ .../retellAI/services/BillingService.ts | 21 ++++++---- .../webhook/_checkout.session.completed.ts | 28 +++++++++++++ .../settings/other-team-profile-view.tsx | 25 ------------ packages/features/ee/teams/lib/payments.ts | 27 ++++++++----- packages/lib/cookie.ts | 7 ++++ packages/lib/tracking/index.ts | 7 ++++ packages/lib/tracking/server.ts | 39 +++++++++++++++++++ .../routers/viewer/organizations/_router.tsx | 5 ++- .../viewer/organizations/publish.handler.ts | 10 ++++- .../routers/viewer/phoneNumber/_router.ts | 4 +- .../routers/viewer/phoneNumber/buy.handler.ts | 6 +++ .../server/routers/viewer/teams/_router.tsx | 9 ++--- .../routers/viewer/teams/create.handler.ts | 11 ++++++ .../routers/viewer/teams/publish.handler.ts | 38 +----------------- turbo.json | 3 ++ 22 files changed, 181 insertions(+), 95 deletions(-) create mode 100644 packages/lib/tracking/index.ts create mode 100644 packages/lib/tracking/server.ts diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 4a70b27a3a..3683e26aa8 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -115,8 +115,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo diff --git a/apps/web/components/PageWrapperAppDir.tsx b/apps/web/components/PageWrapperAppDir.tsx index 64c6d11155..86cdc7fffc 100644 --- a/apps/web/components/PageWrapperAppDir.tsx +++ b/apps/web/components/PageWrapperAppDir.tsx @@ -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 ? ( diff --git a/apps/web/server/lib/auth/sso/[provider]/getServerSideProps.tsx b/apps/web/server/lib/auth/sso/[provider]/getServerSideProps.tsx index 8dc8a6395d..095c3f22d3 100644 --- a/apps/web/server/lib/auth/sso/[provider]/getServerSideProps.tsx +++ b/apps/web/server/lib/auth/sso/[provider]/getServerSideProps.tsx @@ -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 => { // @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 }), }, }); diff --git a/packages/app-store/stripepayment/api/subscription.ts b/packages/app-store/stripepayment/api/subscription.ts index 07914b8535..8b9bea5083 100644 --- a/packages/app-store/stripepayment/api/subscription.ts +++ b/packages/app-store/stripepayment/api/subscription.ts @@ -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); diff --git a/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts b/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts index 67c1010a32..6751e5fc17 100644 --- a/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts +++ b/packages/features/calAIPhone/interfaces/AIPhoneService.interface.ts @@ -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; /** diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts index 44294ebf16..23d6010d44 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIPhoneServiceProvider.ts @@ -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); } diff --git a/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts b/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts index 7df281dcb6..ec66f004df 100644 --- a/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts +++ b/packages/features/calAIPhone/providers/retellAI/RetellAIService.ts @@ -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); } diff --git a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts index f71b3fb775..abb25f5783 100644 --- a/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts +++ b/packages/features/calAIPhone/providers/retellAI/services/BillingService.ts @@ -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({ diff --git a/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts b/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts index 32b9908858..a8a0c64d2e 100644 --- a/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts +++ b/packages/features/ee/billing/api/webhook/_checkout.session.completed.ts @@ -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); } diff --git a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx index fb6550d266..0f754af683 100644 --- a/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx +++ b/packages/features/ee/organizations/pages/settings/other-team-profile-view.tsx @@ -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 = () => {
diff --git a/packages/features/ee/teams/lib/payments.ts b/packages/features/ee/teams/lib/payments.ts index 1c6129a049..834532ff98 100644 --- a/packages/features/ee/teams/lib/payments.ts +++ b/packages/features/ee/teams/lib/payments.ts @@ -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: { diff --git a/packages/lib/cookie.ts b/packages/lib/cookie.ts index 454a0192e0..de428541eb 100644 --- a/packages/lib/cookie.ts +++ b/packages/lib/cookie.ts @@ -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}`; +}; diff --git a/packages/lib/tracking/index.ts b/packages/lib/tracking/index.ts new file mode 100644 index 0000000000..b01b1fabdf --- /dev/null +++ b/packages/lib/tracking/index.ts @@ -0,0 +1,7 @@ +export { + getTrackingFromCookies, + type TrackingData, + type GoogleAdsTrackingData, + type LinkedInAdsTrackingData, +} from "./server"; + diff --git a/packages/lib/tracking/server.ts b/packages/lib/tracking/server.ts new file mode 100644 index 0000000000..baa6806773 --- /dev/null +++ b/packages/lib/tracking/server.ts @@ -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; +} diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 1e18bf49a0..dc5652c19f 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -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"); diff --git a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts index fb6abecc4c..afa43ac3e5 100644 --- a/packages/trpc/server/routers/viewer/organizations/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/publish.handler.ts @@ -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; + 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>; try { - updatedTeam = await prisma.team.update({ + await prisma.team.update({ where: { id: orgId }, data: { slug: requestedSlug, diff --git a/packages/trpc/server/routers/viewer/phoneNumber/_router.ts b/packages/trpc/server/routers/viewer/phoneNumber/_router.ts index 556767fd00..faa515af8d 100644 --- a/packages/trpc/server/routers/viewer/phoneNumber/_router.ts +++ b/packages/trpc/server/routers/viewer/phoneNumber/_router.ts @@ -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, }); }), diff --git a/packages/trpc/server/routers/viewer/phoneNumber/buy.handler.ts b/packages/trpc/server/routers/viewer/phoneNumber/buy.handler.ts index 5d37674d4f..426cc63156 100644 --- a/packages/trpc/server/routers/viewer/phoneNumber/buy.handler.ts +++ b/packages/trpc/server/routers/viewer/phoneNumber/buy.handler.ts @@ -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; + 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) { diff --git a/packages/trpc/server/routers/viewer/teams/_router.tsx b/packages/trpc/server/routers/viewer/teams/_router.tsx index b199fc5a54..b0dce5f91a 100644 --- a/packages/trpc/server/routers/viewer/teams/_router.tsx +++ b/packages/trpc/server/routers/viewer/teams/_router.tsx @@ -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) => { diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index 735f10e493..df2150a1e1 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -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; + 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. diff --git a/packages/trpc/server/routers/viewer/teams/publish.handler.ts b/packages/trpc/server/routers/viewer/teams/publish.handler.ts index 979e576d23..f16e79a85d 100644 --- a/packages/trpc/server/routers/viewer/teams/publish.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/publish.handler.ts @@ -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(); diff --git a/turbo.json b/turbo.json index 80a0ecd74c..44790bbc4c 100644 --- a/turbo.json +++ b/turbo.json @@ -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" ],