diff --git a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts index fac503b098..a410afe196 100644 --- a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts +++ b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts @@ -289,6 +289,111 @@ describe("Cancel Booking", () => { expect(processPaymentRefund).toHaveBeenCalled(); }); + test("Should pass all seat payments to processPaymentRefund when fully cancelling a paid seated booking", async () => { + const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default; + + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const uidOfBookingToBeCancelled = "seated-multi-pay-cancel"; + const idOfBookingToBeCancelled = 1099; + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const booking = { + id: idOfBookingToBeCancelled, + uid: uidOfBookingToBeCancelled, + eventTypeId: 1, + userId: 101, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + attendees: [{ timeZone: "Asia/Kolkata", email: booker.email }], + }; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slotInterval: 30, + length: 30, + seatsPerTimeSlot: 3, + users: [{ id: 101 }], + }, + ], + // Two seats on the same booking → two Payment rows sharing one bookingId. + payment: [ + { + amount: 1000, + bookingId: idOfBookingToBeCancelled, + currency: "usd", + data: {}, + externalId: "ext_seat_1", + fee: 0, + refunded: false, + success: true, + uid: "seat-1-pay", + }, + { + amount: 5000, + bookingId: idOfBookingToBeCancelled, + currency: "usd", + data: {}, + externalId: "ext_seat_2", + fee: 0, + refunded: false, + success: true, + uid: "seat-2-pay", + }, + ], + bookings: [booking], + organizer, + apps: [TestData.apps["daily-video"]], + }) + ); + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { id: "MOCK_ID", password: "MOCK_PASS", url: `http://mock-dailyvideo.example.com/meeting-1` }, + }); + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID" }, + }); + + // Full cancel (no seatReferenceUid) by the host. Clear first so the count is order-independent. + vi.mocked(processPaymentRefund).mockClear(); + await handleCancelBooking({ + bookingData: { + id: idOfBookingToBeCancelled, + uid: uidOfBookingToBeCancelled, + cancelledBy: organizer.email, + cancellationReason: "No reason", + }, + }); + + expect(processPaymentRefund).toHaveBeenCalledTimes(1); + const refundArg = vi.mocked(processPaymentRefund).mock.calls[0][0]; + expect(refundArg.booking.payment).toHaveLength(2); + expect(refundArg.booking.payment.map((p) => p.externalId).sort()).toEqual(["ext_seat_1", "ext_seat_2"]); + }); + test("Should block cancelling past bookings", async () => { const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default; diff --git a/packages/features/bookings/lib/payment/processPaymentRefund.test.ts b/packages/features/bookings/lib/payment/processPaymentRefund.test.ts index d144f9406f..c13141f770 100644 --- a/packages/features/bookings/lib/payment/processPaymentRefund.test.ts +++ b/packages/features/bookings/lib/payment/processPaymentRefund.test.ts @@ -116,6 +116,57 @@ describe("processPaymentRefund", () => { ); }); + it("should refund every successful payment on a multi-seat booking, not just the first", async () => { + mockedGetPaymentAppData.mockReturnValue(mockAppData); + await prismock.app.create({ data: mockApp }); + await prismock.credential.create({ data: mockCredential }); + + // A paid seated booking fans out into one Payment row per seat on the same bookingId. + const multiSeatBooking = { + ...mockBooking, + payment: [ + { ...mockPayment[0], id: 1, uid: "seat-1-pay", externalId: "ext-seat-1" }, + { ...mockPayment[0], id: 2, uid: "seat-2-pay", externalId: "ext-seat-2", amount: 5000 }, + ], + }; + + const mockNow = new Date(mockStartTime.getTime() - 8 * 24 * 60 * 60 * 1000); + vi.useFakeTimers(); + vi.setSystemTime(mockNow); + + await processPaymentRefund({ booking: multiSeatBooking, teamId: 1 }); + + expect(handlePaymentRefund).toHaveBeenCalledTimes(2); + expect(handlePaymentRefund).toHaveBeenCalledWith(1, expect.objectContaining({ appId: "123" })); + expect(handlePaymentRefund).toHaveBeenCalledWith(2, expect.objectContaining({ appId: "123" })); + }); + + it("should attempt every refund even when one fails, then surface an aggregate error", async () => { + mockedGetPaymentAppData.mockReturnValue(mockAppData); + await prismock.app.create({ data: mockApp }); + await prismock.credential.create({ data: mockCredential }); + vi.mocked(handlePaymentRefund) + .mockRejectedValueOnce(new Error("provider down")) + .mockResolvedValueOnce(undefined); + + const multiSeatBooking = { + ...mockBooking, + payment: [ + { ...mockPayment[0], id: 1, uid: "seat-1-pay", externalId: "ext-seat-1" }, + { ...mockPayment[0], id: 2, uid: "seat-2-pay", externalId: "ext-seat-2", amount: 5000 }, + ], + }; + + const mockNow = new Date(mockStartTime.getTime() - 8 * 24 * 60 * 60 * 1000); + vi.useFakeTimers(); + vi.setSystemTime(mockNow); + + await expect(processPaymentRefund({ booking: multiSeatBooking, teamId: 1 })).rejects.toThrow(); + + // Both seats are attempted even though the first refund failed. + expect(handlePaymentRefund).toHaveBeenCalledTimes(2); + }); + it("should not process refund if past the refund deadline", async () => { mockedGetPaymentAppData.mockReturnValueOnce(mockAppData); await prismock.app.create({ data: mockApp }); diff --git a/packages/features/bookings/lib/payment/processPaymentRefund.ts b/packages/features/bookings/lib/payment/processPaymentRefund.ts index cb7d1935c3..0465c98652 100644 --- a/packages/features/bookings/lib/payment/processPaymentRefund.ts +++ b/packages/features/bookings/lib/payment/processPaymentRefund.ts @@ -26,7 +26,10 @@ export const processPaymentRefund = async ({ const { startTime, eventType, payment } = booking; if (!teamId && !eventType?.owner) return; - const successPayment = payment.find((p) => p.success); + // A paid seated booking creates one Payment per seat on the same bookingId, so refund every + // successful (not-yet-refunded) payment — not just the first — otherwise the other seats stay charged. + const successfulPayments = payment.filter((p) => p.success && !p.refunded); + const [successPayment] = successfulPayments; if (!successPayment) return; const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType?.metadata); @@ -80,5 +83,22 @@ export const processPaymentRefund = async ({ dayjs(startTime).businessDaysSubtract(refundDaysCount); if (dayjs().isAfter(refundDeadline)) return; } - await handlePaymentRefund(successPayment.id, paymentAppCredential); + // Attempt every refund independently so one provider failure doesn't leave the remaining seats + // charged, then surface an aggregate error for the caller to log/report. + const refundErrors: unknown[] = []; + for (const paymentToRefund of successfulPayments) { + try { + await handlePaymentRefund(paymentToRefund.id, paymentAppCredential); + } catch (error) { + refundErrors.push(error); + } + } + + if (refundErrors.length) { + throw new Error( + `Failed to refund ${refundErrors.length} of ${successfulPayments.length} payment(s): ${refundErrors + .map((error) => (error instanceof Error ? error.message : String(error))) + .join("; ")}` + ); + } };