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
This commit is contained in:
@@ -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) }}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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,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}`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
getTrackingFromCookies,
|
||||
type TrackingData,
|
||||
type GoogleAdsTrackingData,
|
||||
type LinkedInAdsTrackingData,
|
||||
} from "./server";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user