diff --git a/packages/app-store/_utils/payments/handlePaymentSuccess.ts b/packages/app-store/_utils/payments/handlePaymentSuccess.ts index 4a5c3000e35b23..c7c873dc3d8954 100644 --- a/packages/app-store/_utils/payments/handlePaymentSuccess.ts +++ b/packages/app-store/_utils/payments/handlePaymentSuccess.ts @@ -1,11 +1,15 @@ import { eventTypeAppMetadataOptionalSchema } from "@calcom/app-store/zod-utils"; -import { sendScheduledEmailsAndSMS } from "@calcom/emails/email-manager"; +import { sendScheduledEmailsAndSMS, sendScheduledSeatsEmailsAndSMS } from "@calcom/emails/email-manager"; import EventManager, { placeholderCreatedEvent } from "@calcom/features/bookings/lib/EventManager"; import { doesBookingRequireConfirmation } from "@calcom/features/bookings/lib/doesBookingRequireConfirmation"; import { getAllCredentialsIncludeServiceAccountKey } from "@calcom/features/bookings/lib/getAllCredentialsForUsersOnEvent/getAllCredentials"; import { handleBookingRequested } from "@calcom/features/bookings/lib/handleBookingRequested"; import { handleConfirmation } from "@calcom/features/bookings/lib/handleConfirmation"; import { getBooking } from "@calcom/features/bookings/lib/payment/getBooking"; +import { + allowDisablingAttendeeConfirmationEmails, + allowDisablingHostConfirmationEmails, +} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails"; import { getPlatformParams } from "@calcom/features/platform-oauth-client/get-platform-params"; import { PlatformOAuthClientRepository } from "@calcom/features/platform-oauth-client/platform-oauth-client.repository"; import { HttpError as HttpCode } from "@calcom/lib/http-error"; @@ -13,7 +17,8 @@ import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import { BookingStatus } from "@calcom/prisma/enums"; -import type { EventTypeMetadata } from "@calcom/prisma/zod-utils"; +import { EventTypeMetaDataSchema, type EventTypeMetadata } from "@calcom/prisma/zod-utils"; +import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util"; const log = logger.getSubLogger({ prefix: ["[handlePaymentSuccess]"] }); export async function handlePaymentSuccess(paymentId: number, bookingId: number) { @@ -96,7 +101,49 @@ export async function handlePaymentSuccess(paymentId: number, bookingId: number) log.debug(`handling booking request for eventId ${eventType.id}`); } } else if (areEmailsEnabled) { - await sendScheduledEmailsAndSMS({ ...evt }, undefined, undefined, undefined, eventType.metadata); + const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType?.metadata || {}); + + // For seated events, send emails only to the attendee who just completed payment + if (evt.seatsPerTimeSlot) { + // Find the attendee whose bookingSeat has this paymentId + const attendeeWhoPaid = evt.attendees.find((a) => a.bookingSeat?.paymentId === paymentId); + + if (attendeeWhoPaid) { + const workflows = await getAllWorkflowsFromEventType(booking.eventType, booking.userId); + + let isHostConfirmationEmailsDisabled = false; + let isAttendeeConfirmationEmailDisabled = false; + + if (workflows) { + isHostConfirmationEmailsDisabled = + eventTypeMetadata?.disableStandardEmails?.confirmation?.host || false; + isAttendeeConfirmationEmailDisabled = + eventTypeMetadata?.disableStandardEmails?.confirmation?.attendee || false; + + if (isHostConfirmationEmailsDisabled) { + isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows); + } + + if (isAttendeeConfirmationEmailDisabled) { + isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows); + } + } + + const newSeat = evt.attendees.length > 1; + + await sendScheduledSeatsEmailsAndSMS( + evt, + attendeeWhoPaid, + newSeat, + !!evt.seatsShowAttendees, + isHostConfirmationEmailsDisabled, + isAttendeeConfirmationEmailDisabled, + eventTypeMetadata + ); + } + } else { + await sendScheduledEmailsAndSMS({ ...evt }, undefined, undefined, undefined, eventTypeMetadata); + } } throw new HttpCode({ diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 36b7086fb38e7d..0d275a36c8e5ed 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -99,7 +99,8 @@ const _sendScheduledEmailsAndSMS = async ( title: getEventName({ ...eventNameObject, t: attendee.language.translate }), }), }, - attendee + attendee, + formattedCalEvent.seatsShowAttendees ?? false ) ); }) @@ -531,7 +532,6 @@ export const sendAwaitingPaymentEmailAndSMS = async ( await awaitingPaymentSMS.sendSMSToAttendees(); }; - export const sendRequestRescheduleEmailAndSMS = async ( calEvent: CalendarEvent, metadata: { rescheduleLink: string }, diff --git a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts index ea6d724925010d..98e950a3816a8f 100644 --- a/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts +++ b/packages/features/bookings/lib/handleSeats/create/createNewSeat.ts @@ -126,7 +126,10 @@ const createNewSeat = async ( } const copyEvent = cloneDeep(evt); copyEvent.uid = seatedBooking.uid; - if (noEmail !== true) { + + const requiresPayment = !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!seatedBooking; + + if (noEmail !== true && !requiresPayment) { let isHostConfirmationEmailsDisabled = false; let isAttendeeConfirmationEmailDisabled = false; @@ -158,7 +161,7 @@ const createNewSeat = async ( const foundBooking = await findBookingQuery(seatedBooking.id); - if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!seatedBooking) { + if (requiresPayment) { const credentialPaymentAppCategories = await prisma.credential.findMany({ where: { ...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }), @@ -209,6 +212,13 @@ const createNewSeat = async ( bookerPhoneNumber, }); + if (payment?.id && newBookingSeat?.id) { + await prisma.bookingSeat.update({ + where: { id: newBookingSeat.id }, + data: { paymentId: payment.id }, + }); + } + resultBooking = { ...foundBooking }; resultBooking["message"] = "Payment required"; resultBooking["paymentUid"] = payment?.uid; diff --git a/packages/features/bookings/lib/payment/getBooking.ts b/packages/features/bookings/lib/payment/getBooking.ts index b1917a34692899..0b450675464600 100644 --- a/packages/features/bookings/lib/payment/getBooking.ts +++ b/packages/features/bookings/lib/payment/getBooking.ts @@ -22,6 +22,8 @@ async function getEventType(id: number) { recurringEvent: true, requiresConfirmation: true, metadata: true, + seatsPerTimeSlot: true, + seatsShowAttendees: true, }, }); } @@ -33,6 +35,26 @@ export async function getBooking(bookingId: number) { select: { ...bookingMinimalSelect, responses: true, + attendees: { + select: { + id: true, + name: true, + email: true, + timeZone: true, + locale: true, + bookingSeat: { + select: { + id: true, + referenceUid: true, + data: true, + metadata: true, + bookingId: true, + attendeeId: true, + paymentId: true, + }, + }, + }, + }, eventType: { select: { owner: { @@ -142,6 +164,7 @@ export async function getBooking(bookingId: number) { translate: await getTranslation(attendee.locale ?? "en", "common"), locale: attendee.locale ?? "en", }, + bookingSeat: attendee.bookingSeat, }; }); @@ -195,6 +218,8 @@ export async function getBooking(bookingId: number) { destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [], recurringEvent: parseRecurringEvent(eventType?.recurringEvent), customReplyToEmail: booking.eventType?.customReplyToEmail, + seatsPerTimeSlot: eventType?.seatsPerTimeSlot, + seatsShowAttendees: eventType?.seatsShowAttendees, }; return { diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 8b5fd15f44a612..a580f128b97bc0 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -1802,7 +1802,7 @@ async function handler( // Save description to bookingSeat const uniqueAttendeeId = uuid(); - await deps.prismaClient.bookingSeat.create({ + const newBookingSeat = await deps.prismaClient.bookingSeat.create({ data: { referenceUid: uniqueAttendeeId, data: { @@ -1821,8 +1821,37 @@ async function handler( }, }, }, + select: { + id: true, + referenceUid: true, + data: true, + metadata: true, + bookingId: true, + attendeeId: true, + }, }); evt.attendeeSeatId = uniqueAttendeeId; + + evt = { + ...evt, + attendees: evt.attendees.map((attendee) => { + if (attendee.email === bookerEmail) { + return { + ...attendee, + bookingSeat: { + id: newBookingSeat.id, + referenceUid: newBookingSeat.referenceUid, + data: newBookingSeat.data, + metadata: newBookingSeat.metadata, + bookingId: newBookingSeat.bookingId, + attendeeId: newBookingSeat.attendeeId, + paymentId: null, + }, + }; + } + return attendee; + }), + }; } } else { const { booking: dryRunBooking, troubleshooterData: _troubleshooterData } = buildDryRunBooking({ @@ -2347,6 +2376,14 @@ async function handler( bookingFields: eventType.bookingFields, locale: language, }); + + if (payment?.id && evt.attendeeSeatId) { + await deps.prismaClient.bookingSeat.update({ + where: { referenceUid: evt.attendeeSeatId }, + data: { paymentId: payment.id }, + }); + } + const subscriberOptionsPaymentInitiated: GetSubscriberOptions = { userId: triggerForUser ? organizerUser.id : null, eventTypeId, diff --git a/packages/prisma/migrations/20251127152324_add_payment_id_to_booking_seat/migration.sql b/packages/prisma/migrations/20251127152324_add_payment_id_to_booking_seat/migration.sql new file mode 100644 index 00000000000000..e39f1e04fa86fb --- /dev/null +++ b/packages/prisma/migrations/20251127152324_add_payment_id_to_booking_seat/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[paymentId]` on the table `BookingSeat` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "public"."BookingSeat" ADD COLUMN "paymentId" INTEGER; + +-- CreateIndex +CREATE UNIQUE INDEX "BookingSeat_paymentId_key" ON "public"."BookingSeat"("paymentId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 0241e5820960c4..355a03e2498180 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1626,6 +1626,7 @@ model BookingSeat { booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade) attendeeId Int @unique attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade) + paymentId Int? @unique /// @zod.import(["import { bookingSeatDataSchema } from '../../zod-utils'"]).custom.use(bookingSeatDataSchema) data Json? metadata Json? @@ -2893,4 +2894,4 @@ model CalendarCacheEvent { @@unique([selectedCalendarId, externalId]) @@index([start, end, status]) @@index([selectedCalendarId, iCalUID]) -} +} \ No newline at end of file