diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 7aae05fd49658f..047a15a1080d72 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -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); + } } } diff --git a/packages/lib/payment/handleNoShowFee.test.ts b/packages/lib/payment/handleNoShowFee.test.ts new file mode 100644 index 00000000000000..735126e8f4d837 --- /dev/null +++ b/packages/lib/payment/handleNoShowFee.test.ts @@ -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 }; + + 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"); + }); + }); +}); diff --git a/packages/lib/payment/handleNoShowFee.ts b/packages/lib/payment/handleNoShowFee.ts index df55f679ee5102..730b1aac4cdefb 100644 --- a/packages/lib/payment/handleNoShowFee.ts +++ b/packages/lib/payment/handleNoShowFee.ts @@ -69,23 +69,17 @@ export const handleNoShowFee = async ({ 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 bookingAttendee = booking.attendees[0]; + + 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) { diff --git a/packages/lib/payment/processNoShowFeeOnCancellation.test.ts b/packages/lib/payment/processNoShowFeeOnCancellation.test.ts new file mode 100644 index 00000000000000..4971fd41a37190 --- /dev/null +++ b/packages/lib/payment/processNoShowFeeOnCancellation.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index 07fdfe180c9daa..7f4b9a6492de28 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -985,6 +985,7 @@ export class BookingRepository { paymentOption: true, appId: true, success: true, + data: true, }, }, },