diff --git a/backend/bookings/services.py b/backend/bookings/services.py new file mode 100644 index 0000000..688ce54 --- /dev/null +++ b/backend/bookings/services.py @@ -0,0 +1,188 @@ +from dataclasses import dataclass +from datetime import timedelta + +from django.db import transaction +from django.db.models import Sum +from django.utils import timezone + +from shows.models import Performance + +from .models import Reservation, ReservationToken + + +CONFIRMATION_TOKEN_TTL = timedelta(hours=48) +CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL = timedelta(days=1) + + +class BookingServiceError(Exception): + """Base class for booking service domain errors.""" + + +class PerformanceNotAvailable(BookingServiceError): + pass + + +class NotEnoughSeats(BookingServiceError): + pass + + +class InvalidToken(BookingServiceError): + pass + + +class ExpiredToken(BookingServiceError): + pass + + +class AlreadyConfirmedReservation(BookingServiceError): + pass + + +@dataclass(frozen=True) +class PendingReservationResult: + reservation: Reservation + confirmation_token: ReservationToken + raw_confirmation_token: str + available_seats: int + + +@dataclass(frozen=True) +class ConfirmedReservationResult: + reservation: Reservation + confirmation_token: ReservationToken + check_in_token: ReservationToken + raw_check_in_token: str + available_seats: int + + +def calculate_available_seats(performance): + confirmed_seats = ( + Reservation.objects.filter( + performance=performance, + status=Reservation.Status.CONFIRMED, + ).aggregate(total=Sum("party_size"))["total"] + or 0 + ) + return performance.configured_capacity - confirmed_seats + + +def create_pending_reservation( + *, + performance_id, + name, + email, + party_size, + phone="", + notes="", + confirmation_expires_at=None, +): + with transaction.atomic(): + performance = _get_locked_bookable_performance(performance_id) + available_seats = calculate_available_seats(performance) + if party_size > available_seats: + raise NotEnoughSeats("Not enough seats are available for this performance.") + + reservation = Reservation( + performance=performance, + name=name, + email=email, + phone=phone, + party_size=party_size, + notes=notes, + ) + reservation.full_clean() + reservation.save() + + confirmation_token, raw_confirmation_token = generate_confirmation_token( + reservation, + expires_at=confirmation_expires_at, + ) + + return PendingReservationResult( + reservation=reservation, + confirmation_token=confirmation_token, + raw_confirmation_token=raw_confirmation_token, + available_seats=available_seats, + ) + + +def generate_confirmation_token(reservation, *, expires_at=None): + return ReservationToken.create_token( + reservation=reservation, + purpose=ReservationToken.Purpose.CONFIRMATION, + expires_at=expires_at or timezone.now() + CONFIRMATION_TOKEN_TTL, + ) + + +def confirm_reservation_from_token(raw_token): + token_hash = ReservationToken.hash_token(raw_token) + token_was_expired = False + + with transaction.atomic(): + try: + confirmation_token = ( + ReservationToken.objects.select_for_update() + .select_related("reservation") + .get(token_hash=token_hash, purpose=ReservationToken.Purpose.CONFIRMATION) + ) + except ReservationToken.DoesNotExist as exc: + raise InvalidToken("Confirmation token is invalid.") from exc + + reservation = Reservation.objects.select_for_update().get(pk=confirmation_token.reservation_id) + + if confirmation_token.is_used: + if reservation.status == Reservation.Status.CONFIRMED: + raise AlreadyConfirmedReservation("Reservation is already confirmed.") + raise InvalidToken("Confirmation token has already been used.") + + if confirmation_token.is_expired: + if reservation.status == Reservation.Status.PENDING: + reservation.status = Reservation.Status.EXPIRED + reservation.save(update_fields=["status", "updated_at"]) + token_was_expired = True + + if not token_was_expired and reservation.status == Reservation.Status.CONFIRMED: + raise AlreadyConfirmedReservation("Reservation is already confirmed.") + + if not token_was_expired and reservation.status != Reservation.Status.PENDING: + raise InvalidToken("Confirmation token is not valid for this reservation.") + + if not token_was_expired: + performance = _get_locked_bookable_performance(reservation.performance_id) + available_seats = calculate_available_seats(performance) + if reservation.party_size > available_seats: + raise NotEnoughSeats("Not enough seats are available for this performance.") + + reservation.confirm() + confirmation_token.mark_used() + + check_in_token, raw_check_in_token = ReservationToken.create_token( + reservation=reservation, + purpose=ReservationToken.Purpose.CHECK_IN, + expires_at=performance.starts_at + CHECK_IN_TOKEN_AFTER_PERFORMANCE_TTL, + ) + reservation.qr_code_generated_at = timezone.now() + reservation.save(update_fields=["qr_code_generated_at", "updated_at"]) + + if token_was_expired: + raise ExpiredToken("Confirmation token has expired.") + + return ConfirmedReservationResult( + reservation=reservation, + confirmation_token=confirmation_token, + check_in_token=check_in_token, + raw_check_in_token=raw_check_in_token, + available_seats=available_seats - reservation.party_size, + ) + + +def _get_locked_bookable_performance(performance_id): + try: + performance = Performance.objects.select_for_update().get(pk=performance_id) + except Performance.DoesNotExist as exc: + raise PerformanceNotAvailable("Performance is not available for booking.") from exc + + if not performance.is_booking_enabled: + raise PerformanceNotAvailable("Performance is not available for booking.") + + return performance diff --git a/backend/bookings/test_services.py b/backend/bookings/test_services.py new file mode 100644 index 0000000..8e3c597 --- /dev/null +++ b/backend/bookings/test_services.py @@ -0,0 +1,192 @@ +from datetime import timedelta + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone + +from bookings.models import Reservation, ReservationToken +from bookings.services import ( + AlreadyConfirmedReservation, + ExpiredToken, + InvalidToken, + NotEnoughSeats, + calculate_available_seats, + confirm_reservation_from_token, + create_pending_reservation, + generate_confirmation_token, +) +from shows.models import Performance, Show, Venue + + +class BookingServiceTests(TestCase): + def setUp(self): + self.show = Show.objects.create( + title="Open Stage", + slug="open-stage-services", + is_published=True, + ) + self.venue = Venue.objects.create( + name="AzioneLab Theatre", + slug="azionelab-theatre-services", + address="Via Example 1", + city="Rome", + ) + self.performance = Performance.objects.create( + show=self.show, + venue=self.venue, + starts_at=timezone.now() + timedelta(days=7), + room_capacity=3, + ) + + def test_create_pending_reservation_generates_confirmation_token(self): + result = create_pending_reservation( + performance_id=self.performance.id, + name="Maria Rossi", + email="maria@example.com", + party_size=2, + ) + + self.assertEqual(result.reservation.status, Reservation.Status.PENDING) + self.assertEqual(result.reservation.party_size, 2) + self.assertEqual(result.confirmation_token.purpose, ReservationToken.Purpose.CONFIRMATION) + self.assertEqual( + result.confirmation_token.token_hash, + ReservationToken.hash_token(result.raw_confirmation_token), + ) + self.assertNotIn("maria@example.com", result.raw_confirmation_token) + + def test_generate_confirmation_token_returns_raw_token_once(self): + reservation = self.create_reservation() + + token, raw_token = generate_confirmation_token(reservation) + + self.assertEqual(token.purpose, ReservationToken.Purpose.CONFIRMATION) + self.assertEqual(token.token_hash, ReservationToken.hash_token(raw_token)) + self.assertGreater(token.expires_at, timezone.now()) + + def test_confirm_reservation_from_valid_token(self): + reservation = self.create_reservation(party_size=2) + token, raw_token = generate_confirmation_token(reservation) + + result = confirm_reservation_from_token(raw_token) + reservation.refresh_from_db() + token.refresh_from_db() + + self.assertEqual(reservation.status, Reservation.Status.CONFIRMED) + self.assertIsNotNone(reservation.confirmed_at) + self.assertIsNotNone(reservation.qr_code_generated_at) + self.assertIsNotNone(token.used_at) + self.assertEqual(result.check_in_token.purpose, ReservationToken.Purpose.CHECK_IN) + self.assertEqual( + result.check_in_token.token_hash, + ReservationToken.hash_token(result.raw_check_in_token), + ) + self.assertEqual(result.available_seats, 1) + + def test_confirmation_fails_when_capacity_is_exhausted(self): + Reservation.objects.create( + performance=self.performance, + name="Confirmed Guest", + email="confirmed@example.com", + party_size=3, + status=Reservation.Status.CONFIRMED, + confirmed_at=timezone.now(), + ) + reservation = self.create_reservation(party_size=1) + token, raw_token = generate_confirmation_token(reservation) + + with self.assertRaises(NotEnoughSeats): + confirm_reservation_from_token(raw_token) + + reservation.refresh_from_db() + token.refresh_from_db() + self.assertEqual(reservation.status, Reservation.Status.PENDING) + self.assertIsNone(token.used_at) + + def test_pending_reservation_does_not_reduce_confirmed_availability(self): + create_pending_reservation( + performance_id=self.performance.id, + name="Pending Guest", + email="pending@example.com", + party_size=3, + ) + + self.assertEqual(calculate_available_seats(self.performance), 3) + + def test_invalid_token_fails(self): + with self.assertRaises(InvalidToken): + confirm_reservation_from_token("not-a-real-token") + + def test_expired_token_marks_pending_reservation_expired(self): + reservation = self.create_reservation() + token, raw_token = generate_confirmation_token( + reservation, + expires_at=timezone.now() - timedelta(minutes=1), + ) + + with self.assertRaises(ExpiredToken): + confirm_reservation_from_token(raw_token) + + reservation.refresh_from_db() + token.refresh_from_db() + self.assertEqual(reservation.status, Reservation.Status.EXPIRED) + self.assertIsNone(token.used_at) + + def test_duplicate_confirmation_is_rejected_safely(self): + reservation = self.create_reservation() + token, raw_token = generate_confirmation_token(reservation) + confirm_reservation_from_token(raw_token) + + with self.assertRaises(AlreadyConfirmedReservation): + confirm_reservation_from_token(raw_token) + + reservation.refresh_from_db() + token.refresh_from_db() + self.assertEqual(reservation.status, Reservation.Status.CONFIRMED) + self.assertIsNotNone(token.used_at) + self.assertEqual( + ReservationToken.objects.filter( + reservation=reservation, + purpose=ReservationToken.Purpose.CHECK_IN, + ).count(), + 1, + ) + + def test_confirmation_rechecks_capacity_after_another_confirmation(self): + self.performance.room_capacity = 1 + self.performance.save(update_fields=["room_capacity", "updated_at"]) + first = self.create_reservation(email="first@example.com", party_size=1) + second = self.create_reservation(email="second@example.com", party_size=1) + _, first_raw_token = generate_confirmation_token(first) + second_token, second_raw_token = generate_confirmation_token(second) + + confirm_reservation_from_token(first_raw_token) + + with self.assertRaises(NotEnoughSeats): + confirm_reservation_from_token(second_raw_token) + + second.refresh_from_db() + second_token.refresh_from_db() + self.assertEqual(second.status, Reservation.Status.PENDING) + self.assertIsNone(second_token.used_at) + self.assertEqual(calculate_available_seats(self.performance), 0) + + def test_confirmation_uses_row_level_locking(self): + reservation = self.create_reservation() + _, raw_token = generate_confirmation_token(reservation) + + with CaptureQueriesContext(connection) as queries: + confirm_reservation_from_token(raw_token) + + self.assertTrue(any("FOR UPDATE" in query["sql"] for query in queries)) + + def create_reservation(self, **overrides): + data = { + "performance": self.performance, + "name": "Maria Rossi", + "email": "maria@example.com", + "party_size": 1, + } + data.update(overrides) + return Reservation.objects.create(**data) diff --git a/docs/adr/0005-prevent-overbooking-with-database-transactions.md b/docs/adr/0005-prevent-overbooking-with-database-transactions.md new file mode 100644 index 0000000..4bdcbca --- /dev/null +++ b/docs/adr/0005-prevent-overbooking-with-database-transactions.md @@ -0,0 +1,29 @@ +# ADR-0005: Prevent Overbooking with Database Transactions + +Date: 2026-04-28 + +## Status + +Accepted + +## Context + +AzioneLab allows multiple visitors to create pending reservations for the same performance. Pending reservations do not guarantee seats, so final capacity must be enforced when a reservation is confirmed. + +Concurrent confirmation requests could otherwise read the same availability and confirm more seats than the performance allows. + +## Decision + +Use PostgreSQL transactions and Django ORM row-level locking to serialize capacity checks for a performance. + +When creating or confirming a reservation, the backend locks the related `Performance` row with `select_for_update()`, recalculates confirmed seats server-side, and proceeds only if enough seats remain. + +Confirmation performs the final capacity check inside the transaction before changing the reservation to `confirmed` and consuming the confirmation token. + +## Consequences + +- Capacity is enforced by the backend and database, not by frontend availability values. +- Concurrent confirmations for the same performance are serialized by the locked `Performance` row. +- Pending reservations can exceed current availability, but only confirmed reservations consume seats. +- The design stays simple and works with Django ORM and PostgreSQL. +- Very busy booking moments may queue briefly on the performance row lock.