"""Registration checkout lifecycle rules shared by API views and workers.""" import logging 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) logger = logging.getLogger(__name__) def _dispatch_reconciliation(payment_id): from apps.payments.tasks import reconcile_payment try: reconcile_payment.apply_async(args=(payment_id,), countdown=0) except Exception: logger.exception("Unable to dispatch checkout payment %s for reconciliation", payment_id) 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): """Reconcile each unresolved payment after a checkout is cancelled.""" 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 payment.authority: transaction.on_commit( lambda payment_id=payment.id: _dispatch_reconciliation(payment_id) ) else: # 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()) @transaction.atomic 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) expired_count = 0 for registration_id in candidates.values_list("id", flat=True).iterator(): registration = ( Registration.objects.select_for_update() .filter(pk=registration_id) .first() ) if registration is None or registration.status != Registration.StatusChoices.PENDING: 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 @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