319 lines
13 KiB
Python
319 lines
13 KiB
Python
"""Bounded payment verification and recovery for lost gateway callbacks."""
|
|
|
|
import logging
|
|
from datetime import timedelta
|
|
|
|
from celery import shared_task
|
|
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 (
|
|
PaymentReversalRejected,
|
|
PaymentReversalUncertain,
|
|
PaymentVerificationUncertain,
|
|
claim_payment_verification,
|
|
request_payment_verification,
|
|
request_payment_reversal,
|
|
schedule_payment_verification_retry,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _status_result(payment):
|
|
return {
|
|
Payment.OrderStatusChoices.PAID: "paid",
|
|
Payment.OrderStatusChoices.FAILED: "failed",
|
|
Payment.OrderStatusChoices.CANCELED: "canceled",
|
|
}.get(payment.status)
|
|
|
|
|
|
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,
|
|
_mark_payment_unsuccessful,
|
|
_notify_payment_status,
|
|
)
|
|
|
|
try:
|
|
code, data = request_payment_verification(payment)
|
|
except PaymentVerificationUncertain as exc:
|
|
_mark_payment_pending(payment.id)
|
|
delay = schedule_payment_verification_retry(payment.id)
|
|
if delay is None:
|
|
logger.error(
|
|
"Payment %s remains pending after exhausting verification attempts; manual review is required",
|
|
payment.id,
|
|
exc_info=exc,
|
|
)
|
|
return "pending_manual_review"
|
|
_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}» تأیید شد؛ بازگشت وجه در حال پیگیری است"
|
|
if refund_pending
|
|
else f"پرداخت رویداد «{payment.event.title}» تأیید شد"
|
|
),
|
|
message=(
|
|
"ثبتنام لغو شده است. بازگشت وجه بهصورت خودکار پیگیری میشود؛ اگر نتیجه روشن نشود، پشتیبانی آن را بررسی میکند."
|
|
if refund_pending
|
|
else "پرداخت شما با موفقیت ثبت شد و ثبتنام رویداد تکمیل شده است."
|
|
),
|
|
level="warning" if refund_pending else "success",
|
|
)
|
|
return "paid"
|
|
|
|
payment, result, transitioned = _mark_payment_unsuccessful(
|
|
payment.id,
|
|
Payment.OrderStatusChoices.FAILED,
|
|
)
|
|
if transitioned:
|
|
_notify_payment_status(
|
|
payment,
|
|
title=f"پرداخت رویداد «{payment.event.title}» ناموفق بود",
|
|
message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.",
|
|
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
|