A production-ready movie ticket booking system with complete payment integration, seat hold mechanism, and pessimistic locking to prevent double bookings.
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
- 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
- 5-minute holds - Temporary reservations while selecting seats
- Auto-expiry - Automatic cleanup of expired holds
- Visual indicators - Shows
available,held,held_by_me,booked
- 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
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
- 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
ββββββββββββββββ
β 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) β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
GET /api/v1/seats/shows/{showId}?userId={userId}POST /api/v1/bookings/hold-seats
{
"showId": "uuid",
"userId": "uuid",
"showSeatIds": ["uuid1", "uuid2"]
}POST /api/v1/bookings
{
"userId": "uuid",
"showId": "uuid",
"showSeatIds": ["uuid1", "uuid2"]
}
# Response: { status: "PENDING", expiresAt: "...", totalAmount: 500 }POST /api/v1/payments/initiate
{
"bookingId": "uuid",
"userId": "uuid"
}
# Response: { paymentUrl: "...", sessionId: "sess_..." }# User completes payment on payment gateway
# Gateway sends webhook to: POST /api/v1/payments/webhook
# Booking status automatically updates to CONFIRMEDGET /api/v1/bookings/{id}
GET /api/v1/payments/status/{bookingId}| 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 |
| 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 |
| 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 |
- Node.js 18+
- PostgreSQL
- npm/yarn
# 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:devServer runs at: http://localhost:8001/api/v1/health
Swagger docs: http://localhost:8001/api/docs
npm run test:e2e -- --testPathPatterns=bookings-concurrencyTest 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
npm run test:e2eThe 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.
- 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.
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
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=5MIT
For detailed documentation, see ENHANCEMENT_SUMMARY.md