Skip to content

chayan-mann/ticket-booking-system

Repository files navigation

🎟️ Ticket Booking System

A production-ready movie ticket booking system with complete payment integration, seat hold mechanism, and pessimistic locking to prevent double bookings.

✨ Features

πŸ”’ Concurrency Control

With pessimistic locking, the system guarantees:

βœ… No double booking - Only 1 user can book a seat even with 50+ concurrent requests
βœ… No race conditions - Database-level locking prevents conflicts
βœ… No concurrent writes - Seats are locked during transaction
βœ… No partial commits - All-or-nothing booking guarantees

πŸ’³ Complete Payment Flow

  • Fake Payment Gateway - Simulates Stripe/Razorpay for development
  • Webhook Integration - HMAC-SHA256 signature verification
  • Idempotent Processing - Prevents duplicate webhook handling
  • Refund Support - Time-based refund calculations

πŸͺ‘ Seat Hold Mechanism

  • 5-minute holds - Temporary reservations while selecting seats
  • Auto-expiry - Automatic cleanup of expired holds
  • Visual indicators - Shows available, held, held_by_me, booked

⏱️ Automated Background Jobs

  • Booking expiry (every minute) - Auto-cancel expired PENDING bookings
  • Hold cleanup (every 5 mins) - Remove expired seat holds
  • Data archival (daily 3 AM) - Cleanup old booking data

🎯 Booking Lifecycle

PENDING (15 min) β†’ Payment Success β†’ CONFIRMED
PENDING (15 min) β†’ Payment Failed  β†’ Extended (retry)
PENDING (15 min) β†’ Payment Expired β†’ EXPIRED
PENDING (15 min) β†’ Timeout         β†’ EXPIRED (cron job)
PENDING          β†’ User Cancel     β†’ CANCELLED
CONFIRMED        β†’ Refund Request  β†’ REFUNDED

πŸ’» Tech Stack

  • NestJS 11.x - Backend framework
  • PostgreSQL - Database with pessimistic locking
  • Prisma 7.x - ORM with raw SQL for critical sections
  • @nestjs/schedule - Cron job management
  • TypeScript - Type safety

πŸ”„ Booking Flow Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    USER      β”‚
β”‚  (Frontend)  β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚
       β”‚ 1. Browse Available Seats
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  GET /api/v1/seats/shows/{showId}?userId={userId}           β”‚
β”‚                                                              β”‚
β”‚  SeatsController β†’ SeatsService                             β”‚
β”‚         β”‚                                                    β”‚
β”‚         β–Ό                                                    β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                  β”‚
β”‚  β”‚ PostgreSQL Query:                    β”‚                  β”‚
β”‚  β”‚ β€’ Get all ShowSeats for show         β”‚                  β”‚
β”‚  β”‚ β€’ JOIN with active holds             β”‚                  β”‚
β”‚  β”‚ β€’ JOIN with PENDING/CONFIRMED bookingsβ”‚                 β”‚
β”‚  β”‚ β€’ Filter: exclude booked & held      β”‚                  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β”‚
β”‚         β”‚                                                    β”‚
β”‚         β–Ό                                                    β”‚
β”‚  Response: {                                                 β”‚
β”‚    seats: [{id, row, number, price, tier, status}],        β”‚
β”‚    summary: {available: 85, booked: 10, held: 5}           β”‚
β”‚  }                                                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚
       β”‚ 2. Hold Seats (Optional - 5 min lock)
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  POST /api/v1/bookings/hold-seats                           β”‚
β”‚  Body: {showId, userId, showSeatIds: ["id1", "id2"]}       β”‚
β”‚                                                              β”‚
β”‚  BookingsController β†’ BookingsService.holdSeats()           β”‚
β”‚         β”‚                                                    β”‚
β”‚         β–Ό                                                    β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                  β”‚
β”‚  β”‚ Transaction:                         β”‚                  β”‚
β”‚  β”‚ 1. Check seats not already held/bookedβ”‚                 β”‚
β”‚  β”‚ 2. INSERT into SeatHold             β”‚                  β”‚
β”‚  β”‚    expiresAt = now() + 5 minutes    β”‚                  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β”‚
β”‚         β”‚                                                    β”‚
β”‚         β–Ό                                                    β”‚
β”‚  Response: {                                                 β”‚
β”‚    success: true,                                           β”‚
β”‚    holds: [{seatId, expiresAt}],                           β”‚
β”‚    expiresIn: 300 seconds                                  β”‚
β”‚  }                                                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚
       β”‚ 3. Create Booking (PENDING status)
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  POST /api/v1/bookings                                      β”‚
β”‚  Body: {userId, showId, showSeatIds: ["id1", "id2"]}       β”‚
β”‚                                                              β”‚
β”‚  BookingsController β†’ BookingsService.createBooking()       β”‚
β”‚         β”‚                                                    β”‚
β”‚         β–Ό                                                    β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                  β”‚
β”‚  β”‚ πŸ”’ PESSIMISTIC LOCKING TRANSACTION:  β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚ 1. SELECT ... FOR UPDATE             β”‚                  β”‚
β”‚  β”‚    (Locks seat rows - prevents races)β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚ 2. Validate:                         β”‚                  β”‚
β”‚  β”‚    β€’ Show exists & future            β”‚                  β”‚
β”‚  β”‚    β€’ Seats exist                     β”‚                  β”‚
β”‚  β”‚    β€’ No existing bookings on seats   β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚ 3. Calculate totalAmount             β”‚                  β”‚
β”‚  β”‚    = SUM(seat.price)                 β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚ 4. INSERT Booking:                   β”‚                  β”‚
β”‚  β”‚    status = PENDING                  β”‚                  β”‚
β”‚  β”‚    expiresAt = now() + 15 min        β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚ 5. INSERT BookingSeats               β”‚                  β”‚
β”‚  β”‚    (Link booking ↔ seats)            β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚ 6. DELETE SeatHolds for user         β”‚                  β”‚
β”‚  β”‚    (Convert holds to booking)        β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚ 7. COMMIT (or ROLLBACK on conflict)  β”‚                  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β”‚
β”‚         β”‚                                                    β”‚
β”‚         β–Ό                                                    β”‚
β”‚  Response: {                                                 β”‚
β”‚    id: "booking-uuid",                                      β”‚
β”‚    status: "PENDING",                                       β”‚
β”‚    totalAmount: 500,                                        β”‚
β”‚    expiresAt: "2026-01-31T01:00:00Z"                       β”‚
β”‚  }                                                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚
       β”‚ 4. Initiate Payment
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  POST /api/v1/payments/initiate                             β”‚
β”‚  Body: {bookingId, userId}                                  β”‚
β”‚                                                              β”‚
β”‚  PaymentsController β†’ PaymentsService.initiatePayment()     β”‚
β”‚         β”‚                                                    β”‚
β”‚         β–Ό                                                    β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                  β”‚
β”‚  β”‚ 1. Validate booking is PENDING       β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚ 2. Call FakePaymentGateway:          β”‚                  β”‚
β”‚  β”‚    createPaymentSession()            β”‚                  β”‚
β”‚  β”‚    sessionId = "sess_" + uuid        β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚ 3. INSERT Payment:                   β”‚                  β”‚
β”‚  β”‚    status = PENDING                  β”‚                  β”‚
β”‚  β”‚    amount = booking.totalAmount      β”‚                  β”‚
β”‚  β”‚    gatewayRef = sessionId            β”‚                  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β”‚
β”‚         β”‚                                                    β”‚
β”‚         β–Ό                                                    β”‚
β”‚  Response: {                                                 β”‚
β”‚    paymentUrl: "/payments/fake-checkout/sess_xxx",         β”‚
β”‚    sessionId: "sess_xxx",                                  β”‚
β”‚    expiresAt: "2026-01-31T01:30:00Z"                       β”‚
β”‚  }                                                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚
       β”‚ 5. User Completes Payment
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  User redirected to payment page                            β”‚
β”‚  Clicks "Pay Now" β†’ Gateway processes payment               β”‚
β”‚                                                              β”‚
β”‚  POST /api/v1/payments/simulate/{sessionId} (Test Only)     β”‚
β”‚                        OR                                    β”‚
β”‚  POST /api/v1/payments/webhook (Production - from Gateway)  β”‚
β”‚                                                              β”‚
β”‚  PaymentsController β†’ PaymentsService.handleWebhook()       β”‚
β”‚         β”‚                                                    β”‚
β”‚         β–Ό                                                    β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                  β”‚
β”‚  β”‚ 1. Verify webhook signature          β”‚                  β”‚
β”‚  β”‚    (HMAC-SHA256)                     β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚ 2. Check idempotency key             β”‚                  β”‚
β”‚  β”‚    (Prevent duplicate processing)     β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚ 3. Process based on event type:      β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚    payment.success:                  β”‚                  β”‚
β”‚  β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚                  β”‚
β”‚  β”‚    β”‚ Transaction:                β”‚   β”‚                  β”‚
β”‚  β”‚    β”‚ β€’ UPDATE Payment:           β”‚   β”‚                  β”‚
β”‚  β”‚    β”‚   status = SUCCESS          β”‚   β”‚                  β”‚
β”‚  β”‚    β”‚ β€’ UPDATE Booking:           β”‚   β”‚                  β”‚
β”‚  β”‚    β”‚   status = CONFIRMED        β”‚   β”‚                  β”‚
β”‚  β”‚    β”‚ β€’ DELETE SeatHolds          β”‚   β”‚                  β”‚
β”‚  β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚    payment.failed:                   β”‚                  β”‚
β”‚  β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚                  β”‚
β”‚  β”‚    β”‚ β€’ UPDATE Payment: FAILED    β”‚   β”‚                  β”‚
β”‚  β”‚    β”‚ β€’ Extend booking.expiresAt  β”‚   β”‚                  β”‚
β”‚  β”‚    β”‚   (Give user time to retry) β”‚   β”‚                  β”‚
β”‚  β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚                  β”‚
β”‚  β”‚                                       β”‚                  β”‚
β”‚  β”‚    payment.expired:                  β”‚                  β”‚
β”‚  β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚                  β”‚
β”‚  β”‚    β”‚ β€’ UPDATE Payment: EXPIRED   β”‚   β”‚                  β”‚
β”‚  β”‚    β”‚ β€’ UPDATE Booking: EXPIRED   β”‚   β”‚                  β”‚
β”‚  β”‚    β”‚ β€’ DELETE BookingSeats       β”‚   β”‚                  β”‚
β”‚  β”‚    β”‚   (Release seats)           β”‚   β”‚                  β”‚
β”‚  β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚                  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                  β”‚
β”‚         β”‚                                                    β”‚
β”‚         β–Ό                                                    β”‚
β”‚  Response: { status: "processed" }                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚
       β”‚ 6. Get Booking Confirmation
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  GET /api/v1/bookings/{id}                                  β”‚
β”‚                                                              β”‚
β”‚  BookingsController β†’ BookingsService.getBooking()          β”‚
β”‚         β”‚                                                    β”‚
β”‚         β–Ό                                                    β”‚
β”‚  Response: {                                                 β”‚
β”‚    id: "booking-uuid",                                      β”‚
β”‚    status: "CONFIRMED",                                     β”‚
β”‚    totalAmount: 500,                                        β”‚
β”‚    seats: [{row: "A", number: 1, tier: "VIP"}],           β”‚
β”‚    payment: {status: "SUCCESS", amount: 500},              β”‚
β”‚    show: {movie, theatre, startTime}                       β”‚
β”‚  }                                                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚           ⏰ BACKGROUND JOBS (Running Continuously)         β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                              β”‚
β”‚  Every 1 Minute:                                            β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”‚
β”‚  β”‚ BookingExpiryCronService                       β”‚        β”‚
β”‚  β”‚ 1. Find bookings: PENDING + expiresAt < now()  β”‚        β”‚
β”‚  β”‚ 2. For each expired booking:                   β”‚        β”‚
β”‚  β”‚    β€’ DELETE BookingSeats (releases seats)      β”‚        β”‚
β”‚  β”‚    β€’ UPDATE Booking: status = EXPIRED          β”‚        β”‚
β”‚  β”‚    β€’ DELETE expired SeatHolds                  β”‚        β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β”‚
β”‚                                                              β”‚
β”‚  Every 5 Minutes:                                           β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”‚
β”‚  β”‚ Cleanup expired SeatHolds                      β”‚        β”‚
β”‚  β”‚ DELETE FROM SeatHold WHERE expiresAt < now()   β”‚        β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β”‚
β”‚                                                              β”‚
β”‚  Daily at 3 AM:                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”‚
β”‚  β”‚ Archive old booking data (30+ days)            β”‚        β”‚
β”‚  β”‚ DELETE BookingSeats from EXPIRED/CANCELLED     β”‚        β”‚
β”‚  β”‚ (Keeps booking record for history)             β”‚        β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸš€ Complete Booking Flow

1️⃣ Browse Available Seats

GET /api/v1/seats/shows/{showId}?userId={userId}

2️⃣ Hold Seats (Optional - 5 min hold)

POST /api/v1/bookings/hold-seats
{
  "showId": "uuid",
  "userId": "uuid",
  "showSeatIds": ["uuid1", "uuid2"]
}

3️⃣ Create Booking (PENDING - 15 min expiry)

POST /api/v1/bookings
{
  "userId": "uuid",
  "showId": "uuid",
  "showSeatIds": ["uuid1", "uuid2"]
}
# Response: { status: "PENDING", expiresAt: "...", totalAmount: 500 }

4️⃣ Initiate Payment

POST /api/v1/payments/initiate
{
  "bookingId": "uuid",
  "userId": "uuid"
}
# Response: { paymentUrl: "...", sessionId: "sess_..." }

5️⃣ Complete Payment (redirects user to payment page)

# User completes payment on payment gateway
# Gateway sends webhook to: POST /api/v1/payments/webhook
# Booking status automatically updates to CONFIRMED

6️⃣ Check Booking Status

GET /api/v1/bookings/{id}
GET /api/v1/payments/status/{bookingId}

πŸ“‹ API Endpoints

Bookings

Method Endpoint Description
POST /api/v1/bookings Create PENDING booking
GET /api/v1/bookings/:id Get booking details
GET /api/v1/bookings/user/:userId Get user's bookings
PATCH /api/v1/bookings/:id/cancel Cancel PENDING booking
POST /api/v1/bookings/hold-seats Hold seats (5 min)
DELETE /api/v1/bookings/holds/:userId Release seat holds

Payments

Method Endpoint Description
POST /api/v1/payments/initiate Start payment
POST /api/v1/payments/webhook Gateway callback
POST /api/v1/payments/simulate/:sessionId Test payment (dev only)
POST /api/v1/payments/refund/:bookingId Process refund
GET /api/v1/payments/status/:bookingId Payment status

Seats

Method Endpoint Description
GET /api/v1/seats/shows/:showId Seat availability with hold status
GET /api/v1/seats/shows/:showId/available Quick available seats list
GET /api/v1/seats/screens/:screenId Seat layout

πŸƒ Quick Start

Prerequisites

  • Node.js 18+
  • PostgreSQL
  • npm/yarn

Installation

# Install dependencies
npm install

# Setup database
npm run prisma:migrate
npm run prisma:generate

# Seed test data
npm run seed

# Start development server
npm run start:dev

Server runs at: http://localhost:8001/api/v1/health
Swagger docs: http://localhost:8001/api/docs

πŸ§ͺ Testing

Run Concurrency Tests

npm run test:e2e -- --testPathPatterns=bookings-concurrency

Test Results:

  • βœ… 10 concurrent requests β†’ 1 success, 9 failures
  • βœ… 50 concurrent requests β†’ 1 success, 49 failures
  • βœ… Different seats concurrently β†’ All succeed
  • βœ… No duplicate bookings in database

Run All Tests

npm run test:e2e

πŸ” Pessimistic Locking Implementation

The critical booking flow uses raw SQL with FOR UPDATE locking:

// Locks seats during transaction to prevent concurrent booking
await tx.$executeRaw`
  SELECT 1 FROM "ShowSeat" 
  WHERE id IN (${Prisma.join(showSeatIds)}) 
  FOR UPDATE
`;

This ensures only one transaction can modify seats at a time, even with high concurrency.

πŸ“Š Database Schema Highlights

  • SeatHold - Temporary 5-minute seat reservations
  • Pricing Tiers - REGULAR, PREMIUM, VIP seats
  • Booking Statuses - PENDING, CONFIRMED, CANCELLED, EXPIRED, REFUNDED
  • Optimized Indexes - Fast lookups on userId, showId, status, expiresAt

See ENHANCEMENT_SUMMARY.md for complete schema details.

πŸ“ Project Structure

src/
β”œβ”€β”€ modules/
β”‚   β”œβ”€β”€ bookings/       # Booking lifecycle & seat holds
β”‚   β”œβ”€β”€ payments/       # Payment flow & fake gateway
β”‚   β”œβ”€β”€ seats/          # Seat availability
β”‚   β”œβ”€β”€ shows/          # Show management
β”‚   β”œβ”€β”€ movies/         # Movie catalog
β”‚   └── theatres/       # Theatre & screen management
β”œβ”€β”€ common/
β”‚   β”œβ”€β”€ database/       # Prisma service
β”‚   └── crons/          # Scheduled jobs
└── config/             # App configuration

πŸ”§ Configuration

Environment Variables

DATABASE_URL=postgresql://user:password@localhost:5432/db
PORT=8001
NODE_ENV=development

# Optional: Payment gateway settings
PAYMENT_WEBHOOK_SECRET=your_webhook_secret
BOOKING_EXPIRY_MINUTES=15
SEAT_HOLD_MINUTES=5

πŸ“ License

MIT


For detailed documentation, see ENHANCEMENT_SUMMARY.md

About

A BookMyShow-like ticket booking system with seat hold mechanism, and pessimistic locking to prevent double bookings.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors