diff --git a/server/src/business-layer/services/StripeService.ts b/server/src/business-layer/services/StripeService.ts index 9e508ac39..af8d621f2 100644 --- a/server/src/business-layer/services/StripeService.ts +++ b/server/src/business-layer/services/StripeService.ts @@ -20,6 +20,7 @@ import { import BookingSlotService from "data-layer/services/BookingSlotsService" import console from "console" import MailService from "./MailService" +import BookingUtils from "../utils/BookingUtils" const stripe = new Stripe(process.env.STRIPE_API_KEY) @@ -432,6 +433,12 @@ export default class StripeService { stripe_payment_id: session.id, user_id: uid }) + + // Check if the last spot is taken and expire other sessions + const isLastSpot = await BookingUtils.isLastSpotTaken(bookingSlotId) + if (isLastSpot) { + await this.expireOtherCheckoutSessions(bookingSlotId) + } }) ) /** @@ -452,6 +459,40 @@ export default class StripeService { } } + private async expireOtherCheckoutSessions( + bookingSlotId: string + ): Promise { + try { + // Fetch all checkout sessions for the specific booking slot id + const sessions = await this.getRecentActiveSessions( + CheckoutTypeValues.BOOKING, + 1440 + ) + + const sessionsToExpire = sessions.filter((session) => { + const bookingSlots = JSON.parse( + session.metadata[BOOKING_SLOTS_KEY] + ) as Array + return bookingSlots.includes(bookingSlotId) + }) + + // Expire each session that matches the booking slot id + await Promise.all( + sessionsToExpire.map(async (session) => { + if (session.id) { + await stripe.checkout.sessions.expire(session.id) + } + }) + ) + + console.log( + `[WEBHOOK] Expired ${sessionsToExpire.length} sessions for booking slot ${bookingSlotId}` + ) + } catch (err) { + console.error(`[WEBHOOK] Error expiring checkout sessions: ${err}`) + } + } + public async addCouponToUser( stripeId: string, amount: number diff --git a/server/src/business-layer/utils/BookingUtils.test.ts b/server/src/business-layer/utils/BookingUtils.test.ts index 8d5657a7c..d63b2b301 100644 --- a/server/src/business-layer/utils/BookingUtils.test.ts +++ b/server/src/business-layer/utils/BookingUtils.test.ts @@ -1,6 +1,10 @@ import { Timestamp } from "firebase-admin/firestore" import BookingUtils, { _earliestDate, _latestDate } from "./BookingUtils" import { LodgePricingTypeValues } from "./StripeProductMetadata" +import BookingDataService from "../../data-layer/services/BookingDataService" +import { cleanFirestore } from "../../test-config/TestUtils" +import { BookingSlot } from "../../data-layer/models/firebase" +import BookingSlotsService from "../../data-layer/services/BookingSlotsService" describe("BookingUtils", () => { describe("hasInvalidStartAndEndDates", () => { @@ -95,4 +99,64 @@ describe("BookingUtils", () => { ) }) }) + + describe("isLastSpotTaken", () => { + afterEach(async () => { + await cleanFirestore() + }) + + it("should return true if the last spot is taken", async () => { + // Create a booking slot with a maximum of 2 bookings + const timestamp = Timestamp.fromDate(new Date(2024, 4, 23)) + const bookingSlotData: BookingSlot = { + date: timestamp, + description: "booking_slot_description", + max_bookings: 2 + } + const { id: slotId } = await new BookingSlotsService().createBookingSlot( + bookingSlotData + ) + + // Create two bookings for the same slot + await new BookingDataService().createBooking({ + user_id: "ronaldo", + booking_slot_id: slotId, + stripe_payment_id: "stripeID3" + }) + + await new BookingDataService().createBooking({ + user_id: "sui", + booking_slot_id: slotId, + stripe_payment_id: "stripeID1" + }) + + const result = await BookingUtils.isLastSpotTaken(slotId) + + expect(result).toBe(true) + }) + + it("should return false if spots are still available", async () => { + // Create a booking slot with a maximum of 7 bookings + const timestamp = Timestamp.fromDate(new Date(2024, 4, 23)) + const bookingSlotData: BookingSlot = { + date: timestamp, + description: "booking_slot_description", + max_bookings: 7 + } + const { id: slotId } = await new BookingSlotsService().createBookingSlot( + bookingSlotData + ) + + // Create 1 booking for the slot + await new BookingDataService().createBooking({ + user_id: "sdf", + booking_slot_id: slotId, + stripe_payment_id: "stripeID3" + }) + + const result = await BookingUtils.isLastSpotTaken(slotId) + + expect(result).toBe(false) + }) + }) }) diff --git a/server/src/business-layer/utils/BookingUtils.ts b/server/src/business-layer/utils/BookingUtils.ts index bff66c29f..8f6a02ca9 100644 --- a/server/src/business-layer/utils/BookingUtils.ts +++ b/server/src/business-layer/utils/BookingUtils.ts @@ -1,6 +1,8 @@ import { Timestamp } from "firebase-admin/firestore" import { LodgePricingTypeValues } from "./StripeProductMetadata" import { firestoreTimestampToDate } from "data-layer/adapters/DateUtils" +import BookingDataService from "../../data-layer/services/BookingDataService" +import BookingSlotService from "../../data-layer/services/BookingSlotsService" // Need to validate the booking date through a startDate and endDate range. /** @@ -90,6 +92,25 @@ const BookingUtils = { } else { return LodgePricingTypeValues.Normal } + }, + + /** + * Checks if the last spot is taken for a specific booking slot + * @param bookingSlotId The ID of the booking slot + * @returns true if the last spot is taken, false otherwise + */ + isLastSpotTaken: async function (bookingSlotId: string): Promise { + const bookingDataService = new BookingDataService() + const bookingSlotService = new BookingSlotService() + + const bookingSlot = + await bookingSlotService.getBookingSlotById(bookingSlotId) + + const bookings = await bookingDataService.getBookingsBySlotId(bookingSlotId) + const bookingCount = bookings.length + + const availableSlots = bookingSlot.max_bookings - bookingCount + return availableSlots <= 0 } } as const