diff --git a/apps/api/lib/validations/team.ts b/apps/api/lib/validations/team.ts index e5f8e3cbfa..3bc0af2c49 100644 --- a/apps/api/lib/validations/team.ts +++ b/apps/api/lib/validations/team.ts @@ -5,14 +5,23 @@ import { _TeamModel as Team } from "@calcom/prisma/zod"; export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true }).partial({ hideBranding: true, metadata: true, + pendingPayment: true, }); -const schemaTeamRequiredParams = z.object({}); +const schemaTeamRequiredParams = z.object({ + name: z.string().max(255), +}); export const schemaTeamBodyParams = schemaTeamBaseBodyParams.merge(schemaTeamRequiredParams).strict(); export const schemaTeamUpdateBodyParams = schemaTeamBodyParams.partial(); +const schemaOwnerId = z.object({ + ownerId: z.number().optional(), +}); + +export const schemaTeamCreateBodyParams = schemaTeamBodyParams.merge(schemaOwnerId).strict(); + export const schemaTeamReadPublic = Team.omit({}); export const schemaTeamsReadPublic = z.array(schemaTeamReadPublic); diff --git a/apps/api/pages/api/teams/_post.ts b/apps/api/pages/api/teams/_post.ts index a018ed81d6..92588ecee6 100644 --- a/apps/api/pages/api/teams/_post.ts +++ b/apps/api/pages/api/teams/_post.ts @@ -1,12 +1,14 @@ import type { NextApiRequest } from "next"; -import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer"; +import stripe from "@calcom/app-store/stripepayment/lib/server"; +import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { HttpError } from "@calcom/lib/http-error"; import { defaultResponder } from "@calcom/lib/server"; import { MembershipRole } from "@calcom/prisma/enums"; import { schemaMembershipPublic } from "~/lib/validations/membership"; -import { schemaTeamBodyParams, schemaTeamReadPublic } from "~/lib/validations/team"; +import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validations/team"; /** * @swagger @@ -31,6 +33,12 @@ import { schemaTeamBodyParams, schemaTeamReadPublic } from "~/lib/validations/te * required: * - name * - slug + * - hideBookATeamMember + * - brandColor + * - darkBrandColor + * - timeZone + * - weekStart + * - isPrivate * properties: * name: * type: string @@ -38,6 +46,27 @@ import { schemaTeamBodyParams, schemaTeamReadPublic } from "~/lib/validations/te * slug: * type: string * description: A unique slug that works as path for the team public page + * hideBookATeamMember: + * type: boolean + * description: Flag to hide or show the book a team member option + * brandColor: + * type: string + * description: Primary brand color for the team + * darkBrandColor: + * type: string + * description: Dark variant of the primary brand color for the team + * timeZone: + * type: string + * description: Time zone of the team + * weekStart: + * type: string + * description: Starting day of the week for the team + * isPrivate: + * type: boolean + * description: Flag indicating if the team is private + * ownerId: + * type: number + * description: ID of the team owner - only admins can set this. * tags: * - teams * responses: @@ -49,8 +78,12 @@ import { schemaTeamBodyParams, schemaTeamReadPublic } from "~/lib/validations/te * description: Authorization information is missing or invalid. */ async function postHandler(req: NextApiRequest) { - const { prisma, body, userId } = req; - const data = schemaTeamBodyParams.parse(body); + const { prisma, body, userId, isAdmin } = req; + const { ownerId, ...data } = schemaTeamCreateBodyParams.parse(body); + + await checkPermissions(req); + + const effectiveUserId = isAdmin && ownerId ? ownerId : userId; if (data.slug) { const alreadyExist = await prisma.team.findFirst({ @@ -59,13 +92,6 @@ async function postHandler(req: NextApiRequest) { }, }); if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" }); - if (IS_TEAM_BILLING_ENABLED) { - // Setting slug in metadata, so it can be published later - data.metadata = { - requestedSlug: data.slug, - }; - delete data.slug; - } } // Check if parentId is related to this user @@ -87,24 +113,102 @@ async function postHandler(req: NextApiRequest) { ...data, metadata: data.metadata === null ? {} : data.metadata || undefined, }; - const team = await prisma.team.create({ + + if (!IS_TEAM_BILLING_ENABLED) { + const team = await prisma.team.create({ + data: { + ...cloneData, + members: { + create: { userId: effectiveUserId, role: MembershipRole.OWNER, accepted: true }, + }, + }, + include: { members: true }, + }); + + req.statusCode = 201; + + return { + team: schemaTeamReadPublic.parse(team), + owner: schemaMembershipPublic.parse(team.members[0]), + message: `Team created successfully. We also made user with ID=${ownerId} the owner of this team.`, + }; + } + + const pendingPaymentTeam = await prisma.team.create({ data: { ...cloneData, - createdAt: new Date(), - members: { - // We're also creating the relation membership of team ownership in this call. - create: { userId, role: MembershipRole.OWNER, accepted: true }, - }, + pendingPayment: true, }, - include: { members: true }, }); - req.statusCode = 201; - // We are also returning the new ownership relation as owner besides team. + + const checkoutSession = await generateTeamCheckoutSession({ + pendingPaymentTeamId: pendingPaymentTeam.id, + ownerId: effectiveUserId, + }); + return { - team: schemaTeamReadPublic.parse(team), - owner: schemaMembershipPublic.parse(team.members[0]), - message: "Team created successfully, we also made you the owner of this team", + message: + "Your team will be created once we receive your payment. Please complete the payment using the payment link.", + paymentLink: checkoutSession.url, + pendingTeam: { + ...schemaTeamReadPublic.parse(pendingPaymentTeam), + }, }; } +async function checkPermissions(req: NextApiRequest) { + const { isAdmin } = req; + const body = schemaTeamCreateBodyParams.parse(req.body); + + /* Non-admin users can only create teams for themselves */ + if (!isAdmin && body.ownerId) + throw new HttpError({ + statusCode: 401, + message: "ADMIN required for `ownerId`", + }); +} + +const generateTeamCheckoutSession = async ({ + pendingPaymentTeamId, + ownerId, +}: { + pendingPaymentTeamId: number; + ownerId: number; +}) => { + const customer = await getStripeCustomerIdFromUserId(ownerId); + const session = await stripe.checkout.sessions.create({ + customer, + mode: "subscription", + allow_promotion_codes: true, + success_url: `${WEBAPP_URL}/api/teams/api/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: { + pendingPaymentTeamId, + ownerId, + }, + }); + + if (!session.url) + throw new HttpError({ + statusCode: 500, + message: "Failed generating a checkout session URL.", + }); + + return session; +}; + export default defaultResponder(postHandler); diff --git a/apps/web/pages/api/teams/api/create.ts b/apps/web/pages/api/teams/api/create.ts new file mode 100644 index 0000000000..3a091f223f --- /dev/null +++ b/apps/web/pages/api/teams/api/create.ts @@ -0,0 +1,111 @@ +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"; +import { _MembershipModel as Membership, _TeamModel as Team } from "@calcom/prisma/zod"; + +const querySchema = z.object({ + session_id: z.string().min(1), +}); + +const checkoutSessionMetadataSchema = z.object({ + pendingPaymentTeamId: z.string().transform(Number), + ownerId: z.string().transform(Number), +}); + +type CheckoutSessionMetadata = z.infer; + +export const schemaTeamReadPublic = Team.omit({}); +export const schemaMembershipPublic = Membership.merge(z.object({ team: Team }).partial()); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const checkoutSession = await getCheckoutSession(req); + validateCheckoutSession(checkoutSession); + const checkoutSessionSubscription = getCheckoutSessionSubscription(checkoutSession); + const checkoutSessionMetadata = getCheckoutSessionMetadata(checkoutSession); + + const finalizedTeam = await prisma.team.update({ + where: { id: checkoutSessionMetadata.pendingPaymentTeamId }, + data: { + pendingPayment: false, + members: { + create: { + userId: checkoutSessionMetadata.ownerId as number, + role: MembershipRole.OWNER, + accepted: true, + }, + }, + metadata: { + paymentId: checkoutSession.id, + subscriptionId: checkoutSessionSubscription.id || null, + subscriptionItemId: checkoutSessionSubscription.items.data[0].id || null, + }, + }, + include: { members: true }, + }); + + const response = JSON.stringify( + { + message: `Team created successfully. We also made user with ID=${checkoutSessionMetadata.ownerId} the owner of this team.`, + team: schemaTeamReadPublic.parse(finalizedTeam), + owner: schemaMembershipPublic.parse(finalizedTeam.members[0]), + }, + null, + 2 + ); + + return res.status(200).send(response); +} + +async function getCheckoutSession(req: NextApiRequest) { + 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" }); + + return checkoutSession; +} + +function validateCheckoutSession(checkoutSession: Stripe.Response) { + if (checkoutSession.payment_status !== "paid") + throw new HttpError({ statusCode: 402, message: "Payment required" }); +} + +function getCheckoutSessionSubscription(checkoutSession: Stripe.Response) { + if (!checkoutSession.subscription) { + throw new HttpError({ + statusCode: 400, + message: "Can't publish team/org without subscription", + }); + } + + return checkoutSession.subscription as Stripe.Subscription; +} + +function getCheckoutSessionMetadata( + checkoutSession: Stripe.Response +): CheckoutSessionMetadata { + const parseCheckoutSessionMetadata = checkoutSessionMetadataSchema.safeParse(checkoutSession.metadata); + + if (!parseCheckoutSessionMetadata.success) { + throw new HttpError({ + statusCode: 400, + message: `Incorrect metadata in checkout session. Error: ${parseCheckoutSessionMetadata.error}`, + }); + } + + const checkoutSessionMetadata = parseCheckoutSessionMetadata.data; + + return checkoutSessionMetadata; +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/prisma/extensions/exclude-pending-payment-teams.ts b/packages/prisma/extensions/exclude-pending-payment-teams.ts new file mode 100644 index 0000000000..64b2b88107 --- /dev/null +++ b/packages/prisma/extensions/exclude-pending-payment-teams.ts @@ -0,0 +1,42 @@ +import { Prisma } from "@prisma/client"; +import type { DefaultArgs, InternalArgs } from "@prisma/client/runtime/library"; + +export function excludePendingPaymentsExtension() { + return Prisma.defineExtension({ + query: { + team: { + async findUnique({ args, query }) { + return excludePendingPayments(args, query); + }, + async findFirst({ args, query }) { + return excludePendingPayments(args, query); + }, + async findMany({ args, query }) { + return excludePendingPayments(args, query); + }, + async findUniqueOrThrow({ args, query }) { + return excludePendingPayments(args, query); + }, + async findFirstOrThrow({ args, query }) { + return excludePendingPayments(args, query); + }, + }, + }, + }); +} + +async function excludePendingPayments( + args: + | Prisma.TeamFindUniqueArgs + | Prisma.TeamFindFirstArgs + | Prisma.TeamFindManyArgs + | Prisma.TeamFindUniqueOrThrowArgs + | Prisma.TeamFindFirstOrThrowArgs, + query: (args: T) => Promise +) { + args.where = args.where || {}; + if (args.where.pendingPayment === undefined) { + args.where.pendingPayment = false; + } + return query(args); +} diff --git a/packages/prisma/index.ts b/packages/prisma/index.ts index 3e7223675f..86c28c19e6 100644 --- a/packages/prisma/index.ts +++ b/packages/prisma/index.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import { PrismaClient as PrismaClientWithoutExtension } from "@prisma/client"; import { withAccelerate } from "@prisma/extension-accelerate"; +import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams"; import { bookingReferenceMiddleware } from "./middleware"; const prismaOptions: Prisma.PrismaClientOptions = {}; @@ -18,7 +19,9 @@ const prismaWithoutClientExtensions = globalForPrisma.prismaWithoutClientExtensions || new PrismaClientWithoutExtension(prismaOptions); export const customPrisma = (options?: Prisma.PrismaClientOptions) => - new PrismaClientWithoutExtension({ ...prismaOptions, ...options }).$extends(withAccelerate()); + new PrismaClientWithoutExtension({ ...prismaOptions, ...options }) + .$extends(excludePendingPaymentsExtension()) + .$extends(withAccelerate()); // If any changed on middleware server restart is required // TODO: Migrate it to $extends @@ -27,35 +30,8 @@ bookingReferenceMiddleware(prismaWithoutClientExtensions); // FIXME: Due to some reason, there are types failing in certain places due to the $extends. Fix it and then enable it // Specifically we get errors like `Type 'string | Date | null | undefined' is not assignable to type 'Exact'` const prismaWithClientExtensions = prismaWithoutClientExtensions - // + .$extends(excludePendingPaymentsExtension()) .$extends(withAccelerate()); -// .$extends({ -// query: { -// $allModels: { -// async $allOperations({ model, operation, args, query }) { -// const start = performance.now(); -// /* your custom logic here */ -// const res = await query(args); -// const end = performance.now(); -// logger.debug("Query Perf: ", `${model}.${operation} took ${(end - start).toFixed(2)}ms\n`); -// return res; -// }, -// }, -// }, -// }); -// .$extends({ -// name: "teamUpdateWithMetadata", -// query: { -// team: { -// async update({ model, operation, args, query }) { -// if (args.data.metadata) { -// // Prepare args.data with merged metadata -// } -// return query(args); -// }, -// }, -// }, -// }) export const prisma = globalForPrisma.prismaWithClientExtensions || prismaWithClientExtensions; diff --git a/packages/prisma/migrations/20240116085902_add_pending_payment_column_to_teams/migration.sql b/packages/prisma/migrations/20240116085902_add_pending_payment_column_to_teams/migration.sql new file mode 100644 index 0000000000..068193168f --- /dev/null +++ b/packages/prisma/migrations/20240116085902_add_pending_payment_column_to_teams/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "pendingPayment" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 111a3f4d89..d3518661b8 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -318,6 +318,7 @@ model Team { isOrganization Boolean @default(false) organizationSettings OrganizationSettings? instantMeetingTokens InstantMeetingToken[] + pendingPayment Boolean @default(false) @@unique([slug, parentId]) @@unique([slug, isOrganization])