diff --git a/apps/web/components/booking/CancelBooking.tsx b/apps/web/components/booking/CancelBooking.tsx
index bd57791e52f61c..4591aa92b54cb8 100644
--- a/apps/web/components/booking/CancelBooking.tsx
+++ b/apps/web/components/booking/CancelBooking.tsx
@@ -6,10 +6,11 @@ import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
+import { shouldChargeNoShowCancellationFee } from "@calcom/lib/payment/shouldChargeNoShowCancellationFee";
import { collectPageParameters, telemetryEventTypes } from "@calcom/lib/telemetry";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Button } from "@calcom/ui/components/button";
-import { Label, Select, TextArea } from "@calcom/ui/components/form";
+import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
interface InternalNotePresetsSelectProps {
@@ -76,6 +77,12 @@ type Props = {
title?: string;
uid?: string;
id?: number;
+ startTime: Date;
+ payment?: {
+ amount: number;
+ currency: string;
+ appId: string | null;
+ } | null;
};
profile: {
name: string | null;
@@ -100,6 +107,7 @@ type Props = {
};
isHost: boolean;
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
+ eventTypeMetadata?: Record | null;
};
export default function CancelBooking(props: Props) {
@@ -112,13 +120,48 @@ export default function CancelBooking(props: Props) {
seatReferenceUid,
bookingCancelledEventProps,
currentUserEmail,
- teamId,
+ eventTypeMetadata,
} = props;
const [loading, setLoading] = useState(false);
const telemetry = useTelemetry();
const [error, setError] = useState(booking ? null : t("booking_already_cancelled"));
const [internalNote, setInternalNote] = useState<{ id: number; name: string } | null>(null);
+ const [acknowledgeCancellationNoShowFee, setAcknowledgeCancellationNoShowFee] = useState(false);
+ const getAppMetadata = (appId: string): Record | null => {
+ if (!eventTypeMetadata?.apps || !appId) return null;
+ const apps = eventTypeMetadata.apps as Record;
+ return (apps[appId] as Record) || null;
+ };
+
+ const timeValue = booking?.payment?.appId
+ ? (getAppMetadata(booking.payment.appId) as Record | null)?.autoChargeNoShowFeeTimeValue
+ : null;
+ const timeUnit = booking?.payment?.appId
+ ? (getAppMetadata(booking.payment.appId) as Record | null)?.autoChargeNoShowFeeTimeUnit
+ : null;
+
+ const autoChargeNoShowFee = () => {
+ if (props.isHost) return false; // Hosts/organizers are exempt
+
+ if (!booking?.startTime) return false;
+
+ if (!booking?.payment) return false;
+
+ return shouldChargeNoShowCancellationFee({
+ eventTypeMetadata: eventTypeMetadata || null,
+ booking,
+ payment: booking.payment,
+ });
+ };
+
+ const cancellationNoShowFeeWarning = autoChargeNoShowFee();
+
+ const hostMissingCancellationReason =
+ props.isHost &&
+ (!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id));
+ const cancellationNoShowFeeNotAcknowledged =
+ !props.isHost && cancellationNoShowFeeWarning && !acknowledgeCancellationNoShowFee;
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
if (node !== null) {
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode
@@ -187,6 +230,23 @@ export default function CancelBooking(props: Props) {
) : null}
+ {cancellationNoShowFeeWarning && booking?.payment && (
+
-
{seatsEnabled && paymentOption === "HOLD" && (
)}
-
{paymentOption !== "HOLD" && (
@@ -210,6 +218,54 @@ const EventTypeAppSettingsInterface: EventTypeAppSettingsComponent = ({
)}
+ {paymentOption === "HOLD" && (
+
+
+ setAppData("autoChargeNoShowFeeIfCancelled", e.target.checked)}
+ description={t("auto_charge_for_last_minute_cancellation")}
+ />
+
+ {autoChargeNoShowFeeIfCancelled && (
+
+
+
+ setAppData("autoChargeNoShowFeeTimeValue", parseInt(e.currentTarget.value))
+ }
+ />
+
+
+ )}
+
+ )}
>
)}
>
diff --git a/packages/app-store/stripepayment/zod.ts b/packages/app-store/stripepayment/zod.ts
index 4817fa09337845..8b56e982c2374c 100644
--- a/packages/app-store/stripepayment/zod.ts
+++ b/packages/app-store/stripepayment/zod.ts
@@ -14,6 +14,8 @@ const VALUES: [PaymentOption, ...PaymentOption[]] = [
];
export const paymentOptionEnum = z.enum(VALUES);
+export const autoChargeNoShowFeeTimeUnitEnum = z.enum(["minutes", "hours", "days"]);
+
export const appDataSchema = eventTypeAppCardZod.merge(
z.object({
price: z.number(),
@@ -23,6 +25,9 @@ export const appDataSchema = eventTypeAppCardZod.merge(
refundPolicy: z.nativeEnum(RefundPolicy).optional(),
refundDaysCount: z.number().optional(),
refundCountCalendarDays: z.boolean().optional(),
+ autoChargeNoShowFeeIfCancelled: z.boolean().optional(),
+ autoChargeNoShowFeeTimeValue: z.number().optional(),
+ autoChargeNoShowFeeTimeUnit: autoChargeNoShowFeeTimeUnitEnum.optional(),
})
);
diff --git a/packages/features/bookings/lib/getBookingToDelete.ts b/packages/features/bookings/lib/getBookingToDelete.ts
index e218ec254acc75..7310589c515d04 100644
--- a/packages/features/bookings/lib/getBookingToDelete.ts
+++ b/packages/features/bookings/lib/getBookingToDelete.ts
@@ -22,6 +22,7 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
timeFormat: true,
name: true,
destinationCalendar: true,
+ locale: true,
},
},
location: true,
@@ -46,6 +47,7 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
hideBranding: true,
},
},
+ teamId: true,
team: {
select: {
id: true,
diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts
index 2c0b11710fd657..7aae05fd49658f 100644
--- a/packages/features/bookings/lib/handleCancelBooking.ts
+++ b/packages/features/bookings/lib/handleCancelBooking.ts
@@ -23,6 +23,7 @@ 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 { processNoShowFeeOnCancellation } from "@calcom/lib/payment/processNoShowFeeOnCancellation";
import { processPaymentRefund } from "@calcom/lib/payment/processPaymentRefund";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server/i18n";
@@ -194,7 +195,6 @@ async function handler(input: CancelBookingInput) {
const teamMembersPromises = [];
const attendeesListPromises = [];
- const hostsPresent = !!bookingToDelete.eventType?.hosts;
const hostEmails = new Set(bookingToDelete.eventType?.hosts?.map((host) => host.user.email) ?? []);
for (let index = 0; index < bookingToDelete.attendees.length; index++) {
@@ -455,11 +455,17 @@ async function handler(input: CancelBookingInput) {
});
updatedBookings.push(updatedBooking);
- if (!!bookingToDelete.payment.length) {
+ if (bookingToDelete.payment.some((payment) => payment.paymentOption === "ON_BOOKING")) {
await processPaymentRefund({
booking: bookingToDelete,
teamId,
});
+ } else if (bookingToDelete.payment.some((payment) => payment.paymentOption === "HOLD")) {
+ await processNoShowFeeOnCancellation({
+ booking: bookingToDelete,
+ payments: bookingToDelete.payment,
+ cancelledByUserId: userId,
+ });
}
}
diff --git a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts
index fdd7eec07b4f41..b8fd0870574756 100644
--- a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts
+++ b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts
@@ -509,4 +509,365 @@ describe("Cancel Booking", () => {
})
).rejects.toThrow("Cannot cancel a booking that has already ended");
});
+
+ test("Should not charge cancellation fee when organizer cancels booking", async () => {
+ const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default;
+
+ const booker = getBooker({
+ email: "booker@example.com",
+ name: "Booker",
+ });
+
+ const organizer = getOrganizer({
+ name: "Organizer",
+ email: "organizer@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ });
+
+ const uidOfBookingToBeCancelled = "cancellation-fee-organizer-test";
+ const idOfBookingToBeCancelled = 4050;
+ const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
+
+ await createBookingScenario(
+ getScenarioData({
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: 30,
+ length: 30,
+ users: [{ id: 101 }],
+ metadata: {
+ apps: {
+ stripe: {
+ enabled: true,
+ paymentOption: "HOLD",
+ price: 1000,
+ currency: "usd",
+ cancellationFeeEnabled: true,
+ cancellationFeeTimeValue: 2,
+ cancellationFeeTimeUnit: "hours",
+ },
+ },
+ },
+ },
+ ],
+ bookings: [
+ {
+ id: idOfBookingToBeCancelled,
+ uid: uidOfBookingToBeCancelled,
+ eventTypeId: 1,
+ userId: 101,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: BookingLocations.CalVideo },
+ },
+ status: BookingStatus.ACCEPTED,
+ startTime: `${plus1DateString}T05:00:00.000Z`,
+ endTime: `${plus1DateString}T05:30:00.000Z`,
+ attendees: [
+ {
+ email: booker.email,
+ timeZone: "Asia/Kolkata",
+ },
+ ],
+ },
+ ],
+ payment: [
+ {
+ amount: 1000,
+ bookingId: idOfBookingToBeCancelled,
+ currency: "usd",
+ data: {},
+ externalId: "ext_id_cancellation",
+ fee: 0,
+ refunded: false,
+ success: false,
+ uid: uidOfBookingToBeCancelled,
+ paymentOption: "HOLD",
+ },
+ ],
+ organizer,
+ apps: [TestData.apps["daily-video"]],
+ })
+ );
+
+ mockSuccessfulVideoMeetingCreation({
+ metadataLookupKey: "dailyvideo",
+ videoMeetingData: {
+ id: "MOCK_ID",
+ password: "MOCK_PASS",
+ url: `http://mock-dailyvideo.example.com/meeting-cancellation`,
+ },
+ });
+
+ mockCalendarToHaveNoBusySlots("googlecalendar", {
+ create: {
+ id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_CANCELLATION",
+ },
+ });
+
+ const result = await handleCancelBooking({
+ bookingData: {
+ id: idOfBookingToBeCancelled,
+ uid: uidOfBookingToBeCancelled,
+ cancelledBy: organizer.email,
+ },
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ test("Should not charge cancellation fee when team admin cancels booking", async () => {
+ const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default;
+
+ const booker = getBooker({
+ email: "booker@example.com",
+ name: "Booker",
+ });
+
+ const organizer = getOrganizer({
+ name: "Team Admin",
+ email: "admin@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ });
+
+ const teamMember = getOrganizer({
+ name: "Team Member",
+ email: "member@example.com",
+ id: 102,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ });
+
+ const uidOfBookingToBeCancelled = "cancellation-fee-team-admin-test";
+ const idOfBookingToBeCancelled = 4060;
+ const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
+
+ await createBookingScenario(
+ getScenarioData({
+ eventTypes: [
+ {
+ id: 2,
+ slotInterval: 30,
+ length: 30,
+ teamId: 1,
+ users: [{ id: 101 }, { id: 102 }],
+ hosts: [
+ { userId: 101, isFixed: false },
+ { userId: 102, isFixed: false },
+ ],
+ metadata: {
+ apps: {
+ stripe: {
+ enabled: true,
+ paymentOption: "HOLD",
+ price: 1000,
+ currency: "usd",
+ cancellationFeeEnabled: true,
+ cancellationFeeTimeValue: 1,
+ cancellationFeeTimeUnit: "hours",
+ },
+ },
+ },
+ },
+ ],
+ bookings: [
+ {
+ id: idOfBookingToBeCancelled,
+ uid: uidOfBookingToBeCancelled,
+ eventTypeId: 2,
+ userId: 102,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: BookingLocations.CalVideo },
+ },
+ status: BookingStatus.ACCEPTED,
+ startTime: `${plus1DateString}T05:00:00.000Z`,
+ endTime: `${plus1DateString}T05:30:00.000Z`,
+ attendees: [
+ {
+ email: booker.email,
+ timeZone: "Asia/Kolkata",
+ },
+ ],
+ },
+ ],
+ payment: [
+ {
+ amount: 1000,
+ bookingId: idOfBookingToBeCancelled,
+ currency: "usd",
+ data: {},
+ externalId: "ext_id_team_cancellation",
+ fee: 0,
+ refunded: false,
+ success: false,
+ uid: uidOfBookingToBeCancelled,
+ paymentOption: "HOLD",
+ },
+ ],
+ teams: [
+ {
+ id: 1,
+ name: "Test Team",
+ slug: "test-team",
+ },
+ ],
+ users: [organizer, teamMember],
+ apps: [TestData.apps["daily-video"]],
+ })
+ );
+
+ mockSuccessfulVideoMeetingCreation({
+ metadataLookupKey: "dailyvideo",
+ videoMeetingData: {
+ id: "MOCK_ID",
+ password: "MOCK_PASS",
+ url: `http://mock-dailyvideo.example.com/meeting-team-cancellation`,
+ },
+ });
+
+ mockCalendarToHaveNoBusySlots("googlecalendar", {
+ create: {
+ id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_TEAM_CANCELLATION",
+ },
+ });
+
+ const result = await handleCancelBooking({
+ bookingData: {
+ id: idOfBookingToBeCancelled,
+ uid: uidOfBookingToBeCancelled,
+ cancelledBy: organizer.email,
+ },
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ test("Should charge cancellation fee when attendee cancels within time threshold", async () => {
+ const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default;
+
+ const booker = getBooker({
+ email: "booker@example.com",
+ name: "Booker",
+ id: 999,
+ });
+
+ const organizer = getOrganizer({
+ name: "Organizer",
+ email: "organizer@example.com",
+ id: 101,
+ schedules: [TestData.schedules.IstWorkHours],
+ credentials: [getGoogleCalendarCredential()],
+ selectedCalendars: [TestData.selectedCalendars.google],
+ });
+
+ const uidOfBookingToBeCancelled = "cancellation-fee-attendee-test";
+ const idOfBookingToBeCancelled = 4070;
+
+ const now = new Date();
+ const startTime = new Date(now.getTime() + 30 * 60 * 1000);
+ const endTime = new Date(startTime.getTime() + 30 * 60 * 1000);
+
+ await createBookingScenario(
+ getScenarioData({
+ eventTypes: [
+ {
+ id: 1,
+ slotInterval: 30,
+ length: 30,
+ users: [{ id: 101 }],
+ metadata: {
+ apps: {
+ stripe: {
+ enabled: true,
+ paymentOption: "HOLD",
+ price: 1000,
+ currency: "usd",
+ cancellationFeeEnabled: true,
+ cancellationFeeTimeValue: 1,
+ cancellationFeeTimeUnit: "hours",
+ },
+ },
+ },
+ },
+ ],
+ bookings: [
+ {
+ id: idOfBookingToBeCancelled,
+ uid: uidOfBookingToBeCancelled,
+ eventTypeId: 1,
+ userId: 101,
+ responses: {
+ email: booker.email,
+ name: booker.name,
+ location: { optionValue: "", value: BookingLocations.CalVideo },
+ },
+ status: BookingStatus.ACCEPTED,
+ startTime: startTime.toISOString(),
+ endTime: endTime.toISOString(),
+ attendees: [
+ {
+ email: booker.email,
+ timeZone: "Asia/Kolkata",
+ },
+ ],
+ },
+ ],
+ payment: [
+ {
+ amount: 1000,
+ bookingId: idOfBookingToBeCancelled,
+ currency: "usd",
+ data: {},
+ externalId: "ext_id_attendee_cancellation",
+ fee: 0,
+ refunded: false,
+ success: false,
+ uid: uidOfBookingToBeCancelled,
+ paymentOption: "HOLD",
+ appId: "stripe",
+ },
+ ],
+ organizer,
+ apps: [TestData.apps["daily-video"]],
+ })
+ );
+
+ mockSuccessfulVideoMeetingCreation({
+ metadataLookupKey: "dailyvideo",
+ videoMeetingData: {
+ id: "MOCK_ID",
+ password: "MOCK_PASS",
+ url: `http://mock-dailyvideo.example.com/meeting-attendee-cancellation`,
+ },
+ });
+
+ mockCalendarToHaveNoBusySlots("googlecalendar", {
+ create: {
+ id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_ATTENDEE_CANCELLATION",
+ },
+ });
+
+ const result = await handleCancelBooking({
+ bookingData: {
+ id: idOfBookingToBeCancelled,
+ uid: uidOfBookingToBeCancelled,
+ cancelledBy: booker.email,
+ cancellationReason: "Attendee cancelled within time threshold",
+ },
+ userId: 999,
+ });
+
+ expect(result.success).toBe(true);
+ });
});
diff --git a/packages/lib/payment/handleNoShowFee.ts b/packages/lib/payment/handleNoShowFee.ts
new file mode 100644
index 00000000000000..df55f679ee5102
--- /dev/null
+++ b/packages/lib/payment/handleNoShowFee.ts
@@ -0,0 +1,179 @@
+import type { Prisma } from "@prisma/client";
+
+// eslint-disable-next-line
+import { PaymentServiceMap } from "@calcom/app-store/payment.services.generated";
+import dayjs from "@calcom/dayjs";
+import { sendNoShowFeeChargedEmail } from "@calcom/emails";
+import { ErrorCode } from "@calcom/lib/errorCodes";
+import { ErrorWithCode } from "@calcom/lib/errors";
+import logger from "@calcom/lib/logger";
+import { getTranslation } from "@calcom/lib/server/i18n";
+import { CredentialRepository } from "@calcom/lib/server/repository/credential";
+import { MembershipRepository } from "@calcom/lib/server/repository/membership";
+import { TeamRepository } from "@calcom/lib/server/repository/team";
+import prisma from "@calcom/prisma";
+import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
+import type { CalendarEvent } from "@calcom/types/Calendar";
+import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
+
+export const handleNoShowFee = async ({
+ booking,
+ payment,
+}: {
+ booking: {
+ id: number;
+ uid: string;
+ title: string;
+ startTime: Date;
+ endTime: Date;
+ userPrimaryEmail: string | null;
+ userId: number | null;
+ user?: {
+ email: string;
+ name?: string | null;
+ locale: string | null;
+ timeZone: string;
+ } | null;
+ eventType: {
+ title: string;
+ hideOrganizerEmail: boolean;
+ teamId: number | null;
+ metadata?: Prisma.JsonValue;
+ } | null;
+ attendees: {
+ name: string;
+ email: string;
+ timeZone: string;
+ locale: string | null;
+ }[];
+ };
+ payment: {
+ id: number;
+ amount: number;
+ currency: string;
+ paymentOption: string | null;
+ appId: string | null;
+ };
+}) => {
+ const log = logger.getSubLogger({ prefix: [`[handleNoShowFee] bookingUid ${booking.uid}`] });
+ const tOrganizer = await getTranslation(booking.user?.locale ?? "en", "common");
+
+ const userId = booking.userId;
+ const teamId = booking.eventType?.teamId;
+ const appId = payment.appId;
+
+ const eventTypeMetdata = eventTypeMetaDataSchemaWithTypedApps.parse(booking.eventType?.metadata ?? {});
+
+ if (!userId) {
+ log.error("User ID is required");
+ throw new Error("User ID is required");
+ }
+
+ const attendeesListPromises = [];
+
+ for (const attendee of booking.attendees) {
+ const attendeeObject = {
+ name: attendee.name,
+ email: attendee.email,
+ timeZone: attendee.timeZone,
+ language: {
+ translate: await getTranslation(attendee.locale ?? "en", "common"),
+ locale: attendee.locale ?? "en",
+ },
+ };
+
+ attendeesListPromises.push(attendeeObject);
+ }
+
+ const attendeesList = await Promise.all(attendeesListPromises);
+
+ const evt: CalendarEvent = {
+ type: (booking?.eventType?.title as string) || booking?.title,
+ title: booking.title,
+ startTime: dayjs(booking.startTime).format(),
+ endTime: dayjs(booking.endTime).format(),
+ organizer: {
+ email: booking?.userPrimaryEmail ?? booking.user?.email ?? "",
+ name: booking.user?.name || "Nameless",
+ timeZone: booking.user?.timeZone || "",
+ language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" },
+ },
+ attendees: attendeesList,
+ hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
+ paymentInfo: {
+ amount: payment.amount,
+ currency: payment.currency,
+ paymentOption: payment.paymentOption,
+ },
+ };
+
+ if (teamId) {
+ const userIsInTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({
+ userId,
+ teamId,
+ });
+
+ if (!userIsInTeam) {
+ log.error(`User ${userId} is not a member of team ${teamId}`);
+ throw new Error("User is not a member of the team");
+ }
+ }
+ let paymentCredential = await CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId({
+ appId,
+ userId,
+ teamId,
+ });
+
+ if (!paymentCredential && teamId) {
+ const teamRepository = new TeamRepository(prisma);
+ // See if the team event belongs to an org
+ const org = await teamRepository.findParentOrganizationByTeamId(teamId);
+
+ if (org) {
+ paymentCredential = await CredentialRepository.findPaymentCredentialByAppIdAndTeamId({
+ appId,
+ teamId: org.id,
+ });
+ }
+ }
+
+ if (!paymentCredential) {
+ log.error(`No payment credential found for user ${userId} or team ${teamId}`);
+ throw new Error("No payment credential found");
+ }
+
+ const key = paymentCredential?.app?.dirName;
+ const paymentAppImportFn = PaymentServiceMap[key as keyof typeof PaymentServiceMap];
+ if (!paymentAppImportFn) {
+ log.error(`Payment app ${key} not implemented`);
+ throw new Error("Payment app not implemented");
+ }
+ const paymentApp = await paymentAppImportFn;
+ if (!paymentApp?.PaymentService) {
+ log.error(`Payment service not found for app ${key}`);
+ throw new Error("Payment service not found");
+ }
+ const PaymentService = paymentApp.PaymentService;
+ const paymentInstance = new PaymentService(paymentCredential) as IAbstractPaymentService;
+
+ try {
+ const paymentData = await paymentInstance.chargeCard(payment, booking.id);
+
+ if (!paymentData) {
+ log.error(`Error processing payment with paymentId ${payment.id}`);
+ throw new Error("Payment processing failed");
+ }
+
+ await sendNoShowFeeChargedEmail(attendeesListPromises[0], evt, eventTypeMetdata);
+
+ return paymentData;
+ } catch (err) {
+ let errorMessage = `Error processing paymentId ${payment.id} with error ${err}`;
+ if (err instanceof ErrorWithCode && err.code === ErrorCode.ChargeCardFailure) {
+ errorMessage = err.message;
+ }
+
+ log.error(errorMessage);
+ throw new Error(tOrganizer(errorMessage));
+ }
+};
diff --git a/packages/lib/payment/processNoShowFeeOnCancellation.ts b/packages/lib/payment/processNoShowFeeOnCancellation.ts
new file mode 100644
index 00000000000000..dd57c4bfd149cb
--- /dev/null
+++ b/packages/lib/payment/processNoShowFeeOnCancellation.ts
@@ -0,0 +1,77 @@
+import logger from "@calcom/lib/logger";
+import { MembershipRepository } from "@calcom/lib/server/repository/membership";
+import type { Payment } from "@calcom/prisma/client";
+import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
+
+import { handleNoShowFee } from "./handleNoShowFee";
+import { shouldChargeNoShowCancellationFee } from "./shouldChargeNoShowCancellationFee";
+
+export const processNoShowFeeOnCancellation = async ({
+ booking,
+ payments,
+ cancelledByUserId,
+}: {
+ booking: Parameters
[0]["booking"];
+ payments: Payment[];
+ cancelledByUserId?: number;
+}) => {
+ const log = logger.getSubLogger({ prefix: ["processNoShowFeeOnCancellation"] });
+
+ // Skip no-show fee if the booking was cancelled by the organizer or team/org admin
+ if (cancelledByUserId && booking.userId === cancelledByUserId) {
+ log.info(
+ `Booking ${booking.uid} was cancelled by the organizer (${cancelledByUserId}), skipping no-show fee`
+ );
+ return;
+ }
+
+ // Skip no-show fee if the booking was cancelled by a team/org admin
+ if (cancelledByUserId && booking.eventType?.teamId) {
+ const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({
+ userId: cancelledByUserId,
+ teamId: booking.eventType.teamId,
+ });
+
+ if (membership && (membership.role === "ADMIN" || membership.role === "OWNER")) {
+ log.info(
+ `Booking ${booking.uid} was cancelled by team admin/owner (${cancelledByUserId}), skipping no-show fee`
+ );
+ return;
+ }
+ }
+
+ const paymentToCharge = payments.find(
+ (payment) => payment.paymentOption === "HOLD" && payment.success === false
+ );
+
+ if (!paymentToCharge) {
+ log.info(`No payment found to charge for booking ${booking.uid}`);
+ return;
+ }
+
+ // Parse the event type metadata
+ const eventTypeMetadata = eventTypeMetaDataSchemaWithTypedApps.parse(booking.eventType?.metadata ?? {});
+
+ // Determine if we need to charge the no-show fee
+ const shouldChargeNoShowFee = shouldChargeNoShowCancellationFee({
+ booking,
+ eventTypeMetadata,
+ payment: paymentToCharge,
+ });
+
+ if (!shouldChargeNoShowFee) {
+ log.info(`Date is not valid for no-show fee to charge for booking ${booking.uid}`);
+ return;
+ }
+
+ // Process charging the no show fee
+ try {
+ await handleNoShowFee({
+ booking,
+ payment: paymentToCharge,
+ });
+ } catch (error) {
+ log.error(`Error charging no-show fee for booking ${booking.uid}`, error);
+ throw new Error(`Failed to charge no-show fee with error ${error}`);
+ }
+};
diff --git a/packages/lib/payment/shouldChargeNoShowCancellationFee.test.ts b/packages/lib/payment/shouldChargeNoShowCancellationFee.test.ts
new file mode 100644
index 00000000000000..126e7fc1df4a8f
--- /dev/null
+++ b/packages/lib/payment/shouldChargeNoShowCancellationFee.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it, vi, beforeAll } from "vitest";
+import type { z } from "zod";
+
+import type { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
+
+import { shouldChargeNoShowCancellationFee } from "./shouldChargeNoShowCancellationFee";
+
+type EventTypeMetadata = z.infer;
+
+beforeAll(() => {
+ vi.setSystemTime(new Date("2024-09-01T10:00:00Z"));
+});
+
+describe("shouldChargeCancellationFee", () => {
+ const mockEventTypeMetadata = {
+ apps: {
+ stripe: {
+ autoChargeNoShowFeeIfCancelled: true,
+ paymentOption: "HOLD",
+ autoChargeNoShowFeeTimeValue: 2,
+ autoChargeNoShowFeeTimeUnit: "hours",
+ },
+ },
+ };
+
+ const mockPayment = {
+ appId: "stripe",
+ };
+
+ it("should return false if cancellation fee is not enabled", () => {
+ const eventTypeMetadata = {
+ apps: {
+ stripe: {
+ autoChargeNoShowFeeIfCancelled: false,
+ paymentOption: "HOLD",
+ },
+ },
+ };
+
+ const result = shouldChargeNoShowCancellationFee({
+ eventTypeMetadata: eventTypeMetadata as EventTypeMetadata,
+ booking: { startTime: new Date("2024-09-01T12:00:00Z") },
+ payment: mockPayment,
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it("should return false if payment option is not HOLD", () => {
+ const eventTypeMetadata = {
+ apps: {
+ stripe: {
+ autoChargeNoShowFeeIfCancelled: true,
+ paymentOption: "ON_BOOKING",
+ },
+ },
+ };
+
+ const result = shouldChargeNoShowCancellationFee({
+ eventTypeMetadata: eventTypeMetadata as EventTypeMetadata,
+ booking: { startTime: new Date("2024-09-01T12:00:00Z") },
+ payment: mockPayment,
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it("should return true if cancelled within time threshold (hours)", () => {
+ const startTime = new Date("2024-09-01T11:30:00Z");
+
+ const result = shouldChargeNoShowCancellationFee({
+ eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
+ booking: { startTime },
+ payment: mockPayment,
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it("should return false if cancelled outside time threshold (hours)", () => {
+ const startTime = new Date("2024-09-01T13:00:00Z");
+
+ const result = shouldChargeNoShowCancellationFee({
+ eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
+ booking: { startTime },
+ payment: mockPayment,
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it("should handle minutes time unit correctly", () => {
+ const mockEventTypeMetadata = {
+ apps: {
+ stripe: {
+ autoChargeNoShowFeeIfCancelled: true,
+ paymentOption: "HOLD",
+ autoChargeNoShowFeeTimeValue: 30,
+ autoChargeNoShowFeeTimeUnit: "minutes",
+ },
+ },
+ };
+
+ const startTime = new Date("2024-09-01T10:15:00Z");
+
+ const result = shouldChargeNoShowCancellationFee({
+ eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
+ booking: { startTime },
+ payment: mockPayment,
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it("should handle days time unit correctly", () => {
+ const mockEventTypeMetadata = {
+ apps: {
+ stripe: {
+ autoChargeNoShowFeeIfCancelled: true,
+ paymentOption: "HOLD",
+ autoChargeNoShowFeeTimeValue: 1,
+ autoChargeNoShowFeeTimeUnit: "days",
+ },
+ },
+ };
+
+ const startTime = new Date("2024-09-02T09:00:00Z");
+
+ const result = shouldChargeNoShowCancellationFee({
+ eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
+ booking: { startTime },
+ payment: mockPayment,
+ });
+
+ expect(result).toBe(true);
+ });
+
+ it("should handle invalid metadata gracefully", () => {
+ const result = shouldChargeNoShowCancellationFee({
+ eventTypeMetadata: null as EventTypeMetadata,
+ booking: { startTime: new Date("2024-09-01T12:00:00Z") },
+ payment: mockPayment,
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it("should return false when timeValue is missing", () => {
+ const mockEventTypeMetadata = {
+ apps: {
+ stripe: {
+ autoChargeNoShowFeeIfCancelled: true,
+ paymentOption: "HOLD",
+ autoChargeNoShowFeeTimeUnit: "days",
+ },
+ },
+ };
+
+ const result = shouldChargeNoShowCancellationFee({
+ eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
+ booking: { startTime: new Date("2024-09-01T12:00:00Z") },
+ payment: mockPayment,
+ });
+
+ expect(result).toBe(false);
+ });
+
+ it("should return false when timeUnit is missing", () => {
+ const mockEventTypeMetadata = {
+ apps: {
+ stripe: {
+ autoChargeNoShowFeeIfCancelled: true,
+ paymentOption: "HOLD",
+ autoChargeNoShowFeeTimeValue: 24,
+ },
+ },
+ };
+
+ const result = shouldChargeNoShowCancellationFee({
+ eventTypeMetadata: mockEventTypeMetadata as EventTypeMetadata,
+ booking: { startTime: new Date("2024-09-01T12:00:00Z") },
+ payment: mockPayment,
+ });
+
+ expect(result).toBe(false);
+ });
+});
diff --git a/packages/lib/payment/shouldChargeNoShowCancellationFee.ts b/packages/lib/payment/shouldChargeNoShowCancellationFee.ts
new file mode 100644
index 00000000000000..ca1bc9ec8a883c
--- /dev/null
+++ b/packages/lib/payment/shouldChargeNoShowCancellationFee.ts
@@ -0,0 +1,58 @@
+import type { z } from "zod";
+
+import type { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
+
+export const shouldChargeNoShowCancellationFee = ({
+ eventTypeMetadata,
+ booking,
+ payment,
+}: {
+ eventTypeMetadata: z.infer;
+ booking: {
+ startTime: Date;
+ };
+ payment: {
+ appId?: string | null;
+ };
+}) => {
+ const paymentAppId = payment?.appId;
+
+ if (typeof paymentAppId !== "string") {
+ return false;
+ }
+
+ const cancellationFeeEnabled =
+ eventTypeMetadata?.apps?.[paymentAppId as keyof typeof eventTypeMetadata.apps]
+ ?.autoChargeNoShowFeeIfCancelled;
+ const paymentOption =
+ eventTypeMetadata?.apps?.[paymentAppId as keyof typeof eventTypeMetadata.apps]?.paymentOption;
+ const timeValue =
+ eventTypeMetadata?.apps?.[paymentAppId as keyof typeof eventTypeMetadata.apps]
+ ?.autoChargeNoShowFeeTimeValue;
+ const timeUnit =
+ eventTypeMetadata?.apps?.[paymentAppId as keyof typeof eventTypeMetadata.apps]
+ ?.autoChargeNoShowFeeTimeUnit;
+
+ if (!cancellationFeeEnabled || paymentOption !== "HOLD" || !booking?.startTime) {
+ return false;
+ }
+
+ if (!timeValue || !timeUnit) {
+ return false;
+ }
+
+ const multiplier: { [key: string]: number } = {
+ minutes: 1,
+ hours: 60,
+ days: 1440,
+ };
+ const timeInMinutes = timeValue * multiplier[timeUnit];
+
+ const now = new Date();
+ const startTime = new Date(booking.startTime);
+ const threshold = new Date(startTime);
+
+ threshold.setMinutes(threshold.getMinutes() - timeInMinutes);
+
+ return now >= threshold;
+};
diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts
index 589d00bb4c059f..07fdfe180c9daa 100644
--- a/packages/lib/server/repository/booking.ts
+++ b/packages/lib/server/repository/booking.ts
@@ -945,4 +945,49 @@ export class BookingRepository {
},
});
}
+
+ async getBookingForPaymentProcessing(bookingId: number) {
+ return await this.prismaClient.booking.findUnique({
+ where: {
+ id: bookingId,
+ },
+ select: {
+ id: true,
+ uid: true,
+ title: true,
+ startTime: true,
+ endTime: true,
+ userPrimaryEmail: true,
+ status: true,
+ eventTypeId: true,
+ userId: true,
+ attendees: {
+ select: {
+ name: true,
+ email: true,
+ timeZone: true,
+ locale: true,
+ },
+ },
+ eventType: {
+ select: {
+ title: true,
+ hideOrganizerEmail: true,
+ teamId: true,
+ metadata: true,
+ },
+ },
+ payment: {
+ select: {
+ id: true,
+ amount: true,
+ currency: true,
+ paymentOption: true,
+ appId: true,
+ success: true,
+ },
+ },
+ },
+ });
+ }
}
diff --git a/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts
index 36582ff8f03e97..2f2bb1d0051c80 100644
--- a/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts
+++ b/packages/trpc/server/routers/viewer/payments/chargeCard.handler.ts
@@ -1,16 +1,6 @@
-import { PaymentServiceMap } from "@calcom/app-store/payment.services.generated";
-import dayjs from "@calcom/dayjs";
-import { sendNoShowFeeChargedEmail } from "@calcom/emails";
-import { ErrorCode } from "@calcom/lib/errorCodes";
-import { ErrorWithCode } from "@calcom/lib/errors";
-import { getTranslation } from "@calcom/lib/server/i18n";
-import { CredentialRepository } from "@calcom/lib/server/repository/credential";
-import { MembershipRepository } from "@calcom/lib/server/repository/membership";
-import { TeamRepository } from "@calcom/lib/server/repository/team";
+import { handleNoShowFee } from "@calcom/lib/payment/handleNoShowFee";
+import { BookingRepository } from "@calcom/lib/server/repository/booking";
import type { PrismaClient } from "@calcom/prisma";
-import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
-import type { CalendarEvent } from "@calcom/types/Calendar";
-import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
import { TRPCError } from "@trpc/server";
@@ -23,19 +13,9 @@ interface ChargeCardHandlerOptions {
}
export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions) => {
const { prisma } = ctx;
- const teamRepository = new TeamRepository(prisma);
+ const bookingRepository = new BookingRepository(prisma);
- const booking = await prisma.booking.findUnique({
- where: {
- id: input.bookingId,
- },
- include: {
- payment: true,
- user: true,
- attendees: true,
- eventType: true,
- },
- });
+ const booking = await bookingRepository.getBookingForPaymentProcessing(input.bookingId);
if (!booking) {
throw new Error("Booking not found");
@@ -48,118 +28,16 @@ export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions
});
}
- const tOrganizer = await getTranslation(booking.user?.locale ?? "en", "common");
-
- const attendeesListPromises = [];
-
- for (const attendee of booking.attendees) {
- const attendeeObject = {
- name: attendee.name,
- email: attendee.email,
- timeZone: attendee.timeZone,
- language: {
- translate: await getTranslation(attendee.locale ?? "en", "common"),
- locale: attendee.locale ?? "en",
- },
- };
-
- attendeesListPromises.push(attendeeObject);
- }
-
- const attendeesList = await Promise.all(attendeesListPromises);
-
- const evt: CalendarEvent = {
- type: (booking?.eventType?.title as string) || booking?.title,
- title: booking.title,
- startTime: dayjs(booking.startTime).format(),
- endTime: dayjs(booking.endTime).format(),
- organizer: {
- email: booking?.userPrimaryEmail ?? booking.user?.email ?? "",
- name: booking.user?.name || "Nameless",
- timeZone: booking.user?.timeZone || "",
- language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" },
- },
- attendees: attendeesList,
- hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
- paymentInfo: {
- amount: booking.payment[0].amount,
- currency: booking.payment[0].currency,
- paymentOption: booking.payment[0].paymentOption,
- },
- };
-
- const userId = ctx.user.id;
- const teamId = booking.eventType?.teamId;
- const appId = booking.payment[0].appId;
-
- if (teamId) {
- const userIsInTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({
- userId,
- teamId,
- });
-
- if (!userIsInTeam) {
- throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not in team" });
- }
- }
-
- let paymentCredential = await CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId({
- appId,
- userId,
- teamId,
- });
-
- if (!paymentCredential && teamId) {
- // See if the team event belongs to an org
- const org = await teamRepository.findParentOrganizationByTeamId(teamId);
-
- if (org) {
- paymentCredential = await CredentialRepository.findPaymentCredentialByAppIdAndTeamId({
- appId,
- teamId: org.id,
- });
- }
- }
-
- if (!paymentCredential?.app) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid payment credential" });
- }
-
- const key = paymentCredential?.app?.dirName;
- const paymentAppImportFn = PaymentServiceMap[key as keyof typeof PaymentServiceMap];
- if (!paymentAppImportFn) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "Payment app not implemented" });
- }
-
- const paymentApp = await paymentAppImportFn;
- if (!paymentApp?.PaymentService) {
- throw new TRPCError({ code: "BAD_REQUEST", message: "Payment service not found" });
- }
- const PaymentService = paymentApp.PaymentService;
- const paymentInstance = new PaymentService(paymentCredential) as IAbstractPaymentService;
-
try {
- const paymentData = await paymentInstance.chargeCard(booking.payment[0], booking.id);
-
- if (!paymentData) {
- throw new TRPCError({ code: "NOT_FOUND", message: `Could not generate payment data` });
- }
-
- await sendNoShowFeeChargedEmail(
- attendeesListPromises[0],
- evt,
- booking?.eventType?.metadata as EventTypeMetadata
- );
-
- return paymentData;
- } catch (err) {
- let errorMessage = `Error processing payment with error ${err}`;
- if (err instanceof ErrorWithCode && err.code === ErrorCode.ChargeCardFailure) {
- errorMessage = err.message;
- }
+ await handleNoShowFee({
+ booking,
+ payment: booking.payment[0],
+ });
+ } catch (error) {
+ console.error(error);
throw new TRPCError({
- code: "BAD_REQUEST",
- message: tOrganizer(errorMessage),
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Failed to charge no show fee for ${booking.id}`,
});
}
};