feat(events): harden registration checkout lifecycle
This commit is contained in:
136
apps/events/checkout.py
Normal file
136
apps/events/checkout.py
Normal 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
|
||||
Reference in New Issue
Block a user