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

View File

@@ -1,32 +1,63 @@
"""Bounded retries for payment verification after an inconclusive callback."""
"""Bounded payment verification and recovery for lost gateway callbacks."""
import logging
from datetime import timedelta
from celery import shared_task
from django.shortcuts import get_object_or_404
from django.conf import settings
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from apps.events.models import Registration
from apps.payments.models import Payment
from apps.payments.services import PaymentVerificationUncertain, request_payment_verification
from apps.payments.services import (
PaymentReversalRejected,
PaymentReversalUncertain,
PaymentVerificationUncertain,
claim_payment_verification,
request_payment_verification,
request_payment_reversal,
schedule_payment_verification_retry,
)
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=5, name="apps.payments.reconcile_payment")
def reconcile_payment(self, payment_id: int):
payment = get_object_or_404(
Payment.objects.select_related("event", "user"),
pk=payment_id,
)
if payment.status in (Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.FAILED):
return payment.get_status_display().lower()
if not payment.authority:
logger.error(
"Payment %s remains pending without a gateway authority; manual review is required",
payment.id,
)
return "pending_manual_review"
def _status_result(payment):
return {
Payment.OrderStatusChoices.PAID: "paid",
Payment.OrderStatusChoices.FAILED: "failed",
Payment.OrderStatusChoices.CANCELED: "canceled",
}.get(payment.status)
# Imported at task runtime to avoid a view/task import cycle.
def _queue_retry(payment_id: int, countdown: int):
try:
reconcile_payment.apply_async(args=(payment_id,), countdown=countdown)
except Exception:
logger.exception(
"Unable to queue payment verification for payment %s; the recovery sweep will retry it",
payment_id,
)
@shared_task(name="apps.payments.reconcile_payment")
def reconcile_payment(payment_id: int):
payment, claimed = claim_payment_verification(payment_id)
if payment is None:
return "not_found"
if not claimed:
terminal_result = _status_result(payment)
if terminal_result:
return terminal_result
if payment.verification_manual_review_at is not None or not payment.authority:
return "pending_manual_review"
if payment.verification_lease_until and payment.verification_lease_until > timezone.now():
return "verification_in_progress"
return "not_due"
# Imported here to avoid a view/task import cycle.
from apps.payments.api.views import (
_mark_payment_paid,
_mark_payment_pending,
@@ -38,24 +69,37 @@ def reconcile_payment(self, payment_id: int):
code, data = request_payment_verification(payment)
except PaymentVerificationUncertain as exc:
_mark_payment_pending(payment.id)
if self.request.retries >= self.max_retries:
delay = schedule_payment_verification_retry(payment.id)
if delay is None:
logger.error(
"Payment %s verification remains uncertain after bounded retries; manual review is required",
"Payment %s remains pending after exhausting verification attempts; manual review is required",
payment.id,
exc_info=exc,
)
return "pending_manual_review"
countdown = min(60 * (2 ** self.request.retries), 15 * 60)
raise self.retry(exc=exc, countdown=countdown)
_queue_retry(payment.id, delay)
return "retry_scheduled"
if code in (100, 101):
payment, newly_verified = _mark_payment_paid(payment.id, data)
if newly_verified:
refund_pending = payment.refund_status in (
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
)
_notify_payment_status(
payment,
title=f"پرداخت رویداد «{payment.event.title}» تأیید شد",
message="پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است.",
level="success",
title=(
f"پرداخت رویداد «{payment.event.title}» تأیید شد؛ بازگشت وجه در حال پیگیری است"
if refund_pending
else f"پرداخت رویداد «{payment.event.title}» تأیید شد"
),
message=(
"ثبت‌نام لغو شده است. بازگشت وجه به‌صورت خودکار پیگیری می‌شود؛ اگر نتیجه روشن نشود، پشتیبانی آن را بررسی می‌کند."
if refund_pending
else "پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است."
),
level="warning" if refund_pending else "success",
)
return "paid"
@@ -71,3 +115,204 @@ def reconcile_payment(self, payment_id: int):
level="error",
)
return result
@shared_task(name="apps.payments.sweep_stale_payment_verifications")
def sweep_stale_payment_verifications():
"""Queue due old attempts so lost callbacks and broker failures recover safely."""
now = timezone.now()
grace_seconds = max(
0,
getattr(settings, "PAYMENT_VERIFICATION_GRACE_SECONDS", 1800),
)
cutoff = now - timedelta(seconds=grace_seconds)
max_attempts = getattr(settings, "PAYMENT_VERIFICATION_MAX_ATTEMPTS", 6)
dispatch_lease_seconds = max(
60,
getattr(settings, "PAYMENT_VERIFICATION_DISPATCH_LEASE_SECONDS", 900),
)
candidates = (
Payment.objects.filter(
status__in=[
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
],
authority__isnull=False,
verification_attempts__lt=max_attempts,
verification_manual_review_at__isnull=True,
)
.filter(
Q(created_at__lte=cutoff)
| Q(registration__cancellation_reason__in=[
"checkout_expired", "user_cancelled", "event_cancelled",
])
)
.filter(Q(verification_next_attempt_at__isnull=True) | Q(verification_next_attempt_at__lte=now))
.filter(Q(verification_lease_until__isnull=True) | Q(verification_lease_until__lte=now))
.filter(
Q(verification_dispatch_expires_at__isnull=True)
| Q(verification_dispatch_expires_at__lte=now)
)
.order_by("created_at", "pk")
.values_list("pk", flat=True)[:100]
)
scheduled = 0
for payment_id in candidates:
with transaction.atomic():
payment = Payment.objects.select_for_update().filter(pk=payment_id).first()
if payment is None:
continue
canceled_checkout = bool(
payment.registration_id
and Registration.objects.filter(
pk=payment.registration_id,
cancellation_reason__in=[
Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
Registration.CancellationReasonChoices.USER_CANCELLED,
Registration.CancellationReasonChoices.EVENT_CANCELLED,
],
).exists()
)
if (
payment.status not in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
)
or not payment.authority
or (payment.created_at > cutoff and not canceled_checkout)
or payment.verification_attempts >= max_attempts
or payment.verification_manual_review_at is not None
or (payment.verification_lease_until and payment.verification_lease_until > now)
or (
payment.verification_next_attempt_at
and payment.verification_next_attempt_at > now
)
or (
payment.verification_dispatch_expires_at
and payment.verification_dispatch_expires_at > now
)
):
continue
dispatch_expires_at = now + timedelta(seconds=dispatch_lease_seconds)
payment.verification_dispatch_expires_at = dispatch_expires_at
payment.save(update_fields=["verification_dispatch_expires_at"])
try:
reconcile_payment.apply_async(args=(payment.id,))
except Exception:
Payment.objects.filter(
pk=payment.id,
verification_dispatch_expires_at=dispatch_expires_at,
).update(verification_dispatch_expires_at=None)
logger.exception(
"Unable to dispatch stale payment %s for verification",
payment.id,
)
continue
scheduled += 1
return scheduled
@shared_task(name="apps.payments.refund_payment")
def refund_payment(payment_id: int):
"""Attempt a bounded ZarinPal reversal; surface unresolved refunds to admins."""
now = timezone.now()
lease = timedelta(seconds=max(60, getattr(settings, "PAYMENT_REFUND_LEASE_SECONDS", 120)))
with transaction.atomic():
payment = Payment.objects.select_for_update().filter(pk=payment_id).first()
if payment is None:
return "not_found"
if (
payment.status != Payment.OrderStatusChoices.PAID
or payment.refund_status != Payment.RefundStatusChoices.PENDING
):
return payment.refund_status
if payment.refund_next_attempt_at and payment.refund_next_attempt_at > now:
return "not_due"
if not payment.authority:
payment.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
payment.refund_last_error = "No gateway authority is available for reversal."
payment.refund_next_attempt_at = None
payment.save(update_fields=[
"refund_status", "refund_last_error", "refund_next_attempt_at",
])
return "manual_review"
max_attempts = max(1, getattr(settings, "PAYMENT_REFUND_MAX_ATTEMPTS", 3))
if payment.refund_attempts >= max_attempts:
payment.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
payment.refund_next_attempt_at = None
payment.save(update_fields=["refund_status", "refund_next_attempt_at"])
return "manual_review"
payment.refund_attempts += 1
payment.refund_last_attempt_at = now
# Duplicate task deliveries cannot issue concurrent reversals. If a
# worker dies after the external request, the sweep retries at lease end.
payment.refund_next_attempt_at = now + lease
payment.save(update_fields=[
"refund_attempts", "refund_last_attempt_at", "refund_next_attempt_at",
])
try:
data = request_payment_reversal(payment)
except PaymentReversalRejected as exc:
Payment.objects.filter(pk=payment_id).update(
refund_status=Payment.RefundStatusChoices.MANUAL_REVIEW,
refund_last_error=str(exc)[:255],
refund_next_attempt_at=None,
)
return "manual_review"
except PaymentReversalUncertain as exc:
with transaction.atomic():
current = Payment.objects.select_for_update().get(pk=payment_id)
current.refund_last_error = str(exc)[:255]
if current.refund_attempts >= max(1, getattr(settings, "PAYMENT_REFUND_MAX_ATTEMPTS", 3)):
current.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
current.refund_next_attempt_at = None
result = "manual_review"
else:
delay = min(60 * (2 ** (current.refund_attempts - 1)), 15 * 60)
current.refund_next_attempt_at = timezone.now() + timedelta(seconds=delay)
result = "retry_scheduled"
current.save(update_fields=[
"refund_status", "refund_last_error", "refund_next_attempt_at",
])
if result == "retry_scheduled":
try:
refund_payment.apply_async(args=(payment_id,), countdown=delay)
except Exception:
logger.exception("Unable to queue refund retry for payment %s", payment_id)
return result
Payment.objects.filter(pk=payment_id).update(
refund_status=Payment.RefundStatusChoices.SUCCEEDED,
refund_reference=str(data.get("ref_id") or data.get("reference") or "")[:64] or None,
refund_last_error="",
refund_next_attempt_at=None,
)
return "refunded"
@shared_task(name="apps.payments.sweep_pending_refunds")
def sweep_pending_refunds():
"""Recover refund work when an immediate Celery dispatch is lost."""
now = timezone.now()
due_ids = Payment.objects.filter(
status=Payment.OrderStatusChoices.PAID,
refund_status=Payment.RefundStatusChoices.PENDING,
).filter(
Q(refund_next_attempt_at__isnull=True) | Q(refund_next_attempt_at__lte=now)
).order_by("refund_last_attempt_at", "pk").values_list("pk", flat=True)[:100]
scheduled = 0
for payment_id in due_ids:
try:
refund_payment.apply_async(args=(payment_id,))
scheduled += 1
except Exception:
logger.exception("Unable to dispatch pending refund for payment %s", payment_id)
return scheduled