"""Registration checkout lifecycle rules shared by API views and workers.""" from datetime import timedelta from django.db import transaction from django.utils import timezone from apps.events.models import Event, Registration CHECKOUT_TIMEOUT = timedelta(minutes=15) def mark_checkout_cancelled(registration, reason): """Cancel a pending checkout without deleting its historical registration.""" if registration.status != Registration.StatusChoices.PENDING: return False registration.status = Registration.StatusChoices.CANCELLED registration.cancellation_reason = reason registration.save(update_fields=[ "status", "cancellation_reason", "updated_at", ]) return True def queue_checkout_reconciliation(registration): """Mark no-authority attempts for review; the recovery sweep dispatches the rest.""" from apps.payments.models import Payment payments = list( Payment.objects.filter( registration=registration, status__in=[ Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING, ], ).only("id", "authority") ) for payment in payments: if not payment.authority: # The payment request may have reached the gateway before its # authority was persisted. Keep it visible for review until its # request response arrives or an administrator resolves it. Payment.objects.filter( pk=payment.id, status__in=[ Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING, ], authority__isnull=True, ).update(verification_manual_review_at=timezone.now()) def expire_stale_registration_checkouts(*, now=None, event_id=None): """Release seats for new-format pending checkouts whose deadline passed. Legacy registrations have a null checkout deadline and are intentionally excluded from this timeout sweep. """ now = now or timezone.now() candidates = Registration.objects.filter( status=Registration.StatusChoices.PENDING, is_deleted=False, checkout_expires_at__lte=now, ) if event_id is not None: candidates = candidates.filter(event_id=event_id) with transaction.atomic(): batch = list( candidates.select_for_update(skip_locked=True) .order_by("checkout_expires_at", "pk")[:50] ) expired_count = 0 for registration in batch: if registration.status != Registration.StatusChoices.PENDING or registration.checkout_expires_at > now: continue reason = ( Registration.CancellationReasonChoices.EVENT_CANCELLED if registration.event.status == Event.StatusChoices.CANCELLED else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED ) if mark_checkout_cancelled(registration, reason): expired_count += 1 queue_checkout_reconciliation(registration) return expired_count def expire_user_registration_checkouts(*, event, user, now): """Expire only this user's rows while the caller holds the event lock.""" registrations = ( Registration.objects.select_for_update() .filter( event=event, user=user, is_deleted=False, status=Registration.StatusChoices.PENDING, checkout_expires_at__lte=now, ) .order_by("pk") ) for registration in registrations: reason = ( Registration.CancellationReasonChoices.EVENT_CANCELLED if event.status == Event.StatusChoices.CANCELLED else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED ) if mark_checkout_cancelled(registration, reason): queue_checkout_reconciliation(registration) @transaction.atomic def cancel_event_checkouts(event_id): """Cancel active new-format checkouts when an organizer cancels an event.""" event = Event.objects.select_for_update().filter(pk=event_id).first() if event is None: return 0 now = timezone.now() registrations = ( Registration.objects.select_for_update() .filter( event=event, is_deleted=False, status=Registration.StatusChoices.PENDING, checkout_expires_at__isnull=False, ) .order_by("pk") ) cancelled_count = 0 for registration in registrations: if mark_checkout_cancelled( registration, Registration.CancellationReasonChoices.EVENT_CANCELLED, ): cancelled_count += 1 queue_checkout_reconciliation(registration) return cancelled_count