Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c05cb75
feat: add time-based cancellation fees for no-show fee events
devin-ai-integration[bot] Sep 4, 2025
4bd2585
fix: replace any type with proper type guards for metadata
devin-ai-integration[bot] Sep 4, 2025
cf23e6b
fix: handle JsonValue type compatibility in shouldChargeCancellationFee
devin-ai-integration[bot] Sep 4, 2025
7c3d9c6
Refactor Devin changes to Stripe options
joeauyeung Sep 4, 2025
0be2fda
Undo Devin changes to advanced tab
joeauyeung Sep 4, 2025
3c18561
Add translations
joeauyeung Sep 4, 2025
defe6c2
Pass props to CancelBooking
joeauyeung Sep 4, 2025
5d9fcef
Display no show fee charge for attendee
joeauyeung Sep 4, 2025
c5b8471
WIP
joeauyeung Sep 4, 2025
004df7f
Anstract shouldChargeNoSHowCancellationFee
joeauyeung Sep 5, 2025
6b85588
Abstract `handleNoShowFee`
joeauyeung Sep 5, 2025
7b23906
Add to
joeauyeung Sep 5, 2025
70392ae
Refactor `chargeCard.handler`
joeauyeung Sep 5, 2025
f1b1d23
Remove Devin code
joeauyeung Sep 5, 2025
a91bb71
Type fix in `shouldChargeNoShowCancellationFee`
joeauyeung Sep 5, 2025
0194d6d
Create `processNoSHowFeeOnCancellation`
joeauyeung Sep 5, 2025
4d11139
Process no show fee on cancellation
joeauyeung Sep 5, 2025
b20e678
Type fix
joeauyeung Sep 5, 2025
2522dc0
Skip processing no show fee if organizer or admin is cancelling
joeauyeung Sep 5, 2025
7fd6ead
Add translation
joeauyeung Sep 5, 2025
1706303
Dynamically get and in
joeauyeung Sep 5, 2025
c915116
Remove unused translations
joeauyeung Sep 5, 2025
dde41b9
Undo dev change
joeauyeung Sep 5, 2025
d3b0c85
Merge branch 'main' into devin/1757001996-no-show-cancellation-fees
joeauyeung Sep 5, 2025
4dbe76f
Refactor logic
joeauyeung Sep 5, 2025
8670a9a
Type fix
joeauyeung Sep 5, 2025
eba5f77
Merge branch 'main' into devin/1757001996-no-show-cancellation-fees
alishaz-polymath Sep 5, 2025
a2851eb
remove any
alishaz-polymath Sep 5, 2025
fc28e29
revert WEBAPP_URL_FOR_OAUTH
alishaz-polymath Sep 5, 2025
78e8507
Clean up console.log remnants
alishaz-polymath Sep 5, 2025
5cda957
test: add comprehensive tests for time-based cancellation fees
devin-ai-integration[bot] Sep 5, 2025
6e6b2a8
test: add comprehensive tests for time-based cancellation fees
devin-ai-integration[bot] Sep 5, 2025
3a40b01
test: add comprehensive tests for time-based cancellation fees
devin-ai-integration[bot] Sep 5, 2025
7cd8b47
test: add comprehensive unit and E2E tests for time-based cancellatio…
devin-ai-integration[bot] Sep 5, 2025
2cb59da
Revert dev change
joeauyeung Sep 5, 2025
fb46587
Add missing translation
joeauyeung Sep 5, 2025
236b461
Remove Devin code
joeauyeung Sep 5, 2025
2fa873d
Revert Devin changes
joeauyeung Sep 5, 2025
cad3731
test: fix attendee cancellation test logic and ensure comprehensive t…
devin-ai-integration[bot] Sep 5, 2025
8c760ac
Fix tests
joeauyeung Sep 5, 2025
5c15052
Merge branch 'main' into devin/1757001996-no-show-cancellation-fees
joeauyeung Sep 5, 2025
47c7170
Remove reverted PR translations
joeauyeung Sep 5, 2025
7180f87
Merge branch 'main' into devin/1757001996-no-show-cancellation-fees
joeauyeung Sep 5, 2025
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
69 changes: 63 additions & 6 deletions apps/web/components/booking/CancelBooking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -100,6 +107,7 @@ type Props = {
};
isHost: boolean;
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
eventTypeMetadata?: Record<string, unknown> | null;
};

export default function CancelBooking(props: Props) {
Expand All @@ -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<string | null>(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<string, unknown> | null => {
if (!eventTypeMetadata?.apps || !appId) return null;
const apps = eventTypeMetadata.apps as Record<string, unknown>;
return (apps[appId] as Record<string, unknown>) || null;
};

const timeValue = booking?.payment?.appId
? (getAppMetadata(booking.payment.appId) as Record<string, unknown> | null)?.autoChargeNoShowFeeTimeValue
: null;
const timeUnit = booking?.payment?.appId
? (getAppMetadata(booking.payment.appId) as Record<string, unknown> | 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;
Comment on lines +160 to +164
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the two conditions where we would disable the cancel submit button

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
Expand Down Expand Up @@ -187,6 +230,23 @@ export default function CancelBooking(props: Props) {
</p>
</div>
) : null}
{cancellationNoShowFeeWarning && booking?.payment && (
<div>
<div className="bg-attention mb-5 rounded-md p-3">
<CheckboxField
description={t("cancel_booking_acknowledge_no_show_fee", {
timeValue,
timeUnit,
amount: booking.payment.amount / 100,
formatParams: { amount: { currency: booking.payment.currency } },
})}
onChange={(e) => setAcknowledgeCancellationNoShowFee(e.target.checked)}
descriptionClassName="text-info font-semibold"
/>
<p className="text-subtle ml-9 mt-2 text-sm">{t("contact_organizer")}</p>
</div>
</div>
)}
<div className="flex flex-col-reverse rtl:space-x-reverse ">
<div className="ml-auto flex w-full space-x-4 ">
<Button
Expand All @@ -197,10 +257,7 @@ export default function CancelBooking(props: Props) {
</Button>
<Button
data-testid="confirm_cancel"
disabled={
props.isHost &&
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id))
}
disabled={hostMissingCancellationReason || cancellationNoShowFeeNotAcknowledged}
onClick={async () => {
setLoading(true);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import { render, screen } from "@testing-library/react";
import * as React from "react";
import { describe, expect, it, vi, beforeAll } from "vitest";

import * as shouldChargeModule from "@calcom/lib/payment/shouldChargeNoShowCancellationFee";

import CancelBooking from "../CancelBooking";

beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});

vi.mock("@calcom/trpc", () => ({
trpc: {
viewer: {
bookings: {
requestReschedule: {
useMutation: () => ({
mutate: vi.fn(),
isLoading: false,
}),
},
},
},
},
}));

vi.mock("next-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === "cancellation_fee_warning_cancel") {
const opts = options as {
time?: string;
unit?: string;
amount?: number;
formatParams?: { amount?: { currency?: string } };
};
return `Cancelling within ${opts?.time} ${opts?.unit} will result in a ${opts?.amount} ${opts?.formatParams?.amount?.currency} cancellation fee being charged to your card.`;
}
return key;
},
}),
}));

vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === "cancel_booking_acknowledge_no_show_fee") {
const opts = options as {
timeValue?: number;
timeUnit?: string;
amount?: number;
formatParams?: { amount?: { currency?: string } };
};
return `I acknowledge that cancelling within ${opts?.timeValue} ${opts?.timeUnit} will result in a ${opts?.amount} ${opts?.formatParams?.amount?.currency} cancellation fee being charged to my card.`;
}
return key;
},
}),
}));

vi.mock("@calcom/lib/hooks/useTelemetry", () => ({
useTelemetry: () => ({
event: vi.fn(),
}),
}));

vi.mock("@calcom/lib/hooks/useRefreshData", () => ({
useRefreshData: () => vi.fn(),
}));

vi.mock("next/router", () => ({
useRouter: () => ({
push: vi.fn(),
query: {},
}),
}));

vi.mock("@calcom/lib/payment/shouldChargeNoShowCancellationFee", () => ({
shouldChargeNoShowCancellationFee: vi.fn(),
}));

const mockBookingWithCancellationFee = {
uid: "test-booking-uid",
title: "Test Meeting",
id: 123,
startTime: new Date(Date.now() + 30 * 60 * 1000),
payment: {
amount: 1000,
currency: "usd",
appId: "stripe",
},
};

const mockEventTypeMetadataWithFee = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "HOLD" as const,
},
},
};

const mockEventTypeMetadataWithoutFee = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "HOLD" as const,
},
},
};

const mockProps = {
recurringEvent: null,
setIsCancellationMode: vi.fn(),
theme: "light",
allRemainingBookings: false,
seatReferenceUid: undefined,
currentUserEmail: "test@example.com",
bookingCancelledEventProps: {
booking: {},
organizer: {
name: "Test Organizer",
email: "organizer@example.com",
timeZone: "UTC",
},
eventType: {},
},
internalNotePresets: [],
};

const mockBookingWithoutCancellationFee = {
uid: "test-booking-uid-2",
title: "Test Meeting 2",
id: 124,
startTime: new Date(Date.now() + 2 * 60 * 60 * 1000),
payment: {
amount: 1000,
currency: "usd",
appId: "stripe",
},
};

describe("CancelBooking Cancellation Fee Warning", () => {
it("should show cancellation fee warning when booking is within time threshold", () => {
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(true);

render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={mockEventTypeMetadataWithFee}
{...mockProps}
/>
);

expect(
screen.getByText(/I acknowledge that cancelling within 1 hours will result in a/)
).toBeInTheDocument();
});

it("should not show cancellation fee warning when booking is outside time threshold", () => {
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);

render(
<CancelBooking
booking={mockBookingWithoutCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={mockEventTypeMetadataWithoutFee}
{...mockProps}
/>
);

expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});

it("should not show cancellation fee warning when user is host", () => {
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);

render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={true}
eventTypeMetadata={mockEventTypeMetadataWithFee}
{...mockProps}
/>
);

expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});

it("should not show cancellation fee warning when cancellation fee is disabled", () => {
const eventTypeMetadataWithDisabledFee = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: false,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "HOLD" as const,
},
},
};

vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);

render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={eventTypeMetadataWithDisabledFee}
{...mockProps}
/>
);

expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});

it("should not show cancellation fee warning when payment option is not HOLD", () => {
const eventTypeMetadataWithOnBookingPayment = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "ON_BOOKING" as const,
},
},
};

vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);

render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={eventTypeMetadataWithOnBookingPayment}
{...mockProps}
/>
);

expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
bookingId: bookingInfo.id,
},
select: {
appId: true,
success: true,
refunded: true,
currency: true,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/modules/bookings/views/bookings-single-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,10 @@ export default function Success(props: PageProps) {
uid: bookingInfo?.uid,
title: bookingInfo?.title,
id: bookingInfo?.id,
startTime: bookingInfo?.startTime,
payment: props.paymentStatus,
}}
eventTypeMetadata={eventType.metadata}
profile={{ name: props.profile.name, slug: props.profile.slug }}
recurringEvent={eventType.recurringEvent}
team={eventType?.team?.name}
Expand Down
Loading
Loading