feat(events): harden registration checkout lifecycle

This commit is contained in:
2026-10-01 12:26:49 +03:30
parent 1daa755543
commit 962238c32c
29 changed files with 3048 additions and 207 deletions

136
apps/events/checkout.py Normal file
View File

@@ -0,0 +1,136 @@
"""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