Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
38 changes: 37 additions & 1 deletion apps/web/modules/bookings/views/bookings-single-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,39 @@ export default function Success(props: PageProps) {
const status = bookingInfo?.status;
const reschedule = bookingInfo.status === BookingStatus.ACCEPTED;
const cancellationReason = bookingInfo.cancellationReason || bookingInfo.rejectionReason;
const isAwaitingPayment = props.paymentStatus && !props.paymentStatus.success;

// Handle Stripe redirect after 3D Secure authentication
// When redirect_status=succeeded is present, the payment webhook might not have processed yet
const [isCheckingPayment, setIsCheckingPayment] = useState(false);
const redirectStatus = searchParams?.get("redirect_status");
const paymentIntent = searchParams?.get("payment_intent");

useEffect(() => {
// If we just got redirected from Stripe with success status, but payment is not confirmed yet
if (redirectStatus === "succeeded" && paymentIntent && props.paymentStatus && !props.paymentStatus.success) {
setIsCheckingPayment(true);

// Poll for payment status by reloading the page every 2 seconds for up to 30 seconds
const maxAttempts = 15;
let attempts = 0;

const pollInterval = setInterval(() => {
attempts++;

// Reload the page to check updated payment status from server
window.location.reload();

// Stop polling after max attempts (though reload will clear the interval anyway)
if (attempts >= maxAttempts) {
clearInterval(pollInterval);
}
}, 2000);

return () => clearInterval(pollInterval);
}
}, [redirectStatus, paymentIntent, props.paymentStatus]);

const isAwaitingPayment = props.paymentStatus && !props.paymentStatus.success && !isCheckingPayment;

const attendees = bookingInfo?.attendees;

Expand Down Expand Up @@ -393,6 +425,10 @@ export default function Success(props: PageProps) {
const canReschedule = !eventType?.disableRescheduling;

const successPageHeadline = (() => {
if (isCheckingPayment && !isCancelled) {
return "Processing payment...";
}

if (isAwaitingPayment && !isCancelled) {
return props.paymentStatus?.paymentOption === "HOLD"
? t("meeting_awaiting_payment_method")
Expand Down
92 changes: 89 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,88 @@ 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 who just paid by checking which attendee's booking was just updated
// The payment is linked to a specific booking, and we need to find the most recent attendee
// For seated events with payment, we should only email the person who just paid

// Get the booking with attendees to find who just paid
const bookingWithAttendees = await prisma.booking.findUnique({
where: { id: bookingId },
select: {
attendees: {
select: {
id: true,
email: true,
name: true,
timeZone: true,
locale: true,
phoneNumber: true,
bookingSeat: {
select: {
id: true,
referenceUid: true,
bookingId: true,
},
},
},
orderBy: {
id: "desc",
},
},
},
});

// The most recently added attendee is the one who just paid
const newestAttendee = bookingWithAttendees?.attendees[0];

if (newestAttendee) {
// Find this attendee in the evt.attendees array
const attendeeWhoPaid = evt.attendees.find((a) => a.email === newestAttendee.email);

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
22 changes: 22 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,24 @@ 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,
},
},
},
},
eventType: {
select: {
owner: {
Expand Down Expand Up @@ -195,6 +215,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
70 changes: 50 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,38 @@ 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,
},
};
}
return attendee;
}),
};
}
} else {
const { booking: dryRunBooking, troubleshooterData: _troubleshooterData } = buildDryRunBooking({
Expand Down Expand Up @@ -1931,14 +1961,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 +2267,8 @@ async function handler(

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

const bookingFlowConfig = {
Expand Down Expand Up @@ -2331,9 +2361,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 Down Expand Up @@ -2630,7 +2660,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
Loading