Files
calendar/packages/features/bookings/lib/handleCancelBooking.ts
T
Benny JooGitHubbenny@cal.com <sldisek783@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
3aab13b4da refactor: circular deps between app store and lib [3] (#23742)
* getEnabledAppsFromCredentials

* wip

* wip

* mv more

* fix: update test mocking for PrismockClient in processPaymentRefund.test.ts

- Replace prismaMock with prismock for proper PrismockClient usage
- Update mock data structure to include required fields (id, userId, teamId, etc.)
- Create app and credential records separately to handle PrismockClient limitations
- Replace 'as any' type casting with vi.mocked() for proper type safety
- Adjust test expectations to match PrismockClient's actual return structure
- Add proper cleanup in beforeEach hook for both credential and app records

Fixes failing unit tests after migration from packages/lib to packages/features/bookings/lib/payment

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* publish platform libraries

* fix import

* bump version

* fix apiv2

* publish platform-libraries

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-14 11:24:16 -03:00

590 lines
20 KiB
TypeScript

import type { z } from "zod";
import { DailyLocationType } from "@calcom/app-store/constants";
import { FAKE_DAILY_CREDENTIAL } from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
import dayjs from "@calcom/dayjs";
import { sendCancelledEmailsAndSMS } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { processNoShowFeeOnCancellation } from "@calcom/features/bookings/lib/payment/processNoShowFeeOnCancellation";
import { processPaymentRefund } from "@calcom/features/bookings/lib/payment/processPaymentRefund";
import { sendCancelledReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import {
deleteWebhookScheduledTriggers,
cancelNoShowTasksForBooking,
} from "@calcom/features/webhooks/lib/scheduleTrigger";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import EventManager from "@calcom/lib/EventManager";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { Prisma, WorkflowReminder } from "@calcom/prisma/client";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import { bookingMetadataSchema, bookingCancelInput } from "@calcom/prisma/zod-utils";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import { getAllWorkflowsFromEventType } from "@calcom/trpc/server/routers/viewer/workflows/util";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { getAllCredentialsIncludeServiceAccountKey } from "./getAllCredentialsForUsersOnEvent/getAllCredentials";
import { getBookingToDelete } from "./getBookingToDelete";
import { handleInternalNote } from "./handleInternalNote";
import cancelAttendeeSeat from "./handleSeats/cancel/cancelAttendeeSeat";
const log = logger.getSubLogger({ prefix: ["handleCancelBooking"] });
type PlatformParams = {
platformClientId?: string;
platformRescheduleUrl?: string;
platformCancelUrl?: string;
platformBookingUrl?: string;
arePlatformEmailsEnabled?: boolean;
};
export type BookingToDelete = Awaited<ReturnType<typeof getBookingToDelete>>;
export type CancelBookingInput = {
userId?: number;
bookingData: z.infer<typeof bookingCancelInput>;
} & PlatformParams;
export type HandleCancelBookingResponse = {
success: boolean;
message: string;
onlyRemovedAttendee: boolean;
bookingId: number;
bookingUid: string;
};
async function handler(input: CancelBookingInput) {
const body = input.bookingData;
const {
id,
uid,
allRemainingBookings,
cancellationReason,
seatReferenceUid,
cancelledBy,
cancelSubsequentBookings,
internalNote,
} = bookingCancelInput.parse(body);
const bookingToDelete = await getBookingToDelete(id, uid);
const {
userId,
platformBookingUrl,
platformCancelUrl,
platformClientId,
platformRescheduleUrl,
arePlatformEmailsEnabled,
} = input;
/**
* Important: We prevent cancelling an already cancelled booking.
* A booking could have been CANCELLED due to a reschedule,
* in which case we simply update the existing calendar event and meeting.
* We want to avoid deleting them by a subsequent cancellation attempt.
*/
if (bookingToDelete.status === BookingStatus.CANCELLED) {
throw new HttpError({
statusCode: 400,
message: "This booking has already been cancelled.",
});
}
if (!bookingToDelete.userId || !bookingToDelete.user) {
throw new HttpError({ statusCode: 400, message: "User not found" });
}
if (bookingToDelete.eventType?.disableCancelling) {
throw new HttpError({
statusCode: 400,
message: "This event type does not allow cancellations",
});
}
const isCancellationUserHost =
bookingToDelete.userId == userId || bookingToDelete.user.email === cancelledBy;
if (!platformClientId && !cancellationReason?.trim() && isCancellationUserHost) {
throw new HttpError({
statusCode: 400,
message: "Cancellation reason is required when you are the host",
});
}
if (bookingToDelete.endTime && new Date() > new Date(bookingToDelete.endTime)) {
throw new HttpError({
statusCode: 400,
message: "Cannot cancel a booking that has already ended",
});
}
// If the booking is a seated event and there is no seatReferenceUid we should validate that logged in user is host
if (bookingToDelete.eventType?.seatsPerTimeSlot && !seatReferenceUid) {
const userIsHost = bookingToDelete.eventType.hosts.find((host) => {
if (host.user.id === userId) return true;
});
const userIsOwnerOfEventType = bookingToDelete.eventType.owner?.id === userId;
if (!userIsHost && !userIsOwnerOfEventType) {
throw new HttpError({ statusCode: 401, message: "User not a host of this event" });
}
}
// get webhooks
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: bookingToDelete.eventType?.team?.id ?? null },
parentId: bookingToDelete?.eventType?.parentId ?? null,
},
});
const triggerForUser = !teamId || (teamId && bookingToDelete.eventType?.parentId);
const organizerUserId = triggerForUser ? bookingToDelete.userId : null;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId });
const subscriberOptions: GetSubscriberOptions = {
userId: organizerUserId,
eventTypeId: bookingToDelete.eventTypeId as number,
triggerEvent: eventTrigger,
teamId,
orgId,
oAuthClientId: platformClientId,
};
const eventTypeInfo: EventTypeInfo = {
eventTitle: bookingToDelete?.eventType?.title || null,
eventDescription: bookingToDelete?.eventType?.description || null,
requiresConfirmation: bookingToDelete?.eventType?.requiresConfirmation || null,
price: bookingToDelete?.eventType?.price || null,
currency: bookingToDelete?.eventType?.currency || null,
length: bookingToDelete?.eventType?.length || null,
};
const webhooks = await getWebhooks(subscriberOptions);
const organizer = await prisma.user.findUniqueOrThrow({
where: {
id: bookingToDelete.userId,
},
select: {
id: true,
username: true,
name: true,
email: true,
timeZone: true,
timeFormat: true,
locale: true,
},
});
const teamMembersPromises = [];
const attendeesListPromises = [];
const hostEmails = new Set(bookingToDelete.eventType?.hosts?.map((host) => host.user.email) ?? []);
for (let index = 0; index < bookingToDelete.attendees.length; index++) {
const attendee = bookingToDelete.attendees[index];
const attendeeObject = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
phoneNumber: attendee.phoneNumber,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
// The first attendee is the booker in all cases, so always consider them as an attendee.
if (index === 0) {
attendeesListPromises.push(attendeeObject);
} else {
const isTeamEvent = hostEmails.size > 0;
const isTeamMember = isTeamEvent && hostEmails.has(attendee.email);
if (isTeamMember) {
teamMembersPromises.push(attendeeObject);
} else {
attendeesListPromises.push(attendeeObject);
}
}
}
const attendeesList = await Promise.all(attendeesListPromises);
const teamMembers = await Promise.all(teamMembersPromises);
const tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
const ownerProfile = await prisma.profile.findFirst({
where: {
userId: bookingToDelete.userId,
},
});
const bookerUrl = await getBookerBaseUrl(
bookingToDelete.eventType?.team?.parentId ?? ownerProfile?.organizationId ?? null
);
const evt: CalendarEvent = {
bookerUrl,
title: bookingToDelete?.title,
length: bookingToDelete?.eventType?.length,
type: bookingToDelete?.eventType?.slug as string,
additionalNotes: bookingToDelete?.description,
description: bookingToDelete.eventType?.description,
customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs),
eventTypeId: bookingToDelete.eventTypeId as number,
...getCalEventResponses({
bookingFields: bookingToDelete.eventType?.bookingFields ?? null,
booking: bookingToDelete,
}),
startTime: bookingToDelete?.startTime ? dayjs(bookingToDelete.startTime).format() : "",
endTime: bookingToDelete?.endTime ? dayjs(bookingToDelete.endTime).format() : "",
organizer: {
id: organizer.id,
username: organizer.username || undefined,
email: bookingToDelete?.userPrimaryEmail ?? organizer.email,
name: organizer.name ?? "Nameless",
timeZone: organizer.timeZone,
timeFormat: getTimeFormatStringFromUserTimeFormat(organizer.timeFormat),
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
},
attendees: attendeesList,
uid: bookingToDelete?.uid,
bookingId: bookingToDelete?.id,
/* Include recurringEvent information only when cancelling all bookings */
recurringEvent: allRemainingBookings
? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent)
: undefined,
location: bookingToDelete?.location,
destinationCalendar: bookingToDelete?.destinationCalendar
? [bookingToDelete?.destinationCalendar]
: bookingToDelete?.user.destinationCalendar
? [bookingToDelete?.user.destinationCalendar]
: [],
cancellationReason: cancellationReason,
...(teamMembers &&
teamId && {
team: {
name: bookingToDelete?.eventType?.team?.name || "Nameless",
members: teamMembers,
id: teamId,
},
}),
seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot,
seatsShowAttendees: bookingToDelete.eventType?.seatsShowAttendees,
iCalUID: bookingToDelete.iCalUID,
iCalSequence: bookingToDelete.iCalSequence + 1,
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
hideOrganizerEmail: bookingToDelete.eventType?.hideOrganizerEmail,
platformBookingUrl,
customReplyToEmail: bookingToDelete.eventType?.customReplyToEmail,
};
const dataForWebhooks = { evt, webhooks, eventTypeInfo };
// If it's just an attendee of a booking then just remove them from that booking
const result = await cancelAttendeeSeat(
{
seatReferenceUid: seatReferenceUid,
bookingToDelete,
},
dataForWebhooks,
bookingToDelete?.eventType?.metadata as EventTypeMetadata
);
if (result)
return {
success: true,
onlyRemovedAttendee: true,
bookingId: bookingToDelete.id,
bookingUid: bookingToDelete.uid,
message: "Attendee successfully removed.",
} satisfies HandleCancelBookingResponse;
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
...eventTypeInfo,
status: "CANCELLED",
smsReminderNumber: bookingToDelete.smsReminderNumber || undefined,
cancelledBy: cancelledBy,
}).catch((e) => {
logger.error(
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
);
})
);
await Promise.all(promises);
const workflows = await getAllWorkflowsFromEventType(bookingToDelete.eventType, bookingToDelete.userId);
const parsedMetadata = bookingMetadataSchema.safeParse(bookingToDelete.metadata || {});
await sendCancelledReminders({
workflows,
smsReminderNumber: bookingToDelete.smsReminderNumber,
evt: {
...evt,
...(parsedMetadata.success && parsedMetadata.data?.videoCallUrl
? { metadata: { videoCallUrl: parsedMetadata.data.videoCallUrl } }
: {}),
bookerUrl,
...{
eventType: {
slug: bookingToDelete.eventType?.slug as string,
schedulingType: bookingToDelete.eventType?.schedulingType,
hosts: bookingToDelete.eventType?.hosts,
},
},
},
hideBranding: !!bookingToDelete.eventType?.owner?.hideBranding,
});
let updatedBookings: {
id: number;
uid: string;
workflowReminders: WorkflowReminder[];
references: {
type: string;
credentialId: number | null;
uid: string;
externalCalendarId: string | null;
}[];
startTime: Date;
endTime: Date;
}[] = [];
// by cancelling first, and blocking whilst doing so; we can ensure a cancel
// action always succeeds even if subsequent integrations fail cancellation.
if (
bookingToDelete.eventType?.recurringEvent &&
bookingToDelete.recurringEventId &&
(allRemainingBookings || cancelSubsequentBookings)
) {
const recurringEventId = bookingToDelete.recurringEventId;
const gte = cancelSubsequentBookings ? bookingToDelete.startTime : new Date();
// Proceed to mark as cancelled all remaining recurring events instances (greater than or equal to right now)
await prisma.booking.updateMany({
where: {
recurringEventId,
startTime: {
gte,
},
},
data: {
status: BookingStatus.CANCELLED,
cancellationReason: cancellationReason,
cancelledBy: cancelledBy,
},
});
const allUpdatedBookings = await prisma.booking.findMany({
where: {
recurringEventId: bookingToDelete.recurringEventId,
startTime: {
gte: new Date(),
},
},
select: {
id: true,
startTime: true,
endTime: true,
references: {
select: {
uid: true,
type: true,
externalCalendarId: true,
credentialId: true,
},
},
workflowReminders: true,
uid: true,
},
});
updatedBookings = updatedBookings.concat(allUpdatedBookings);
} else {
if (bookingToDelete?.eventType?.seatsPerTimeSlot) {
await prisma.attendee.deleteMany({
where: {
bookingId: bookingToDelete.id,
},
});
}
const where: Prisma.BookingWhereUniqueInput = uid ? { uid } : { id };
const updatedBooking = await prisma.booking.update({
where,
data: {
status: BookingStatus.CANCELLED,
cancellationReason: cancellationReason,
cancelledBy: cancelledBy,
// Assume that canceling the booking is the last action
iCalSequence: evt.iCalSequence || 100,
},
select: {
id: true,
startTime: true,
endTime: true,
references: {
select: {
uid: true,
type: true,
externalCalendarId: true,
credentialId: true,
},
},
workflowReminders: true,
uid: true,
},
});
updatedBookings.push(updatedBooking);
if (bookingToDelete.payment.some((payment) => payment.paymentOption === "ON_BOOKING")) {
try {
await processPaymentRefund({
booking: bookingToDelete,
teamId,
});
} catch (error) {
log.error(`Error processing payment refund for booking ${bookingToDelete.uid}:`, error);
}
} else if (bookingToDelete.payment.some((payment) => payment.paymentOption === "HOLD")) {
try {
await processNoShowFeeOnCancellation({
booking: bookingToDelete,
payments: bookingToDelete.payment,
cancelledByUserId: userId,
});
} catch (error) {
log.error(`Error processing no-show fee for booking ${bookingToDelete.uid}:`, error);
}
}
}
/** TODO: Remove this without breaking functionality */
if (bookingToDelete.location === DailyLocationType) {
bookingToDelete.user.credentials.push({
...FAKE_DAILY_CREDENTIAL,
teamId: bookingToDelete.eventType?.team?.id || null,
});
}
const isBookingInRecurringSeries = !!(
bookingToDelete.eventType?.recurringEvent &&
bookingToDelete.recurringEventId &&
allRemainingBookings
);
try {
const bookingToDeleteEventTypeMetadataParsed = eventTypeMetaDataSchemaWithTypedApps.safeParse(
bookingToDelete.eventType?.metadata || null
);
if (!bookingToDeleteEventTypeMetadataParsed.success) {
log.error(
`Error parsing metadata`,
safeStringify({ error: bookingToDeleteEventTypeMetadataParsed?.error })
);
throw new Error("Error parsing metadata");
}
const bookingToDeleteEventTypeMetadata = bookingToDeleteEventTypeMetadataParsed.data;
const credentials = await getAllCredentialsIncludeServiceAccountKey(bookingToDelete.user, {
...bookingToDelete.eventType,
metadata: bookingToDeleteEventTypeMetadata,
});
const eventManager = new EventManager(
{ ...bookingToDelete.user, credentials },
bookingToDeleteEventTypeMetadata?.apps
);
await eventManager.cancelEvent(evt, bookingToDelete.references, isBookingInRecurringSeries);
await prisma.bookingReference.deleteMany({
where: {
bookingId: bookingToDelete.id,
},
});
} catch (error) {
log.error(`Error deleting integrations`, safeStringify({ error }));
}
try {
const webhookTriggerPromises = [];
const workflowReminderPromises = [];
for (const booking of updatedBookings) {
// delete scheduled webhook triggers of cancelled bookings
webhookTriggerPromises.push(deleteWebhookScheduledTriggers({ booking }));
webhookTriggerPromises.push(cancelNoShowTasksForBooking({ bookingUid: booking.uid }));
//Workflows - cancel all reminders for cancelled bookings
workflowReminderPromises.push(WorkflowRepository.deleteAllWorkflowReminders(booking.workflowReminders));
}
await Promise.allSettled([...webhookTriggerPromises, ...workflowReminderPromises]).then((results) => {
const rejectedReasons = results
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
.map((result) => result.reason);
if (rejectedReasons.length > 0) {
log.error("An error occurred when deleting workflow reminders and webhook triggers", rejectedReasons);
}
});
} catch (error) {
log.error("Error deleting scheduled webhooks and workflows", safeStringify({ error }));
}
try {
if (internalNote && teamId) {
await handleInternalNote({
internalNote,
booking: bookingToDelete,
userId: userId || -1,
teamId: teamId,
});
}
} catch (error) {
log.error("Error handlingInternalNote", safeStringify({ error }));
}
try {
// TODO: if emails fail try to requeue them
if (!platformClientId || (platformClientId && arePlatformEmailsEnabled))
await sendCancelledEmailsAndSMS(
evt,
{ eventName: bookingToDelete?.eventType?.eventName },
bookingToDelete?.eventType?.metadata as EventTypeMetadata
);
} catch (error) {
log.error("Error deleting event", error);
}
return {
success: true,
message: "Booking successfully cancelled.",
onlyRemovedAttendee: false,
bookingId: bookingToDelete.id,
bookingUid: bookingToDelete.uid,
} satisfies HandleCancelBookingResponse;
}
export default handler;