diff --git a/apps/web/pages/api/teams/create.ts b/apps/web/pages/api/teams/create.ts new file mode 100644 index 0000000000..b0c8935839 --- /dev/null +++ b/apps/web/pages/api/teams/create.ts @@ -0,0 +1,92 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import type Stripe from "stripe"; +import { z } from "zod"; + +import stripe from "@calcom/features/ee/payments/server/stripe"; +import { HttpError } from "@calcom/lib/http-error"; +import { defaultHandler, defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +const querySchema = z.object({ + session_id: z.string().min(1), +}); + +const checkoutSessionMetadataSchema = z.object({ + teamName: z.string(), + teamSlug: z.string(), + userId: z.string().transform(Number), +}); + +const generateRandomString = () => { + return Math.random().toString(36).substring(2, 10); +}; + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { session_id } = querySchema.parse(req.query); + + const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, { + expand: ["subscription"], + }); + if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" }); + + const subscription = checkoutSession.subscription as Stripe.Subscription; + + if (checkoutSession.payment_status !== "paid") + throw new HttpError({ statusCode: 402, message: "Payment required" }); + + // Let's query to ensure that the team metadata carried over from the checkout session. + const parseCheckoutSessionMetadata = checkoutSessionMetadataSchema.safeParse(checkoutSession.metadata); + + if (!parseCheckoutSessionMetadata.success) { + console.error( + "Team metadata not found in checkout session", + parseCheckoutSessionMetadata.error, + checkoutSession.id + ); + } + + if (!checkoutSession.metadata?.userId) { + throw new HttpError({ + statusCode: 400, + message: "Can't publish team/org without userId", + }); + } + + const checkoutSessionMetadata = parseCheckoutSessionMetadata.success + ? parseCheckoutSessionMetadata.data + : { + teamName: checkoutSession?.metadata?.teamName ?? generateRandomString(), + teamSlug: checkoutSession?.metadata?.teamSlug ?? generateRandomString(), + userId: checkoutSession.metadata.userId, + }; + + const team = await prisma.team.create({ + data: { + name: checkoutSessionMetadata.teamName, + slug: checkoutSessionMetadata.teamSlug, + members: { + create: { + userId: checkoutSessionMetadata.userId as number, + role: MembershipRole.OWNER, + accepted: true, + }, + }, + metadata: { + paymentId: checkoutSession.id, + subscriptionId: subscription.id || null, + subscriptionItemId: subscription.items.data[0].id || null, + }, + }, + }); + + // Sync Services: Close.com + // closeComUpdateTeam(prevTeam, team); + + // redirect to team screen + res.redirect(302, `/settings/teams/${team.id}/onboard-members?event=team_created`); +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/apps/web/playwright/auth/forgot-password.e2e.ts b/apps/web/playwright/auth/forgot-password.e2e.ts index 0524a538e0..ad4d30aa3a 100644 --- a/apps/web/playwright/auth/forgot-password.e2e.ts +++ b/apps/web/playwright/auth/forgot-password.e2e.ts @@ -24,7 +24,7 @@ test("Can reset forgotten password", async ({ page, users }) => { // there should be one, otherwise we throw const { id } = await prisma.resetPasswordRequest.findFirstOrThrow({ where: { - email: `${user.username}@example.com`, + email: user.email, }, select: { id: true, @@ -37,7 +37,7 @@ test("Can reset forgotten password", async ({ page, users }) => { // Test when a user changes his email after starting the password reset flow await prisma.user.update({ where: { - email: `${user.username}@example.com`, + email: user.email, }, data: { email: `${user.username}-2@example.com`, @@ -54,7 +54,7 @@ test("Can reset forgotten password", async ({ page, users }) => { email: `${user.username}-2@example.com`, }, data: { - email: `${user.username}@example.com`, + email: user.email, }, }); @@ -75,7 +75,7 @@ test("Can reset forgotten password", async ({ page, users }) => { // we're not logging in to the UI to speed up test performance. const updatedUser = await prisma.user.findUniqueOrThrow({ where: { - email: `${user.username}@example.com`, + email: user.email, }, select: { id: true, @@ -84,10 +84,10 @@ test("Can reset forgotten password", async ({ page, users }) => { }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy(); + expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy(); // finally, make sure the same URL cannot be used to reset the password again, as it should be expired. await page.goto(`/auth/forgot-password/${id}`); - await page.waitForSelector("text=That request is expired."); + await expect(page.locator(`text=Whoops`)).toBeVisible(); }); diff --git a/apps/web/playwright/lib/testUtils.ts b/apps/web/playwright/lib/testUtils.ts index 1deefb206f..5bb711d71a 100644 --- a/apps/web/playwright/lib/testUtils.ts +++ b/apps/web/playwright/lib/testUtils.ts @@ -295,3 +295,11 @@ export function generateTotpCode(email: string) { totp.options = { step: 90 }; return totp.generate(secret); } + +export async function fillStripeTestCheckout(page: Page) { + await page.fill("[name=cardNumber]", "4242424242424242"); + await page.fill("[name=cardExpiry]", "12/30"); + await page.fill("[name=cardCvc]", "111"); + await page.fill("[name=billingName]", "Stripe Stripeson"); + await page.click(".SubmitButton--complete-Shimmer"); +} diff --git a/apps/web/playwright/managed-event-types.e2e.ts b/apps/web/playwright/managed-event-types.e2e.ts index a0323ed8b7..3db81e362c 100644 --- a/apps/web/playwright/managed-event-types.e2e.ts +++ b/apps/web/playwright/managed-event-types.e2e.ts @@ -1,9 +1,15 @@ -import { expect } from "@playwright/test"; import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { test } from "./lib/fixtures"; -import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils"; -import { localize } from "./lib/testUtils"; +import { + bookTimeSlot, + fillStripeTestCheckout, + localize, + selectFirstAvailableTimeSlotNextMonth, +} from "./lib/testUtils"; test.afterEach(({ users }) => users.deleteAll()); @@ -21,18 +27,20 @@ test.describe("Managed Event Types tests", () => { await test.step("Managed event option exists for team admin", async () => { // Filling team creation form wizard - await page.locator('input[name="name"]').waitFor(); await page.locator('input[name="name"]').fill(`${adminUser.username}'s Team`); - await page.locator("text=Continue").click(); - await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i); + await page.click("[type=submit]"); + // TODO: Figure out a way to make this more reliable + // eslint-disable-next-line playwright/no-conditional-in-test + if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page); + await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i); await page.getByTestId("new-member-button").click(); await page.locator('[placeholder="email\\@example\\.com"]').fill(`${memberUser.username}@example.com`); await page.getByTestId("invite-new-member-button").click(); // wait for the second member to be added to the pending-member-list. await page.getByTestId("pending-member-list").locator("li:nth-child(2)").waitFor(); // and publish - await page.locator("text=Publish team").click(); - await page.waitForURL("/settings/teams/**"); + await page.locator("[data-testid=publish-button]").click(); + await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i); // Going to create an event type await page.goto("/event-types"); await page.getByTestId("new-event-type").click(); diff --git a/apps/web/playwright/teams.e2e.ts b/apps/web/playwright/teams.e2e.ts index ba9f2960d6..4c8cae0bd5 100644 --- a/apps/web/playwright/teams.e2e.ts +++ b/apps/web/playwright/teams.e2e.ts @@ -1,34 +1,32 @@ import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; +import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { prisma } from "@calcom/prisma"; import { MembershipRole, SchedulingType } from "@calcom/prisma/enums"; import { test } from "./lib/fixtures"; -import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth, testName, todo } from "./lib/testUtils"; +import { + bookTimeSlot, + fillStripeTestCheckout, + selectFirstAvailableTimeSlotNextMonth, + testName, + todo, +} from "./lib/testUtils"; test.describe.configure({ mode: "parallel" }); test.describe("Teams - NonOrg", () => { test.afterEach(({ users }) => users.deleteAll()); - test("Can create teams via Wizard", async ({ page, users }) => { - const user = await users.create(); - const inviteeEmail = `${user.username}+invitee@example.com`; - await user.apiLogin(); - await page.goto("/teams"); - await test.step("Can create team", async () => { - // Click text=Create Team - await page.locator("text=Create Team").click(); - await page.waitForURL("/settings/teams/new"); - // Fill input[name="name"] - await page.locator('input[name="name"]').fill(`${user.username}'s Team`); - // Click text=Continue - await page.locator("text=Continue").click(); - await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i); - await page.waitForSelector('[data-testid="pending-member-list"]'); - expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1); - }); + test("Team Onboarding Invite Members", async ({ page, users }) => { + const user = await users.create(undefined, { hasTeam: true }); + const { team } = await user.getFirstTeam(); + const inviteeEmail = `${user.username}+invitee@example.com`; + + await user.apiLogin(); + + page.goto(`/settings/teams/${team.id}/onboard-members`); await test.step("Can add members", async () => { // Click [data-testid="new-member-button"] @@ -50,9 +48,9 @@ test.describe("Teams - NonOrg", () => { await prisma.user.delete({ where: { email: inviteeEmail } }); }); - await test.step("Can publish team", async () => { - await page.locator("text=Publish team").click(); - await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i); + await test.step("Finishing brings you to team profile page", async () => { + await page.locator("[data-testid=publish-button]").click(); + await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i); }); await test.step("Can disband team", async () => { @@ -66,7 +64,6 @@ test.describe("Teams - NonOrg", () => { }); test("Can create a booking for Collective EventType", async ({ page, users }) => { - const ownerObj = { username: "pro-user", name: "pro-user" }; const teamMatesObj = [ { name: "teammate-1" }, { name: "teammate-2" }, @@ -74,11 +71,14 @@ test.describe("Teams - NonOrg", () => { { name: "teammate-4" }, ]; - const owner = await users.create(ownerObj, { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.COLLECTIVE, - }); + const owner = await users.create( + { username: "pro-user", name: "pro-user" }, + { + hasTeam: true, + teammates: teamMatesObj, + schedulingType: SchedulingType.COLLECTIVE, + } + ); const { team } = await owner.getFirstTeam(); const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); @@ -102,18 +102,20 @@ test.describe("Teams - NonOrg", () => { }); test("Can create a booking for Round Robin EventType", async ({ page, users }) => { - const ownerObj = { username: "pro-user", name: "pro-user" }; const teamMatesObj = [ { name: "teammate-1" }, { name: "teammate-2" }, { name: "teammate-3" }, { name: "teammate-4" }, ]; - const owner = await users.create(ownerObj, { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.ROUND_ROBIN, - }); + const owner = await users.create( + { username: "pro-user", name: "pro-user" }, + { + hasTeam: true, + teammates: teamMatesObj, + schedulingType: SchedulingType.ROUND_ROBIN, + } + ); const { team } = await owner.getFirstTeam(); const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); @@ -134,7 +136,7 @@ test.describe("Teams - NonOrg", () => { // Anyone of the teammates could be the Host of the booking. const chosenUser = await page.getByTestId("booking-host-name").textContent(); expect(chosenUser).not.toBeNull(); - expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true); + expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true); // TODO: Assert whether the user received an email }); @@ -164,8 +166,7 @@ test.describe("Teams - NonOrg", () => { await page.goto("/settings/teams/new"); // Fill input[name="name"] await page.locator('input[name="name"]').fill(uniqueName); - await page.locator("text=Continue").click(); - await expect(page.locator("[data-testid=alert]")).toBeVisible(); + await page.click("[type=submit]"); // cleanup const org = await owner.getOrgMembership(); @@ -174,11 +175,9 @@ test.describe("Teams - NonOrg", () => { }); test("Can create team with same name as user", async ({ page, users }) => { + const user = await users.create(); // Name to be used for both user and team - const uniqueName = "test-unique-name"; - const ownerObj = { username: uniqueName, name: uniqueName, useExactUsername: true }; - - const user = await users.create(ownerObj); + const uniqueName = user.username!; await user.apiLogin(); await page.goto("/teams"); @@ -189,11 +188,14 @@ test.describe("Teams - NonOrg", () => { // Fill input[name="name"] await page.locator('input[name="name"]').fill(uniqueName); // Click text=Continue - await page.locator("text=Continue").click(); - await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i); + await page.click("[type=submit]"); + // TODO: Figure out a way to make this more reliable + // eslint-disable-next-line playwright/no-conditional-in-test + if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page); + await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i); // Click text=Continue - await page.locator("text=Publish team").click(); - await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i); + await page.locator("[data-testid=publish-button]").click(); + await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i); }); await test.step("Can access user and team with same slug", async () => { @@ -210,13 +212,11 @@ test.describe("Teams - NonOrg", () => { await expect(page.locator("[data-testid=name-title]")).toHaveText(uniqueName); // cleanup team - const team = await prisma.team.findFirst({ where: { slug: uniqueName } }); - await prisma.team.delete({ where: { id: team?.id } }); + await prisma.team.deleteMany({ where: { slug: uniqueName } }); }); }); test("Can create a private team", async ({ page, users }) => { - const ownerObj = { username: "pro-user", name: "pro-user" }; const teamMatesObj = [ { name: "teammate-1" }, { name: "teammate-2" }, @@ -224,11 +224,14 @@ test.describe("Teams - NonOrg", () => { { name: "teammate-4" }, ]; - const owner = await users.create(ownerObj, { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.COLLECTIVE, - }); + const owner = await users.create( + { username: "pro-user", name: "pro-user" }, + { + hasTeam: true, + teammates: teamMatesObj, + schedulingType: SchedulingType.COLLECTIVE, + } + ); await owner.apiLogin(); const { team } = await owner.getFirstTeam(); @@ -278,45 +281,43 @@ test.describe("Teams - Org", () => { // Fill input[name="name"] await page.locator('input[name="name"]').fill(`${user.username}'s Team`); // Click text=Continue - await page.locator("text=Continue").click(); - await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members$/i); + await page.click("[type=submit]"); + // TODO: Figure out a way to make this more reliable + // eslint-disable-next-line playwright/no-conditional-in-test + if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page); + await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i); await page.waitForSelector('[data-testid="pending-member-list"]'); - expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1); + expect(await page.getByTestId("pending-member-item").count()).toBe(1); }); await test.step("Can add members", async () => { - // Click [data-testid="new-member-button"] - await page.locator('[data-testid="new-member-button"]').click(); - // Fill [placeholder="email\@example\.com"] + await page.getByTestId("new-member-button").click(); await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail); - // Click [data-testid="invite-new-member-button"] - await page.locator('[data-testid="invite-new-member-button"]').click(); + await page.getByTestId("invite-new-member-button").click(); await expect(page.locator(`li:has-text("${inviteeEmail}")`)).toBeVisible(); - expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2); + expect(await page.getByTestId("pending-member-item").count()).toBe(2); }); await test.step("Can remove members", async () => { - expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(2); - - const lastRemoveMemberButton = page.locator('[data-testid="remove-member-button"]').last(); + expect(await page.getByTestId("pending-member-item").count()).toBe(2); + const lastRemoveMemberButton = page.getByTestId("remove-member-button").last(); await lastRemoveMemberButton.click(); await page.waitForLoadState("networkidle"); - expect(await page.locator('[data-testid="pending-member-item"]').count()).toBe(1); + expect(await page.getByTestId("pending-member-item").count()).toBe(1); // Cleanup here since this user is created without our fixtures. await prisma.user.delete({ where: { email: inviteeEmail } }); }); await test.step("Can finish team creation", async () => { - await page.locator("text=Finish").click(); - await page.waitForURL("/settings/teams"); + await page.getByTestId("publish-button").click(); + await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/profile$/i); }); await test.step("Can disband team", async () => { - await page.locator('[data-testid="team-list-item-link"]').click(); await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i); - await page.locator("text=Disband Team").click(); - await page.locator("text=Yes, disband team").click(); + await page.getByTestId("disband-team-button").click(); + await page.getByTestId("dialog-confirmation").click(); await page.waitForURL("/teams"); expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0); }); @@ -361,13 +362,13 @@ test.describe("Teams - Org", () => { await page.goto(`/team/${team.slug}/${teamEventSlug}`); await selectFirstAvailableTimeSlotNextMonth(page); await bookTimeSlot(page); - await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + await expect(page.getByTestId("success-page")).toBeVisible(); // The title of the booking const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`; - await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle); + await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle); // The booker should be in the attendee list - await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName); + await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName); // All the teammates should be in the booking for (const teammate of teamMatesObj.concat([{ name: owner.name || "" }])) { @@ -380,18 +381,20 @@ test.describe("Teams - Org", () => { }); test("Can create a booking for Round Robin EventType", async ({ page, users }) => { - const ownerObj = { username: "pro-user", name: "pro-user" }; const teamMatesObj = [ { name: "teammate-1" }, { name: "teammate-2" }, { name: "teammate-3" }, { name: "teammate-4" }, ]; - const owner = await users.create(ownerObj, { - hasTeam: true, - teammates: teamMatesObj, - schedulingType: SchedulingType.ROUND_ROBIN, - }); + const owner = await users.create( + { username: "pro-user", name: "pro-user" }, + { + hasTeam: true, + teammates: teamMatesObj, + schedulingType: SchedulingType.ROUND_ROBIN, + } + ); const { team } = await owner.getFirstTeam(); const { title: teamEventTitle, slug: teamEventSlug } = await owner.getFirstTeamEvent(team.id); @@ -402,17 +405,17 @@ test.describe("Teams - Org", () => { await expect(page.locator("[data-testid=success-page]")).toBeVisible(); // The person who booked the meeting should be in the attendee list - await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName); + await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName); // The title of the booking const BookingTitle = `${teamEventTitle} between ${team.name} and ${testName}`; - await expect(page.locator("[data-testid=booking-title]")).toHaveText(BookingTitle); + await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle); // Since all the users have the same leastRecentlyBooked value // Anyone of the teammates could be the Host of the booking. const chosenUser = await page.getByTestId("booking-host-name").textContent(); expect(chosenUser).not.toBeNull(); - expect(teamMatesObj.concat([{ name: ownerObj.name }]).some(({ name }) => name === chosenUser)).toBe(true); + expect(teamMatesObj.concat([{ name: owner.name! }]).some(({ name }) => name === chosenUser)).toBe(true); // TODO: Assert whether the user received an email }); }); diff --git a/packages/features/ee/teams/components/AddNewTeamMembers.tsx b/packages/features/ee/teams/components/AddNewTeamMembers.tsx index 750f8ecbae..da444655da 100644 --- a/packages/features/ee/teams/components/AddNewTeamMembers.tsx +++ b/packages/features/ee/teams/components/AddNewTeamMembers.tsx @@ -1,6 +1,6 @@ import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import InviteLinkSettingsModal from "@calcom/features/ee/teams/components/InviteLinkSettingsModal"; @@ -10,6 +10,7 @@ import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants"; import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useTelemetry, telemetryEventTypes } from "@calcom/lib/telemetry"; import { MembershipRole } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; @@ -33,11 +34,21 @@ type FormValues = { const AddNewTeamMembers = () => { const searchParams = useCompatSearchParams(); const session = useSession(); + const telemetry = useTelemetry(); + const teamId = searchParams?.get("id") ? Number(searchParams.get("id")) : -1; const teamQuery = trpc.viewer.teams.get.useQuery( { teamId }, { enabled: session.status === "authenticated" } ); + + useEffect(() => { + const event = searchParams?.get("event"); + if (event === "team_created") { + telemetry.event(telemetryEventTypes.team_created); + } + }, []); + if (session.status === "loading" || !teamQuery.data) return ; return ; @@ -170,18 +181,15 @@ export const AddNewTeamMembersForm = ({ )}
); diff --git a/packages/features/ee/teams/components/CreateANewTeamForm.tsx b/packages/features/ee/teams/components/CreateANewTeamForm.tsx index 76b2cbc6db..14de3a1588 100644 --- a/packages/features/ee/teams/components/CreateANewTeamForm.tsx +++ b/packages/features/ee/teams/components/CreateANewTeamForm.tsx @@ -4,14 +4,15 @@ import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { extractDomainFromWebsiteUrl } from "@calcom/ee/organizations/lib/utils"; +import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import slugify from "@calcom/lib/slugify"; import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry"; import { trpc } from "@calcom/trpc/react"; -import { Avatar, Button, Form, ImageUploader, TextField, Alert, Label } from "@calcom/ui"; -import { ArrowRight, Plus } from "@calcom/ui/components/icon"; +import { Alert, Button, Form, TextField } from "@calcom/ui"; +import { ArrowRight } from "@calcom/ui/components/icon"; import { useOrgBranding } from "../../organizations/context/provider"; import type { NewTeamFormValues } from "../lib/types"; @@ -21,8 +22,19 @@ const querySchema = z.object({ slug: z.string().optional(), }); +const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES; +const flag = isTeamBillingEnabledClient + ? { + telemetryEvent: telemetryEventTypes.team_checkout_session_created, + submitLabel: "checkout", + } + : { + telemetryEvent: telemetryEventTypes.team_created, + submitLabel: "continue", + }; + export const CreateANewTeamForm = () => { - const { t } = useLocale(); + const { t, isLocaleReady } = useLocale(); const router = useRouter(); const telemetry = useTelemetry(); const params = useParamsWithFallback(); @@ -42,8 +54,8 @@ export const CreateANewTeamForm = () => { const createTeamMutation = trpc.viewer.teams.create.useMutation({ onSuccess: (data) => { - telemetry.event(telemetryEventTypes.team_created); - router.push(`/settings/teams/${data.id}/onboard-members`); + telemetry.event(flag.telemetryEvent); + router.push(data.url); }, onError: (err) => { if (err.message === "team_url_taken") { @@ -81,6 +93,10 @@ export const CreateANewTeamForm = () => { render={({ field: { value } }) => ( <> { /> -
- ( - <> - -
- } - size="lg" - /> -
- { - newTeamFormMethods.setValue("logo", newAvatar); - createTeamMutation.reset(); - }} - imageSrc={value} - /> -
-
- - )} - /> -
-
diff --git a/packages/features/ee/teams/lib/payments.ts b/packages/features/ee/teams/lib/payments.ts index 5a0b50f31b..5e44add974 100644 --- a/packages/features/ee/teams/lib/payments.ts +++ b/packages/features/ee/teams/lib/payments.ts @@ -29,6 +29,51 @@ export const checkIfTeamPaymentRequired = async ({ teamId = -1 }) => { return { url: `${WEBAPP_URL}/api/teams/${teamId}/upgrade?session_id=${metadata.paymentId}` }; }; +/** + * Used to generate a checkout session when trying to create a team + */ +export const generateTeamCheckoutSession = async ({ + teamName, + teamSlug, + userId, +}: { + teamName: string; + teamSlug: string; + userId: number; +}) => { + const customer = await getStripeCustomerIdFromUserId(userId); + const session = await stripe.checkout.sessions.create({ + customer, + mode: "subscription", + allow_promotion_codes: true, + success_url: `${WEBAPP_URL}/api/teams/create?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${WEBAPP_URL}/settings/my-account/profile`, + line_items: [ + { + /** We only need to set the base price and we can upsell it directly on Stripe's checkout */ + price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID, + /**Initially it will be just the team owner */ + quantity: 1, + }, + ], + customer_update: { + address: "auto", + }, + automatic_tax: { + enabled: true, + }, + metadata: { + teamName, + teamSlug, + userId, + }, + }); + return session; +}; + +/** + * Used to generate a checkout session when creating a new org (parent team) or backwards compatibility for old teams + */ export const purchaseTeamSubscription = async (input: { teamId: number; seats: number; diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 294ce36fbd..88c53e991f 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -194,7 +194,11 @@ const ProfileView = () => { - diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index f9fb122f34..6f1a66ab9a 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -88,7 +88,7 @@ export const IS_STRIPE_ENABLED = !!( process.env.STRIPE_PRIVATE_KEY ); /** Self hosted shouldn't checkout when creating teams unless required */ -export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && (!IS_SELF_HOSTED || HOSTED_CAL_FEATURES); +export const IS_TEAM_BILLING_ENABLED = IS_STRIPE_ENABLED && HOSTED_CAL_FEATURES; export const FULL_NAME_LENGTH_MAX_LIMIT = 50; export const MINUTES_TO_BOOK = process.env.NEXT_PUBLIC_MINUTES_TO_BOOK || "5"; diff --git a/packages/lib/telemetry.ts b/packages/lib/telemetry.ts index 5e4636eaf8..757ab05161 100644 --- a/packages/lib/telemetry.ts +++ b/packages/lib/telemetry.ts @@ -18,6 +18,7 @@ export const telemetryEventTypes = { onboardingFinished: "onboarding_finished", onboardingStarted: "onboarding_started", signup: "signup", + team_checkout_session_created: "team_checkout_session_created", team_created: "team_created", slugReplacementAction: "slug_replacement_action", org_created: "org_created", diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index 2b861c7d88..4c3ffffd18 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -1,3 +1,5 @@ +import { generateTeamCheckoutSession } from "@calcom/features/ee/teams/lib/payments"; +import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { closeComUpsertTeamUser } from "@calcom/lib/sync/SyncServiceManager"; import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; @@ -14,6 +16,34 @@ type CreateOptions = { input: TCreateInputSchema; }; +const generateCheckoutSession = async ({ + teamSlug, + teamName, + userId, +}: { + teamSlug: string; + teamName: string; + userId: number; +}) => { + if (!IS_TEAM_BILLING_ENABLED) { + console.info("Team billing is disabled, not generating a checkout session."); + return; + } + + const checkoutSession = await generateTeamCheckoutSession({ + teamSlug, + teamName, + userId, + }); + + 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" }; +}; + export const createHandler = async ({ ctx, input }: CreateOptions) => { const { user } = ctx; const { slug, name, logo } = input; @@ -45,28 +75,26 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { if (nameCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "team_slug_exists_as_user" }); } - // Ensure that the user is not duplicating a requested team - const duplicatedRequest = await prisma.team.findFirst({ - where: { - members: { - some: { - userId: ctx.user.id, - }, - }, - metadata: { - path: ["requestedSlug"], - equals: slug, - }, - }, - }); + // If the user is not a part of an org, then make them pay before creating the team + if (!isOrgChildTeam) { + const checkoutSession = await generateCheckoutSession({ + teamSlug: slug, + teamName: name, + userId: user.id, + }); - if (duplicatedRequest) { - return duplicatedRequest; + // If there is a checkout session, return it. Otherwise, it means it's disabled. + if (checkoutSession) + return { + url: checkoutSession.url, + message: checkoutSession.message, + team: null, + }; } - const createTeam = await prisma.team.create({ + const createdTeam = await prisma.team.create({ data: { - ...(isOrgChildTeam ? { slug } : {}), + slug, name, logo, members: { @@ -76,17 +104,16 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { accepted: true, }, }, - metadata: !isOrgChildTeam - ? { - requestedSlug: slug, - } - : undefined, ...(isOrgChildTeam && { parentId: user.organizationId }), }, }); // Sync Services: Close.com - closeComUpsertTeamUser(createTeam, ctx.user, MembershipRole.OWNER); + closeComUpsertTeamUser(createdTeam, ctx.user, MembershipRole.OWNER); - return createTeam; + return { + url: `${WEBAPP_URL}/settings/teams/${createdTeam.id}/onboard-members`, + message: "Team billing is disabled, not generating a checkout session.", + team: createdTeam, + }; }; diff --git a/packages/ui/components/dialog/ConfirmationDialogContent.tsx b/packages/ui/components/dialog/ConfirmationDialogContent.tsx index b25fbbc9c9..c615a81acc 100644 --- a/packages/ui/components/dialog/ConfirmationDialogContent.tsx +++ b/packages/ui/components/dialog/ConfirmationDialogContent.tsx @@ -69,7 +69,11 @@ export function ConfirmationDialogContent(props: PropsWithChildren onConfirm && onConfirm(e)}> + onConfirm && onConfirm(e)} + data-testid="dialog-confirmation"> {isLoading ? loadingText : confirmBtnText} )}