From 380df8ec898b0a05198208a229900de406358116 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 11 Feb 2026 20:11:07 +0100 Subject: [PATCH] Fix voucher-required ticket validation at checkout --- .../registration/services/checkout.py | 18 ++++++++--- .../test_checkout_service.py | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/django_program/registration/services/checkout.py b/src/django_program/registration/services/checkout.py index bded236..a246270 100644 --- a/src/django_program/registration/services/checkout.py +++ b/src/django_program/registration/services/checkout.py @@ -100,11 +100,11 @@ def checkout( if not items: raise ValidationError("Cannot check out an empty cart.") - _revalidate_stock(items) + voucher = cart.voucher + _revalidate_stock(items, voucher=voucher) summary = CartService.get_summary_from_items(cart, items) - voucher = cart.voucher _validate_voucher_for_checkout(voucher) voucher_code = voucher.code if voucher else "" voucher_details = _snapshot_voucher(voucher) if voucher else "" @@ -339,7 +339,7 @@ def _increment_voucher_usage(*, voucher: Voucher | None, now: object) -> None: raise ValidationError(f"Voucher code '{voucher.code}' is no longer valid.") -def _revalidate_stock(items: list[object]) -> None: +def _revalidate_stock(items: list[object], *, voucher: Voucher | None) -> None: """Re-validate stock availability for all cart items at checkout time. Raises: @@ -349,16 +349,24 @@ def _revalidate_stock(items: list[object]) -> None: ticket_type_ids = {item.ticket_type_id for item in items if item.ticket_type_id is not None} for item in items: if item.ticket_type is not None: - _revalidate_ticket_stock(item) + _revalidate_ticket_stock(item, voucher=voucher) elif item.addon is not None: _revalidate_addon_stock(item, now, ticket_type_ids) -def _revalidate_ticket_stock(item: object) -> None: +def _revalidate_ticket_stock(item: object, *, voucher: Voucher | None) -> None: """Validate a ticket type is still available with sufficient stock.""" tt = item.ticket_type if not tt.is_available: raise ValidationError(f"Ticket type '{tt.name}' is no longer available.") + if tt.requires_voucher: + if voucher is None or not voucher.unlocks_hidden_tickets: + raise ValidationError( + f"Ticket type '{tt.name}' requires a voucher that unlocks hidden tickets." + ) + applicable_ids = set(voucher.applicable_ticket_types.values_list("pk", flat=True)) + if applicable_ids and tt.pk not in applicable_ids: + raise ValidationError(f"The applied voucher does not cover ticket type '{tt.name}'.") remaining = tt.remaining_quantity if remaining is not None and remaining < item.quantity: raise ValidationError(f"Only {remaining} tickets of type '{tt.name}' remaining, but {item.quantity} requested.") diff --git a/tests/test_registration/test_checkout_service.py b/tests/test_registration/test_checkout_service.py index 9eb5657..6b4befa 100644 --- a/tests/test_registration/test_checkout_service.py +++ b/tests/test_registration/test_checkout_service.py @@ -336,6 +336,37 @@ def test_rejects_checkout_when_voucher_usage_limit_reached(self, cart, ticket_ty with pytest.raises(ValidationError, match="no longer valid"): CheckoutService.checkout(cart) + def test_rejects_checkout_for_voucher_required_ticket_when_voucher_removed(self, cart, conference): + hidden_ticket = TicketType.objects.create( + conference=conference, + name="Hidden", + slug="hidden", + price=Decimal("100.00"), + total_quantity=0, + limit_per_user=10, + is_active=True, + requires_voucher=True, + ) + voucher = Voucher.objects.create( + conference=conference, + code="HIDDEN", + voucher_type=Voucher.VoucherType.COMP, + max_uses=5, + is_active=True, + unlocks_hidden_tickets=True, + ) + voucher.applicable_ticket_types.add(hidden_ticket) + + CartService.apply_voucher(cart, "HIDDEN") + CartService.add_ticket(cart, hidden_ticket, qty=1) + CartService.remove_voucher(cart) + + with pytest.raises(ValidationError, match="requires a voucher"): + CheckoutService.checkout(cart) + + voucher.refresh_from_db() + assert voucher.times_used == 0 + def test_retries_on_reference_collision(self, cart_with_ticket): """IntegrityError on duplicate reference triggers retry with new reference.""" real_create = Order.objects.create