Files
guilan-ace-backend/apps/events/checkout.py

137 lines
4.4 KiB
Python

"""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