fix(bookings): refund all seat payments when cancelling a paid seated… (#29685)
* fix(bookings): refund all seat payments when cancelling a paid seated booking A paid seated booking creates one Payment row per seat on the same bookingId (each seat's payment_intent.succeeded webhook flips only its own payment via handlePaymentSuccess). processPaymentRefund only refunded payment.find((p) => p.success) — the first successful payment — so fully cancelling a multi-seat paid booking with an auto-refund policy refunded a single seat and left the other attendees charged with no error surfaced. Refund every successful, not-yet-refunded payment instead. Tests: unit regression in processPaymentRefund.test.ts (all payments refunded, verified red before the fix); integration test in handleCancelBooking.test.ts proving a full cancel of a seated multi-payment booking hands all payments to the refund path. * fix(bookings): make seat refunds resilient and drop dayjs in test Address CodeRabbit review on #29685: - processPaymentRefund now attempts every seat's refund independently and throws an aggregate error, so one provider failure no longer skips the remaining seats. - Add a regression test for that resilience, and use native Date instead of dayjs for fixed-offset test timestamps.
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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("; ")}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user