From a8a3a93ace51eb8713a7ea06dc4baa58ead54a0a Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:14:40 +0530 Subject: [PATCH] fix: redirect to existing payment session on paid booking retry (#23096) * fix: redirect to existing payment page on booking retry - Extract existing payment UID from unpaid bookings - Return paymentUid and paymentId when payment form should be shown - Prevents payment form bypass by redirecting to original payment session - Maintains payment continuity without creating duplicate bookings Fixes issue where canceled payments showed success page on rebooking Co-Authored-By: anik@cal.com * test: add unit test for payment retry scenario - Test verifies existing payment UID is returned on booking retry - Ensures no duplicate bookings are created in database - Validates paymentRequired flag is set correctly for unpaid bookings - Covers the payment redirect fix in handleNewBooking.ts Co-Authored-By: anik@cal.com * update * fix: restore calendar event deletion for round robin reassignment - Add deleteEventsAndMeetings call back to EventManager.reschedule() when changedOrganizer is true - Add unit test to verify calendar deletion during round robin reassignment - Fixes regression where calendar events weren't deleted from original host's calendar Co-Authored-By: anik@cal.com * Update roundRobinReassignment.test.ts * Update EventManager.ts * test: update payment retry test title to single descriptive line - Changed from numbered list format to concise single line description - Test functionality remains unchanged and passes successfully Co-Authored-By: anik@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../features/bookings/lib/handleNewBooking.ts | 16 ++- .../test/fresh-booking.test.ts | 118 ++++++++++++++++++ packages/lib/server/repository/booking.ts | 1 + 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 9854474d4a..0295994819 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -550,6 +550,9 @@ async function handler( eventType.schedulingType === SchedulingType.ROUND_ROBIN ) { const bookingRepo = new BookingRepository(prisma); + + const requiresPayment = !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0; + const existingBooking = await bookingRepo.getValidBookingFromEventTypeForAttendee({ eventTypeId, bookerEmail, @@ -559,13 +562,20 @@ async function handler( }); if (existingBooking) { + const hasPayments = existingBooking.payment.length > 0; + const isPaidBooking = existingBooking.paid || !hasPayments; + + const shouldShowPaymentForm = requiresPayment && !isPaidBooking; + + const firstPayment = shouldShowPaymentForm ? existingBooking.payment[0] : undefined; + const bookingResponse = { ...existingBooking, user: { ...existingBooking.user, email: null, }, - paymentRequired: false, + paymentRequired: shouldShowPaymentForm, seatReferenceUid: "", }; @@ -574,8 +584,8 @@ async function handler( luckyUsers: bookingResponse.userId ? [bookingResponse.userId] : [], isDryRun, ...(isDryRun ? { troubleshooterData } : {}), - paymentUid: undefined, - paymentId: undefined, + paymentUid: firstPayment?.uid, + paymentId: firstPayment?.id, }; } } diff --git a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts index 4e462adf66..1d75498d55 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/fresh-booking.test.ts @@ -7,6 +7,8 @@ * * They don't intend to test what the apps logic should do, but rather test if the apps are called with the correct data. For testing that, once should write tests within each app. */ +import prismaMock from "../../../../../../tests/libs/__mocks__/prisma"; + import { createBookingScenario, getDate, @@ -3277,6 +3279,122 @@ describe("handleNewBooking", () => { }, timeout ); + + test( + `Payment retry scenario - should return existing payment UID and prevent duplicate bookings when retrying canceled payment`, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).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(), getStripeAppCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const scenarioData = getScenarioData({ + eventTypes: [ + { + id: 1, + title: "Paid Event", + description: "It's a test Paid Event", + slotInterval: 30, + requiresConfirmation: false, + metadata: { + apps: { + stripe: { + price: 100, + enabled: true, + currency: "usd", + }, + }, + }, + length: 30, + users: [ + { + id: 101, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["stripe-payment"]], + }); + + await createBookingScenario(scenarioData); + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + const { paymentUid } = mockPaymentApp({ + metadataLookupKey: "stripe", + appStoreLookupKey: "stripepayment", + }); + mockCalendarToHaveNoBusySlots("googlecalendar"); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: "New York" }, + }, + }, + }); + + const firstBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(firstBooking).toEqual( + expect.objectContaining({ + paymentUid: expect.any(String), + paymentRequired: true, + uid: expect.any(String), + }) + ); + + const firstBookingInDb = await prismaMock.booking.findUnique({ + where: { uid: firstBooking.uid }, + include: { payment: true }, + }); + + expect(firstBookingInDb).toBeTruthy(); + expect(firstBookingInDb?.payment).toHaveLength(1); + expect(firstBookingInDb?.paid).toBe(false); + + const secondBooking = await handleNewBooking({ + bookingData: mockBookingData, + }); + + expect(secondBooking.uid).toBe(firstBooking.uid); + expect(secondBooking.paymentUid).toBe(firstBooking.paymentUid); + expect(secondBooking.paymentRequired).toBe(true); + + const bookingsInDb = await prismaMock.booking.findMany({ + where: { + eventTypeId: 1, + attendees: { + some: { + email: booker.email, + }, + }, + }, + include: { payment: true }, + }); + + expect(bookingsInDb).toHaveLength(1); + expect(bookingsInDb[0].payment).toHaveLength(1); + expect(bookingsInDb[0].uid).toBe(firstBooking.uid); + }, + timeout + ); }); }); diff --git a/packages/lib/server/repository/booking.ts b/packages/lib/server/repository/booking.ts index e459989fb8..589d00bb4c 100644 --- a/packages/lib/server/repository/booking.ts +++ b/packages/lib/server/repository/booking.ts @@ -828,6 +828,7 @@ export class BookingRepository { attendees: true, references: true, user: true, + payment: true, }, }); }