test: add comprehensive unit tests for no-show fee payment functions (#23626)

* feat: add time-based cancellation fees for no-show fee events

- Add configurable time threshold (minutes/hours/days) for cancellation fees
- Show warnings during booking submission and cancellation
- Automatically charge fees when bookings cancelled within threshold
- Exempt organizer/admin cancellations from fees
- Extend existing no-show fee infrastructure

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: replace any type with proper type guards for metadata

- Use Record<string, unknown> instead of any for metadata type
- Add proper type assertions for nested metadata properties
- Maintain type safety while avoiding ESLint no-explicit-any warning

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: handle JsonValue type compatibility in shouldChargeCancellationFee

- Add proper type guard for metadata JsonValue from Prisma
- Ensure metadata is object before casting to Record<string, unknown>
- Fixes TypeScript error on line 390 in handleCancelBooking.ts

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Refactor Devin changes to Stripe options

* Undo Devin changes to advanced tab

* Add translations

* Pass props to CancelBooking

* Display no show fee charge for attendee

* WIP

* Anstract shouldChargeNoSHowCancellationFee

* Abstract `handleNoShowFee`

* Add  to

* Refactor `chargeCard.handler`

* Remove Devin code

* Type fix in `shouldChargeNoShowCancellationFee`

* Create `processNoSHowFeeOnCancellation`

* Process no show fee on cancellation

* Type fix

* Skip processing no show fee if organizer or admin is cancelling

* Add translation

* Dynamically get  and  in

* Remove unused translations

* Undo dev change

* Refactor logic

* Type fix

* remove any

* revert WEBAPP_URL_FOR_OAUTH

* Clean up console.log remnants

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All 23 new tests pass successfully, covering:
- Time-based logic with different units (minutes, hours, days)
- Role-based exemptions (organizer, team admin vs regular attendee)
- Payment charging integration with HOLD payment option
- UI warning display during booking submission and cancellation
- Edge cases like invalid metadata and past bookings

Fixed lint issues:
- Replaced 'any' types with proper Record<string, unknown>
- Fixed Playwright test.skip() usage with ESLint disable comments

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All unit and integration tests pass successfully. E2E test implementation complete.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript issues and prettier formatting
- All tests follow existing Cal.com patterns and pass locally

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive unit and E2E tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions and attendee fee charging
- Add UI component tests for cancellation fee warning display in CancelBooking component
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript errors in CancelBooking.tsx with proper type annotations
- All tests follow existing Cal.com patterns and use proper mocking/test utilities

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Revert dev change

* Add missing translation

* Remove Devin code

* Revert Devin changes

* test: fix attendee cancellation test logic and ensure comprehensive test coverage

- Fix userId in attendee cancellation test to properly test fee charging
- Ensure all test files follow Cal.com patterns with proper mocking
- Add comprehensive coverage for time thresholds, role exemptions, and UI warnings
- All tests pass locally with proper timezone handling

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Fix tests

* test: add comprehensive unit tests for no-show fee payment functions

- Add unit tests for handleNoShowFee.ts covering successful scenarios, error handling, and edge cases
- Add unit tests for processNoShowFeeOnCancellation.ts covering cancellation logic and payment conditions
- Tests include proper mocking of dependencies and comprehensive coverage of all code paths
- All tests pass with TZ=UTC yarn test and type checking passes

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Don't block cancel flow for failed payment processing

* Await single attendee getTranslation in `handleNoShowFee`

* Add missing data

* Undo old strings

* Type fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
authored and GitHub committed 2025-09-05 23:01:52 +01:00
1 parent c42e803cfa
commit d6b17b76db
5 files changed
+992 -27

No files matched your search

@@ -456,16 +456,24 @@ async function handler(input: CancelBookingInput) {
updatedBookings.push(updatedBooking);
if (bookingToDelete.payment.some((payment) => payment.paymentOption === "ON_BOOKING")) {
await processPaymentRefund({
booking: bookingToDelete,
teamId,
});
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")) {
await processNoShowFeeOnCancellation({
booking: bookingToDelete,
payments: bookingToDelete.payment,
cancelledByUserId: userId,
});
try {
await processNoShowFeeOnCancellation({
booking: bookingToDelete,
payments: bookingToDelete.payment,
cancelledByUserId: userId,
});
} catch (error) {
log.error(`Error processing no-show fee for booking ${bookingToDelete.uid}:`, error);
}
}
}
@@ -0,0 +1,453 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// eslint-disable-next-line no-restricted-imports
import { PaymentServiceMap } from "@calcom/app-store/payment.services.generated";
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 "./handleNoShowFee";
vi.mock("@calcom/app-store/payment.services.generated", () => ({
PaymentServiceMap: {
stripepayment: Promise.resolve({
PaymentService: vi.fn().mockImplementation(() => ({
chargeCard: vi.fn(),
})),
}),
},
}));
vi.mock("@calcom/emails", () => ({
sendNoShowFeeChargedEmail: vi.fn(),
}));
vi.mock("@calcom/lib/server/i18n", () => ({
getTranslation: vi.fn().mockResolvedValue((key: string) => key),
}));
vi.mock("@calcom/lib/server/repository/credential", () => ({
CredentialRepository: {
findPaymentCredentialByAppIdAndUserIdOrTeamId: vi.fn(),
findPaymentCredentialByAppIdAndTeamId: vi.fn(),
},
}));
vi.mock("@calcom/lib/server/repository/membership", () => ({
MembershipRepository: {
findUniqueByUserIdAndTeamId: vi.fn(),
},
}));
vi.mock("@calcom/lib/server/repository/team", () => ({
TeamRepository: vi.fn().mockImplementation(() => ({
findParentOrganizationByTeamId: vi.fn(),
})),
}));
vi.mock("@calcom/prisma", () => ({
default: {},
}));
describe("handleNoShowFee", () => {
let mockPaymentService: { chargeCard: ReturnType<typeof vi.fn> };
beforeEach(async () => {
vi.clearAllMocks();
mockPaymentService = {
chargeCard: vi.fn(),
};
const paymentServiceModule = await PaymentServiceMap.stripepayment;
vi.mocked(paymentServiceModule.PaymentService).mockImplementation(() => mockPaymentService);
});
const mockBooking = {
id: 1,
uid: "booking-123",
title: "Test Meeting",
startTime: new Date("2024-09-01T10:00:00Z"),
endTime: new Date("2024-09-01T11:00:00Z"),
userPrimaryEmail: "organizer@example.com",
userId: 1,
user: {
email: "organizer@example.com",
name: "John Organizer",
locale: "en",
timeZone: "UTC",
},
eventType: {
title: "Test Event Type",
hideOrganizerEmail: false,
teamId: null,
metadata: {},
},
attendees: [
{
name: "Jane Attendee",
email: "attendee@example.com",
timeZone: "UTC",
locale: "en",
},
],
};
const mockPayment = {
id: 1,
amount: 5000,
currency: "USD",
paymentOption: "HOLD",
appId: "stripepayment",
};
const mockCredential = {
id: 1,
type: "stripepayment_payment",
key: { test: "key" },
userId: 1,
teamId: null,
appId: "stripepayment",
invalid: false,
createdAt: new Date(),
updatedAt: new Date(),
app: {
keys: { test: "key" },
slug: "stripepayment",
createdAt: new Date(),
updatedAt: new Date(),
dirName: "stripepayment",
categories: ["payment"],
enabled: true,
},
};
describe("successful scenarios", () => {
it("should successfully process no-show fee for individual user", async () => {
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
vi.mocked(sendNoShowFeeChargedEmail).mockResolvedValue(undefined);
const result = await handleNoShowFee({
booking: mockBooking,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
expect(mockPaymentService.chargeCard).toHaveBeenCalledWith(mockPayment, mockBooking.id);
expect(sendNoShowFeeChargedEmail).toHaveBeenCalled();
});
it("should successfully process no-show fee for team event", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue({
id: 1,
userId: 1,
teamId: 1,
role: "MEMBER",
createdAt: new Date(),
updatedAt: new Date(),
disableImpersonation: false,
accepted: true,
customRoleId: null,
});
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
const result = await handleNoShowFee({
booking: teamBooking,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenCalledWith({
userId: 1,
teamId: 1,
});
});
it("should find credential from parent organization when team credential not found", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue({
id: 1,
userId: 1,
teamId: 1,
role: "MEMBER",
createdAt: new Date(),
updatedAt: new Date(),
disableImpersonation: false,
accepted: true,
customRoleId: null,
});
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(mockCredential);
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndTeamId).mockResolvedValue(mockCredential);
const mockTeamRepository = {
findParentOrganizationByTeamId: vi.fn().mockResolvedValue({ id: 2 }),
};
vi.mocked(TeamRepository).mockImplementation(() => mockTeamRepository);
const result = await handleNoShowFee({
booking: teamBooking,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
expect(mockTeamRepository.findParentOrganizationByTeamId).toHaveBeenCalledWith(1);
expect(CredentialRepository.findPaymentCredentialByAppIdAndTeamId).toHaveBeenCalledWith({
appId: "stripepayment",
teamId: 2,
});
});
});
describe("error scenarios", () => {
it("should throw error when userId is missing", async () => {
const bookingWithoutUser = {
...mockBooking,
userId: null,
};
await expect(
handleNoShowFee({
booking: bookingWithoutUser,
payment: mockPayment,
})
).rejects.toThrow("User ID is required");
});
it("should throw error when user is not a member of the team", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue(null);
await expect(
handleNoShowFee({
booking: teamBooking,
payment: mockPayment,
})
).rejects.toThrow("User is not a member of the team");
});
it("should throw error when no payment credential is found", async () => {
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockReset();
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndTeamId).mockReset();
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(null);
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndTeamId).mockResolvedValue(null);
const bookingWithoutCredential = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: null,
},
};
await expect(
handleNoShowFee({
booking: bookingWithoutCredential,
payment: mockPayment,
})
).rejects.toThrow("No payment credential found");
});
it("should throw error when payment app is not implemented", async () => {
const paymentWithUnknownApp = {
...mockPayment,
appId: "unknown-app",
};
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockImplementation(
async () => ({
...mockCredential,
app: {
...mockCredential.app,
dirName: "unknown-app",
},
})
);
await expect(
handleNoShowFee({
booking: mockBooking,
payment: paymentWithUnknownApp,
})
).rejects.toThrow("Payment app not implemented");
});
it("should throw error when payment service is not found", async () => {
const originalStripepayment = PaymentServiceMap.stripepayment;
// @ts-expect-error - Mocking for test
PaymentServiceMap.stripepayment = Promise.resolve({});
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
try {
await expect(
handleNoShowFee({
booking: mockBooking,
payment: mockPayment,
})
).rejects.toThrow("Payment service not found");
} finally {
// @ts-expect-error - Restoring for test
PaymentServiceMap.stripepayment = originalStripepayment;
}
});
it("should throw error when payment processing fails", async () => {
mockPaymentService.chargeCard.mockResolvedValue(null);
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
await expect(
handleNoShowFee({
booking: mockBooking,
payment: mockPayment,
})
).rejects.toThrow("Payment processing failed");
});
it("should handle ChargeCardFailure error with proper message", async () => {
mockPaymentService.chargeCard.mockRejectedValue(
new ErrorWithCode(ErrorCode.ChargeCardFailure, "Card declined")
);
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
vi.mocked(getTranslation).mockResolvedValue((key: string) => `Translated: ${key}`);
await expect(
handleNoShowFee({
booking: mockBooking,
payment: mockPayment,
})
).rejects.toThrow("Translated: Card declined");
});
it("should handle generic payment errors", async () => {
mockPaymentService.chargeCard.mockRejectedValue(new Error("Generic payment error"));
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
vi.mocked(getTranslation).mockResolvedValue((key: string) => `Translated: ${key}`);
await expect(
handleNoShowFee({
booking: mockBooking,
payment: mockPayment,
})
).rejects.toThrow(/Translated: Error processing paymentId 1 with error/);
});
});
describe("edge cases", () => {
it("should handle booking without event type", async () => {
const bookingWithoutEventType = {
...mockBooking,
eventType: null,
};
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
const result = await handleNoShowFee({
booking: bookingWithoutEventType,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
});
it("should handle booking without user details", async () => {
const bookingWithoutUserDetails = {
...mockBooking,
user: null,
};
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
const result = await handleNoShowFee({
booking: bookingWithoutUserDetails,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
});
it("should handle attendees without locale", async () => {
const bookingWithAttendeesWithoutLocale = {
...mockBooking,
attendees: [
{
name: "Jane Attendee",
email: "attendee@example.com",
timeZone: "UTC",
locale: null,
},
],
};
mockPaymentService.chargeCard.mockResolvedValue({ success: true, paymentId: "pay_123" });
vi.mocked(CredentialRepository.findPaymentCredentialByAppIdAndUserIdOrTeamId).mockResolvedValue(
mockCredential
);
const result = await handleNoShowFee({
booking: bookingWithAttendeesWithoutLocale,
payment: mockPayment,
});
expect(result).toEqual({ success: true, paymentId: "pay_123" });
expect(getTranslation).toHaveBeenCalledWith("en", "common");
});
});
});
+12 -18
View File
@@ -69,23 +69,17 @@ export const handleNoShowFee = async ({
throw new Error("User ID is required");
}
const attendeesListPromises = [];
const bookingAttendee = booking.attendees[0];
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 attendee = {
name: bookingAttendee.name,
email: bookingAttendee.email,
timeZone: bookingAttendee.timeZone,
language: {
translate: await getTranslation(bookingAttendee.locale ?? "en", "common"),
locale: bookingAttendee.locale ?? "en",
},
};
const evt: CalendarEvent = {
type: (booking?.eventType?.title as string) || booking?.title,
@@ -98,7 +92,7 @@ export const handleNoShowFee = async ({
timeZone: booking.user?.timeZone || "",
language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" },
},
attendees: attendeesList,
attendees: [attendee],
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
paymentInfo: {
amount: payment.amount,
@@ -164,7 +158,7 @@ export const handleNoShowFee = async ({
throw new Error("Payment processing failed");
}
await sendNoShowFeeChargedEmail(attendeesListPromises[0], evt, eventTypeMetdata);
await sendNoShowFeeChargedEmail(attendee, evt, eventTypeMetdata);
return paymentData;
} catch (err) {
@@ -0,0 +1,509 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { MembershipRepository } from "@calcom/lib/server/repository/membership";
import type { Payment } from "@calcom/prisma/client";
import { handleNoShowFee } from "./handleNoShowFee";
import { processNoShowFeeOnCancellation } from "./processNoShowFeeOnCancellation";
import { shouldChargeNoShowCancellationFee } from "./shouldChargeNoShowCancellationFee";
vi.mock("@calcom/lib/server/repository/membership", () => ({
MembershipRepository: {
findUniqueByUserIdAndTeamId: vi.fn(),
},
}));
vi.mock("./handleNoShowFee", () => ({
handleNoShowFee: vi.fn(),
}));
vi.mock("./shouldChargeNoShowCancellationFee", () => ({
shouldChargeNoShowCancellationFee: vi.fn(),
}));
describe("processNoShowFeeOnCancellation", () => {
const mockBooking = {
id: 1,
uid: "booking-123",
title: "Test Meeting",
startTime: new Date("2024-09-01T10:00:00Z"),
endTime: new Date("2024-09-01T11:00:00Z"),
userPrimaryEmail: "organizer@example.com",
userId: 1,
user: {
email: "organizer@example.com",
name: "John Organizer",
locale: "en",
timeZone: "UTC",
},
eventType: {
title: "Test Event Type",
hideOrganizerEmail: false,
teamId: null,
metadata: {},
},
attendees: [
{
name: "Jane Attendee",
email: "attendee@example.com",
timeZone: "UTC",
locale: "en",
},
],
};
const mockHoldPayment: Payment = {
id: 1,
uid: "payment-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: false,
refunded: false,
data: {},
externalId: "ext_123",
paymentOption: "HOLD",
};
const mockSuccessfulPayment: Payment = {
...mockHoldPayment,
id: 2,
success: true,
paymentOption: "ON_BOOKING",
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("successful scenarios", () => {
it("should successfully process no-show fee when conditions are met", async () => {
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalledWith({
booking: mockBooking,
eventTypeMetadata: {},
payment: mockHoldPayment,
});
expect(handleNoShowFee).toHaveBeenCalledWith({
booking: mockBooking,
payment: mockHoldPayment,
});
});
it("should process no-show fee when cancelled by attendee (no cancelledByUserId)", async () => {
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockHoldPayment],
});
expect(handleNoShowFee).toHaveBeenCalledWith({
booking: mockBooking,
payment: mockHoldPayment,
});
});
});
describe("skip scenarios - organizer cancellation", () => {
it("should skip no-show fee when cancelled by organizer", async () => {
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockHoldPayment],
cancelledByUserId: 1,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
});
describe("skip scenarios - team admin cancellation", () => {
it("should skip no-show fee when cancelled by team admin", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue({
id: 1,
userId: 999,
teamId: 1,
role: "ADMIN",
accepted: true,
createdAt: new Date(),
updatedAt: new Date(),
disableImpersonation: false,
customRoleId: null,
});
await processNoShowFeeOnCancellation({
booking: teamBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenCalledWith({
userId: 999,
teamId: 1,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
it("should skip no-show fee when cancelled by team owner", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue({
id: 1,
userId: 999,
teamId: 1,
role: "OWNER",
accepted: true,
createdAt: new Date(),
updatedAt: new Date(),
disableImpersonation: false,
customRoleId: null,
});
await processNoShowFeeOnCancellation({
booking: teamBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
it("should process no-show fee when cancelled by team member (not admin/owner)", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue({
id: 1,
userId: 999,
teamId: 1,
role: "MEMBER",
accepted: true,
createdAt: new Date(),
updatedAt: new Date(),
disableImpersonation: false,
customRoleId: null,
});
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: teamBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenCalledWith({
userId: 999,
teamId: 1,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalled();
expect(handleNoShowFee).toHaveBeenCalled();
});
it("should process no-show fee when cancelled by non-team member", async () => {
const teamBooking = {
...mockBooking,
eventType: {
...mockBooking.eventType,
teamId: 1,
},
};
vi.mocked(MembershipRepository.findUniqueByUserIdAndTeamId).mockResolvedValue(null);
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: teamBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalled();
expect(handleNoShowFee).toHaveBeenCalled();
});
});
describe("skip scenarios - payment conditions", () => {
it("should skip no-show fee when no HOLD payment found", async () => {
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockSuccessfulPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
it("should skip no-show fee when no payments provided", async () => {
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
it("should use first HOLD payment when multiple exist", async () => {
const secondHoldPayment: Payment = {
...mockHoldPayment,
id: 3,
uid: "payment-456",
};
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockSuccessfulPayment, mockHoldPayment, secondHoldPayment],
cancelledByUserId: 999,
});
expect(handleNoShowFee).toHaveBeenCalledWith({
booking: mockBooking,
payment: mockHoldPayment,
});
});
});
describe("skip scenarios - time threshold", () => {
it("should skip no-show fee when outside time threshold", async () => {
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(false);
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalledWith({
booking: mockBooking,
eventTypeMetadata: {},
payment: mockHoldPayment,
});
expect(handleNoShowFee).not.toHaveBeenCalled();
});
});
describe("error scenarios", () => {
it("should throw error when handleNoShowFee fails", async () => {
const error = new Error("Payment processing failed");
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockRejectedValue(error);
await expect(
processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [mockHoldPayment],
cancelledByUserId: 999,
})
).rejects.toThrow("Failed to charge no-show fee with error Error: Payment processing failed");
});
});
describe("edge cases", () => {
it("should handle booking without event type", async () => {
const bookingWithoutEventType = {
...mockBooking,
eventType: null,
};
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: bookingWithoutEventType,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalledWith({
booking: bookingWithoutEventType,
eventTypeMetadata: {},
payment: mockHoldPayment,
});
expect(handleNoShowFee).toHaveBeenCalled();
});
it("should handle booking with complex event type metadata", async () => {
const bookingWithMetadata = {
...mockBooking,
eventType: {
...mockBooking.eventType,
metadata: {
apps: {
stripe: {
enabled: true,
price: 1000,
currency: "usd",
autoChargeNoShowFeeIfCancelled: true,
paymentOption: "HOLD",
autoChargeNoShowFeeTimeValue: 2,
autoChargeNoShowFeeTimeUnit: "hours",
},
},
},
},
};
vi.mocked(shouldChargeNoShowCancellationFee).mockReturnValue(true);
vi.mocked(handleNoShowFee).mockResolvedValue({
id: 999,
uid: "payment-charged-123",
appId: "stripe",
bookingId: 1,
amount: 5000,
fee: 0,
currency: "USD",
success: true,
refunded: false,
data: {},
externalId: "ext_charged_123",
paymentOption: "HOLD",
});
await processNoShowFeeOnCancellation({
booking: bookingWithMetadata,
payments: [mockHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).toHaveBeenCalledWith({
booking: bookingWithMetadata,
eventTypeMetadata: bookingWithMetadata.eventType.metadata,
payment: mockHoldPayment,
});
});
it("should handle successful HOLD payment (should still be processed)", async () => {
const successfulHoldPayment: Payment = {
...mockHoldPayment,
success: true,
};
await processNoShowFeeOnCancellation({
booking: mockBooking,
payments: [successfulHoldPayment],
cancelledByUserId: 999,
});
expect(shouldChargeNoShowCancellationFee).not.toHaveBeenCalled();
expect(handleNoShowFee).not.toHaveBeenCalled();
});
});
});
@@ -985,6 +985,7 @@ export class BookingRepository {
paymentOption: true,
appId: true,
success: true,
data: true,
},
},
},