Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions packages/app-store/_utils/payments/handlePaymentSuccess.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
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";
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) {
Expand Down Expand Up @@ -96,7 +101,52 @@ 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);
}
}

// Check if this is a new seat (if there are other attendees already)
const newSeat = evt.attendees.length > 1;

// Send emails only to the attendee who just paid
await sendScheduledSeatsEmailsAndSMS(
evt,
attendeeWhoPaid,
newSeat,
!!evt.seatsShowAttendees,
isHostConfirmationEmailsDisabled,
isAttendeeConfirmationEmailDisabled,
eventTypeMetadata
);
}
} else {
// For non-seated events, send regular emails to all attendees
await sendScheduledEmailsAndSMS({ ...evt }, undefined, undefined, undefined, eventTypeMetadata);
}
}

throw new HttpCode({
Expand Down
4 changes: 2 additions & 2 deletions packages/emails/email-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ const _sendScheduledEmailsAndSMS = async (
title: getEventName({ ...eventNameObject, t: attendee.language.translate }),
}),
},
attendee
attendee,
formattedCalEvent.seatsShowAttendees ?? undefined
)
);
})
Expand Down Expand Up @@ -531,7 +532,6 @@ export const sendAwaitingPaymentEmailAndSMS = async (
await awaitingPaymentSMS.sendSMSToAttendees();
};


export const sendRequestRescheduleEmailAndSMS = async (
calEvent: CalendarEvent,
metadata: { rescheduleLink: string },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,13 @@ const createNewSeat = async (
}
const copyEvent = cloneDeep(evt);
copyEvent.uid = seatedBooking.uid;
if (noEmail !== true) {

// Check if payment is required BEFORE sending emails
const requiresPayment = !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!seatedBooking;

// Only send confirmation emails if payment is NOT required
// If payment is required, emails will be sent after payment is completed via handlePaymentSuccess
if (noEmail !== true && !requiresPayment) {
let isHostConfirmationEmailsDisabled = false;
let isAttendeeConfirmationEmailDisabled = false;

Expand Down Expand Up @@ -158,7 +164,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 }),
Expand Down Expand Up @@ -209,6 +215,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;
Expand Down
26 changes: 26 additions & 0 deletions packages/features/bookings/lib/payment/getBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ async function getEventType(id: number) {
recurringEvent: true,
requiresConfirmation: true,
metadata: true,
seatsPerTimeSlot: true,
seatsShowAttendees: true,
},
});
}
Expand All @@ -33,6 +35,27 @@ export async function getBooking(bookingId: number) {
select: {
...bookingMinimalSelect,
responses: true,
attendees: {
select: {
id: true,
name: true,
email: true,
timeZone: true,
locale: true,
phoneNumber: true,
bookingSeat: {
select: {
id: true,
referenceUid: true,
data: true,
metadata: true,
bookingId: true,
attendeeId: true,
paymentId: true,
},
},
},
},
eventType: {
select: {
owner: {
Expand Down Expand Up @@ -142,6 +165,7 @@ export async function getBooking(bookingId: number) {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
bookingSeat: attendee.bookingSeat,
};
});

Expand Down Expand Up @@ -195,6 +219,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 {
Expand Down
79 changes: 59 additions & 20 deletions packages/features/bookings/lib/service/RegularBookingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1263,9 +1263,9 @@ async function handler(
// This ensures that createMeeting isn't called for static video apps as bookingLocation becomes just a regular value for them.
const { bookingLocation, conferenceCredentialId } = organizerOrFirstDynamicGroupMemberDefaultLocationUrl
? {
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
conferenceCredentialId: undefined,
}
bookingLocation: organizerOrFirstDynamicGroupMemberDefaultLocationUrl,
conferenceCredentialId: undefined,
}
: getLocationValueForDB(locationBodyString, eventType.locations);

tracingLogger.info("locationBodyString", locationBodyString);
Expand Down Expand Up @@ -1311,8 +1311,8 @@ async function handler(
const destinationCalendar = eventType.destinationCalendar
? [eventType.destinationCalendar]
: organizerUser.destinationCalendar
? [organizerUser.destinationCalendar]
: null;
? [organizerUser.destinationCalendar]
: null;

let organizerEmail = organizerUser.email || "Email-less";
if (eventType.useEventTypeDestinationCalendarEmail && destinationCalendar?.[0]?.primaryEmail) {
Expand Down Expand Up @@ -1801,7 +1801,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: {
Expand All @@ -1820,8 +1820,39 @@ async function handler(
},
},
},
select: {
id: true,
referenceUid: true,
data: true,
metadata: true,
bookingId: true,
attendeeId: true,
},
});
evt.attendeeSeatId = uniqueAttendeeId;

// Update the evt attendees to include the bookingSeat information
// This is needed for the payment service to correctly filter attendees for email sending
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, // updated after payment is processed in handleSeats
},
};
}
return attendee;
}),
};
}
} else {
const { booking: dryRunBooking, troubleshooterData: _troubleshooterData } = buildDryRunBooking({
Expand Down Expand Up @@ -1931,14 +1962,14 @@ async function handler(
}
const updateManager = !skipCalendarSyncTaskCreation
? await eventManager.reschedule(
evt,
originalRescheduledBooking.uid,
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
)
evt,
originalRescheduledBooking.uid,
undefined,
changedOrganizer,
previousHostDestinationCalendar,
isBookingRequestedReschedule,
skipDeleteEventsAndMeetings
)
: placeholderCreatedEvent;
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
// to the default description when we are sending the emails.
Expand Down Expand Up @@ -2237,8 +2268,8 @@ async function handler(

const metadata = videoCallUrl
? {
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
}
videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl,
}
: undefined;

const bookingFlowConfig = {
Expand Down Expand Up @@ -2331,9 +2362,9 @@ async function handler(
...eventType,
metadata: eventType.metadata
? {
...eventType.metadata,
apps: eventType.metadata?.apps as Prisma.JsonValue,
}
...eventType.metadata,
apps: eventType.metadata?.apps as Prisma.JsonValue,
}
: {},
},
paymentAppCredentials: eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
Expand All @@ -2345,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,
Expand Down Expand Up @@ -2630,7 +2669,7 @@ async function handler(
* We are open to renaming it to something more descriptive.
*/
export class RegularBookingService implements IBookingService {
constructor(private readonly deps: IBookingServiceDependencies) { }
constructor(private readonly deps: IBookingServiceDependencies) {}

async createBooking(input: { bookingData: CreateRegularBookingData; bookingMeta?: CreateBookingMeta }) {
return handler({ bookingData: input.bookingData, ...input.bookingMeta }, this.deps);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/*
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");

-- CreateIndex
CREATE INDEX "BookingSeat_paymentId_idx" ON "public"."BookingSeat"("paymentId");
4 changes: 3 additions & 1 deletion packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1626,12 +1626,14 @@ 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?

@@index([bookingId])
@@index([attendeeId])
@@index([paymentId])
}

model VerifiedNumber {
Expand Down Expand Up @@ -2778,4 +2780,4 @@ model CalendarCacheEvent {
@@unique([selectedCalendarId, externalId])
@@index([start, end, status])
@@index([selectedCalendarId, iCalUID])
}
}
Loading