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:
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user