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

@@ -52,18 +52,26 @@ class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin):
list_display = (
'id', 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount',
'status', 'created_at', 'verified_at', 'is_deleted'
'status', 'refund_status', 'refund_attempts', 'verification_attempts',
'verification_manual_review_at',
'created_at', 'verified_at', 'is_deleted'
)
list_filter = (
'status', 'event',
'status', 'refund_status', 'event', 'verification_manual_review_at',
SoftDeleteListFilter,
)
search_fields = (
'user__email', 'authority', 'ref_id', 'discount_code__code'
)
readonly_fields = (
'user', 'event', 'base_amount', 'discount_code', 'discount_code', 'discount_amount', 'amount', 'authority',
'status', 'ref_id', 'card_pan', 'card_hash', 'created_at', 'updated_at', 'deleted_at'
'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount', 'authority',
'status', 'ref_id', 'card_pan', 'card_hash', 'verified_at', 'gateway_paid_at',
'verification_attempts',
'verification_last_attempt_at', 'verification_next_attempt_at', 'verification_lease_until',
'verification_dispatch_expires_at', 'verification_manual_review_at', 'idempotency_key',
'refund_status', 'refund_attempts', 'refund_last_attempt_at', 'refund_next_attempt_at',
'refund_reference', 'refund_last_error',
'created_at', 'updated_at', 'deleted_at'
)
fieldsets = (
@@ -74,7 +82,20 @@ class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin):
'fields': ('base_amount', 'discount_code', 'discount_amount', 'amount')
}),
('Others', {
'fields': ('authority', 'ref_id', 'card_pan', 'card_hash')
'fields': ('authority', 'ref_id', 'card_pan', 'card_hash', 'verified_at', 'gateway_paid_at', 'idempotency_key')
}),
('Verification and Support Review', {
'fields': (
'verification_attempts', 'verification_last_attempt_at',
'verification_next_attempt_at', 'verification_lease_until',
'verification_dispatch_expires_at', 'verification_manual_review_at',
)
}),
('Refund and Reversal Review', {
'fields': (
'refund_status', 'refund_attempts', 'refund_last_attempt_at',
'refund_next_attempt_at', 'refund_reference', 'refund_last_error',
)
}),
('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'),

View File

@@ -71,21 +71,44 @@ class PaymentDetailOut(Schema):
amount: int
status: str
verified_at: str | None = None
gateway_paid_at: str | None = None
refund_status: Literal["none", "pending", "succeeded", "manual_review"] = "none"
refund_attempts: int = 0
refund_last_attempt_at: str | None = None
refund_next_attempt_at: str | None = None
refund_reference: str | None = None
refund_last_error: str | None = None
verification_state: Literal[
"awaiting_verification",
"checking",
"retry_scheduled",
"manual_review",
"verified",
"failed",
"canceled",
]
verification_attempts: int
last_verification_at: str | None = None
next_verification_at: str | None = None
manual_review_at: str | None = None
event: dict
class UserPaymentEventSchema(Schema):
id: int
title: str
slug: str
image_url: Optional[str] = None
class UserPaymentSchema(Schema):
payment_id: int
event: UserPaymentEventSchema
base_amount: int
discount_amount: int
amount: int
status: Literal["initiated", "pending", "paid", "failed", "canceled"]
refund_status: Literal["none", "pending", "succeeded", "manual_review"] = "none"
created_at: datetime
paid_at: Optional[datetime] = None

View File

@@ -1,5 +1,7 @@
import logging
from datetime import datetime
from uuid import uuid4
from urllib.parse import quote
from django.conf import settings
from django.shortcuts import redirect, get_object_or_404
@@ -14,8 +16,18 @@ import requests
from apps.payments.models import Payment, DiscountCode
from apps.events.models import Event, Registration
from apps.events.checkout import (
CHECKOUT_TIMEOUT,
expire_stale_registration_checkouts,
queue_checkout_reconciliation,
)
from apps.notifications.services import notify_user
from apps.payments.services import PaymentVerificationUncertain, request_payment_verification
from apps.payments.services import (
PaymentVerificationUncertain,
claim_payment_verification,
request_payment_verification,
schedule_payment_verification_retry,
)
from apps.payments.tasks import reconcile_payment
from apps.users.tasks import send_critical_sms
from core.authentication import jwt_auth
@@ -55,7 +67,9 @@ def my_payments(request):
def _user_payment_payload(request, payment, status_names):
return {
"payment_id": payment.id,
"event": {
"id": payment.event_id,
"title": payment.event.title,
"slug": payment.event.slug,
"image_url": request.build_absolute_uri(payment.event.featured_image.url)
@@ -66,6 +80,7 @@ def _user_payment_payload(request, payment, status_names):
"discount_amount": payment.discount_amount,
"amount": payment.amount,
"status": status_names[payment.status],
"refund_status": payment.refund_status,
"created_at": payment.created_at,
"paid_at": payment.verified_at,
}
@@ -270,9 +285,38 @@ def _payment_checkout_payload(payment: Payment):
}
def _ensure_checkout_attempt_can_be_reused(payment: Payment, *, now, allow_review_replay=False):
registration = payment.registration
if registration is None:
return
if payment.verification_manual_review_at is not None and not allow_review_replay:
raise HttpError(409, "وضعیت پرداخت در حال بررسی پشتیبانی است و امکان شروع دوباره وجود ندارد.")
if (
registration.status == Registration.StatusChoices.PENDING
and registration.checkout_expires_at is not None
and registration.checkout_expires_at <= now
):
raise HttpError(409, "مهلت پرداخت این ثبت‌نام به پایان رسیده است.")
if (
registration.status == Registration.StatusChoices.CANCELLED
and registration.cancellation_reason in {
Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
Registration.CancellationReasonChoices.USER_CANCELLED,
Registration.CancellationReasonChoices.EVENT_CANCELLED,
}
and payment.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING)
):
raise HttpError(409, "وضعیت پرداخت قبلی هنوز مشخص نشده است. پس از بررسی دوباره تلاش کنید.")
@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
def create_payment(request, payload: CreatePaymentIn):
idempotency_key = payload.idempotency_key or uuid4()
now = timezone.now()
expire_stale_registration_checkouts(now=now, event_id=payload.event_id)
# Serialize checkout creation for this event so concurrent taps cannot
# both create a new active attempt for the same user/event pair.
@@ -282,19 +326,48 @@ def create_payment(request, payload: CreatePaymentIn):
pk=payload.event_id,
is_deleted=False,
)
if event.status == Event.StatusChoices.CANCELLED:
raise HttpError(400, "این رویداد لغو شده است و امکان پرداخت ندارد.")
keyed_payment = Payment.objects.filter(
user=request.auth,
event=event,
idempotency_key=idempotency_key,
).select_related("event").first()
).select_related("event", "registration").first()
if keyed_payment:
_ensure_checkout_attempt_can_be_reused(
keyed_payment,
now=now,
allow_review_replay=True,
)
return _payment_checkout_payload(keyed_payment)
unresolved_refund = Payment.objects.filter(
user=request.auth,
event=event,
registration__cancellation_reason__in=[
Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
Registration.CancellationReasonChoices.USER_CANCELLED,
Registration.CancellationReasonChoices.EVENT_CANCELLED,
],
status=Payment.OrderStatusChoices.PAID,
refund_status__in=[
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
],
).exists()
if unresolved_refund:
raise HttpError(409, "بازگشت وجه پرداخت قبلی هنوز نهایی نشده است. پس از بررسی دوباره تلاش کنید.")
if Payment.objects.filter(
status=Payment.OrderStatusChoices.PAID,
user=request.auth,
event=event,
refund_status__in=[
Payment.RefundStatusChoices.NONE,
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
],
).exists():
raise HttpError(400, "You have already registered in this event")
@@ -304,10 +377,12 @@ def create_payment(request, payload: CreatePaymentIn):
event=event,
status__in=[Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING],
)
.select_related("registration")
.order_by("-created_at", "-id")
.first()
)
if active_payment:
_ensure_checkout_attempt_can_be_reused(active_payment, now=now)
return _payment_checkout_payload(active_payment)
registration = (
@@ -316,11 +391,29 @@ def create_payment(request, payload: CreatePaymentIn):
.order_by("-registered_at")
.first()
)
if registration and registration.status in (
Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED,
):
raise HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید.")
needs_new_checkout = not registration or registration.status == Registration.StatusChoices.CANCELLED
if needs_new_checkout:
if event.status != Event.StatusChoices.PUBLISHED:
raise HttpError(400, "ثبت‌نام این رویداد فعال نیست.")
if event.registration_start_date and event.registration_start_date > now:
raise HttpError(400, "زمان ثبت‌نام هنوز آغاز نشده است")
if event.registration_end_date and event.registration_end_date < now:
raise HttpError(400, "مهلت ثبت‌نام به پایان رسیده‌است")
if not event.has_available_slots:
raise HttpError(400, "ظرفیت شرکت‌کنندگان تکمیل است")
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
registration = Registration.objects.create(
event=event,
user=request.auth,
status=Registration.StatusChoices.PENDING,
checkout_started_at=now,
checkout_expires_at=now + CHECKOUT_TIMEOUT,
final_price=event.price,
)
elif registration.final_price is None:
@@ -331,7 +424,17 @@ def create_payment(request, payload: CreatePaymentIn):
discount_amount = 0
final_amount = event.price
if payload.discount_code:
if (
registration.status == Registration.StatusChoices.PENDING
and registration.final_price is not None
and (registration.discount_code_id or not payload.discount_code)
):
# Resume with the persisted quote; a browser reload must not lose a
# coupon or silently change the amount agreed when the seat was held.
discount_code = registration.discount_code
discount_amount = registration.discount_amount
final_amount = registration.final_price
elif payload.discount_code:
discount_code = DiscountCode.objects.filter(
code=payload.discount_code,
applicable_events=event,
@@ -421,7 +524,8 @@ def create_payment(request, payload: CreatePaymentIn):
pay.id,
)
pay.status = Payment.OrderStatusChoices.PENDING
pay.save(update_fields=["status"])
pay.verification_manual_review_at = timezone.now()
pay.save(update_fields=["status", "verification_manual_review_at"])
return _payment_checkout_payload(pay)
data = jd.get("data") if isinstance(jd, dict) else None
@@ -441,31 +545,122 @@ def create_payment(request, payload: CreatePaymentIn):
pay.id,
)
pay.status = Payment.OrderStatusChoices.PENDING
pay.save(update_fields=["status"])
pay.verification_manual_review_at = timezone.now()
pay.save(update_fields=["status", "verification_manual_review_at"])
return _payment_checkout_payload(pay)
pay.authority = authority
pay.status = Payment.OrderStatusChoices.PENDING
pay.save(update_fields=["authority","status"])
with transaction.atomic():
event = Event.objects.select_for_update().get(pk=pay.event_id)
registration = None
if pay.registration_id:
registration = Registration.objects.select_for_update().filter(pk=pay.registration_id).first()
pay = Payment.objects.select_for_update().get(pk=pay.id)
pay.authority = authority
if pay.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING):
pay.status = Payment.OrderStatusChoices.PENDING
pay.verification_manual_review_at = None
pay.save(update_fields=["authority", "status", "verification_manual_review_at"])
if (
registration is not None
and registration.status == Registration.StatusChoices.CANCELLED
) or event.status == Event.StatusChoices.CANCELLED:
if registration is not None:
queue_checkout_reconciliation(registration)
return _payment_checkout_payload(pay)
def _callback_redirect(payment: Payment, status: str):
url = (
f"{settings.FRONTEND_CALLBACK_URL}?status={status}"
f"&event_id={payment.event_id}&payment_id={payment.id}"
)
if status == "success" and payment.ref_id:
url += f"&ref_id={payment.ref_id}"
def _frontend_root_url():
root = (getattr(settings, "FRONTEND_ROOT", "") or settings.FRONTEND_CALLBACK_URL).rstrip("/")
return root
def _callback_redirect(payment: Payment, _status: str):
"""Return to the authenticated hub; its API read is the status authority."""
event = payment.event
url = f"{_frontend_root_url()}/events/{quote(event.slug or str(event.id), safe='')}/registration"
if payment.registration_id:
url += f"?registration_id={payment.registration.ticket_id}"
else:
url += f"?payment_id={payment.id}"
return redirect(url)
def _callback_unknown_redirect():
"""Send malformed or stale provider callbacks to the designed safe fallback."""
return redirect(f"{_frontend_root_url()}/events?payment_callback=unmatched")
def _provider_paid_at(verification_data: dict | None):
"""Read an explicit timezone-aware payment time if the gateway supplies one."""
raw_value = verification_data.get("paid_at") if isinstance(verification_data, dict) else None
if isinstance(raw_value, datetime):
return raw_value if timezone.is_aware(raw_value) else None
if not isinstance(raw_value, str) or not raw_value.strip():
return None
try:
value = datetime.fromisoformat(raw_value.strip().replace("Z", "+00:00"))
except ValueError:
return None
return value if timezone.is_aware(value) else None
def _checkout_may_be_confirmed(event, registration, paid_at, now):
if registration is None:
return False
if registration.status in (
Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED,
):
return True
if event.status == Event.StatusChoices.CANCELLED:
return False
expiry = registration.checkout_expires_at
if registration.status == Registration.StatusChoices.PENDING:
if registration.cancellation_reason:
return False
if expiry is None or expiry > now:
return True
if (
registration.status == Registration.StatusChoices.CANCELLED
and registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
and expiry is not None
and paid_at is not None
and paid_at <= expiry
and event.has_available_slots
):
return True
return False
def _queue_payment_refund(payment_id):
from apps.payments.tasks import refund_payment
def dispatch():
try:
refund_payment.apply_async(args=(payment_id,), countdown=0)
except Exception:
logger.exception("Unable to dispatch refund reversal for payment %s", payment_id)
transaction.on_commit(
dispatch
)
def _mark_payment_paid(payment_id: int, verification_data: dict | None = None):
"""Persist a verified payment and its registration as one idempotent transition."""
"""Persist provider success without reviving a cancelled checkout by accident."""
payment_snapshot = Payment.objects.only("event_id", "registration_id").get(pk=payment_id)
paid_at = _provider_paid_at(verification_data)
with transaction.atomic():
event = Event.objects.select_for_update().get(pk=payment_snapshot.event_id)
registration = None
if payment_snapshot.registration_id:
registration = Registration.objects.select_for_update().filter(
pk=payment_snapshot.registration_id
).first()
payment = (
Payment.objects.select_for_update()
.select_related("event", "user")
.get(pk=payment_id)
)
was_already_verified = (
@@ -483,27 +678,63 @@ def _mark_payment_paid(payment_id: int, verification_data: dict | None = None):
payment.card_hash = verification_data.get("card_hash")
payment.verified_at = timezone.now()
updates.extend(["ref_id", "card_pan", "card_hash", "verified_at"])
if paid_at is not None and payment.gateway_paid_at is None:
payment.gateway_paid_at = paid_at
updates.append("gateway_paid_at")
for field in (
"verification_next_attempt_at",
"verification_lease_until",
"verification_dispatch_expires_at",
"verification_manual_review_at",
):
if getattr(payment, field) is not None:
setattr(payment, field, None)
updates.append(field)
if updates:
payment.save(update_fields=updates)
registration = None
if payment.registration_id:
registration = Registration.objects.select_for_update().filter(
pk=payment.registration_id
).first()
if registration is None:
registration = Registration.objects.select_for_update().filter(
user_id=payment.user_id,
event_id=payment.event_id,
status=Registration.StatusChoices.PENDING,
).first()
if registration and registration.status != Registration.StatusChoices.CONFIRMED:
registration.status = Registration.StatusChoices.CONFIRMED
registration_updates = ["status"]
now = timezone.now()
if registration and registration.status == Registration.StatusChoices.PENDING:
if event.status == Event.StatusChoices.CANCELLED:
registration.status = Registration.StatusChoices.CANCELLED
registration.cancellation_reason = Registration.CancellationReasonChoices.EVENT_CANCELLED
registration.save(update_fields=["status", "cancellation_reason", "updated_at"])
elif (
registration.checkout_expires_at is not None
and registration.checkout_expires_at <= now
and not registration.cancellation_reason
):
registration.status = Registration.StatusChoices.CANCELLED
registration.cancellation_reason = Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
registration.save(update_fields=["status", "cancellation_reason", "updated_at"])
if _checkout_may_be_confirmed(event, registration, paid_at, now):
if registration.status == Registration.StatusChoices.CANCELLED:
registration.cancellation_reason = ""
registration.status = Registration.StatusChoices.CONFIRMED
registration_updates = ["status", "cancellation_reason"]
else:
registration.status = Registration.StatusChoices.CONFIRMED
registration_updates = ["status"]
if registration.final_price is None:
registration.final_price = payment.amount
registration_updates.append("final_price")
registration.save(update_fields=registration_updates)
if payment.refund_status != Payment.RefundStatusChoices.NONE:
payment.refund_status = Payment.RefundStatusChoices.NONE
payment.refund_last_error = ""
payment.refund_next_attempt_at = None
payment.save(update_fields=[
"refund_status", "refund_last_error", "refund_next_attempt_at",
])
elif not was_already_verified and payment.refund_status not in (
Payment.RefundStatusChoices.SUCCEEDED,
Payment.RefundStatusChoices.MANUAL_REVIEW,
):
payment.refund_status = Payment.RefundStatusChoices.PENDING
payment.refund_next_attempt_at = None
payment.save(update_fields=["refund_status", "refund_next_attempt_at"])
_queue_payment_refund(payment.id)
return payment, not was_already_verified
@@ -516,7 +747,11 @@ def _mark_payment_pending(payment_id: int):
if payment.status != Payment.OrderStatusChoices.PAID:
payment.status = Payment.OrderStatusChoices.PAID
payment.save(update_fields=["status"])
elif payment.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.CANCELED):
elif payment.status in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.FAILED,
Payment.OrderStatusChoices.CANCELED,
):
payment.status = Payment.OrderStatusChoices.PENDING
payment.save(update_fields=["status"])
return payment
@@ -524,10 +759,16 @@ def _mark_payment_pending(payment_id: int):
def _mark_payment_unsuccessful(payment_id: int, target_status: int):
"""Apply a failure only while the payment has not been verified successfully."""
snapshot = Payment.objects.only("event_id", "registration_id", "user_id").get(pk=payment_id)
with transaction.atomic():
Event.objects.select_for_update().get(pk=snapshot.event_id)
registration = None
if snapshot.registration_id:
registration = Registration.objects.select_for_update().filter(
pk=snapshot.registration_id,
).first()
payment = (
Payment.objects.select_for_update()
.select_related("event", "user")
.get(pk=payment_id)
)
if (
@@ -551,7 +792,17 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
transitioned = False
else:
payment.status = target_status
payment.save(update_fields=["status"])
payment.verification_next_attempt_at = None
payment.verification_lease_until = None
payment.verification_dispatch_expires_at = None
payment.verification_manual_review_at = None
payment.save(update_fields=[
"status",
"verification_next_attempt_at",
"verification_lease_until",
"verification_dispatch_expires_at",
"verification_manual_review_at",
])
result = (
"canceled"
if target_status == Payment.OrderStatusChoices.CANCELED
@@ -559,13 +810,7 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
)
transitioned = True
registration = None
if payment.registration_id:
registration = Registration.objects.select_for_update().filter(
pk=payment.registration_id,
status=Registration.StatusChoices.PENDING,
).first()
else:
if registration is None:
registration = Registration.objects.select_for_update().filter(
user_id=payment.user_id,
event_id=payment.event_id,
@@ -589,7 +834,10 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
Registration.objects.filter(
pk=registration.pk,
status=Registration.StatusChoices.PENDING,
).update(status=Registration.StatusChoices.CANCELLED)
).update(
status=Registration.StatusChoices.CANCELLED,
cancellation_reason=Registration.CancellationReasonChoices.PAYMENT_FAILED,
)
return payment, result, transitioned
@@ -597,49 +845,96 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
@payments_router.get("callback")
def callback(request, Authority: str | None = None, Status: str | None = None):
if not Authority:
raise HttpError(400, "Missing Authority")
return _callback_unknown_redirect()
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
if not pay:
raise HttpError(404, "Payment not found")
return _callback_unknown_redirect()
gateway_says_success = (Status or "").strip().upper() == "OK"
if pay.status == Payment.OrderStatusChoices.PAID or pay.verified_at is not None:
pay, _ = _mark_payment_paid(pay.id)
return _callback_redirect(pay, "success")
if pay.status in (
Payment.OrderStatusChoices.FAILED,
Payment.OrderStatusChoices.CANCELED,
) and not gateway_says_success:
return _callback_redirect(pay, _payment_status_name(pay))
pay, claimed = claim_payment_verification(
pay.id,
allow_terminal=gateway_says_success,
allow_manual_review=gateway_says_success,
)
if not claimed:
return _callback_redirect(pay, _payment_status_name(pay))
try:
vcode, data = request_payment_verification(pay)
except PaymentVerificationUncertain:
pay = _mark_payment_pending(pay.id)
if pay.status == Payment.OrderStatusChoices.PENDING and pay.authority:
delay = schedule_payment_verification_retry(pay.id)
pay.refresh_from_db()
if delay is not None and pay.status == Payment.OrderStatusChoices.PENDING:
try:
reconcile_payment.apply_async(args=(pay.id,), countdown=60)
reconcile_payment.apply_async(args=(pay.id,), countdown=delay)
except Exception:
logger.exception(
"Unable to queue payment verification retry for payment %s; it remains pending for manual review",
"Unable to queue payment verification retry for payment %s; the recovery sweep will retry it",
pay.id,
)
elif delay is None and pay.status == Payment.OrderStatusChoices.PENDING:
logger.error(
"Payment %s remains pending after exhausting verification attempts; manual review is required",
pay.id,
)
return _callback_redirect(pay, _payment_status_name(pay))
if vcode in (100, 101):
pay, newly_verified = _mark_payment_paid(pay.id, data)
if newly_verified:
refund_pending = pay.refund_status in (
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
)
_notify_payment_status(
pay,
title=f"پرداخت رویداد «{pay.event.title}» تأیید شد",
message="پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است.",
level="success",
title=(
f"پرداخت رویداد «{pay.event.title}» تأیید شد؛ بازگشت وجه در حال پیگیری است"
if refund_pending
else f"پرداخت رویداد «{pay.event.title}» تأیید شد"
),
message=(
"ثبت‌نام لغو شده است. بازگشت وجه به‌صورت خودکار پیگیری می‌شود؛ اگر نتیجه روشن نشود، پشتیبانی آن را بررسی می‌کند."
if refund_pending
else "پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است."
),
level="warning" if refund_pending else "success",
)
return _callback_redirect(pay, "success")
pay, result, transitioned = _mark_payment_unsuccessful(pay.id, Payment.OrderStatusChoices.FAILED)
target_status = (
Payment.OrderStatusChoices.CANCELED
if (Status or "").strip().upper() == "NOK"
else Payment.OrderStatusChoices.FAILED
)
pay, result, transitioned = _mark_payment_unsuccessful(pay.id, target_status)
if transitioned:
canceled = result == "canceled"
_notify_payment_status(
pay,
title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود",
message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.",
level="error",
title=(
f"پرداخت رویداد «{pay.event.title}» لغو شد"
if canceled
else f"پرداخت رویداد «{pay.event.title}» ناموفق بود"
),
message=(
"پرداخت تکمیل نشد و ثبت‌نام نهایی نشد. برای شروع دوباره به صفحه رویداد برگردید."
if canceled
else "تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید."
),
level="warning" if canceled else "error",
)
return _callback_redirect(pay, result)
@@ -666,6 +961,34 @@ def _payment_detail_payload(request, payment: Payment):
"amount": payment.amount,
"status": _payment_status_name(payment),
"verified_at": payment.verified_at.isoformat() if payment.verified_at else None,
"gateway_paid_at": payment.gateway_paid_at.isoformat() if payment.gateway_paid_at else None,
"refund_status": payment.refund_status,
"refund_attempts": payment.refund_attempts,
"refund_last_attempt_at": (
payment.refund_last_attempt_at.isoformat() if payment.refund_last_attempt_at else None
),
"refund_next_attempt_at": (
payment.refund_next_attempt_at.isoformat() if payment.refund_next_attempt_at else None
),
"refund_reference": payment.refund_reference,
"refund_last_error": payment.refund_last_error or None,
"verification_state": payment.verification_state,
"verification_attempts": payment.verification_attempts,
"last_verification_at": (
payment.verification_last_attempt_at.isoformat()
if payment.verification_last_attempt_at
else None
),
"next_verification_at": (
payment.verification_next_attempt_at.isoformat()
if payment.verification_next_attempt_at
else None
),
"manual_review_at": (
payment.verification_manual_review_at.isoformat()
if payment.verification_manual_review_at
else None
),
"event": {
"id": event.id,
"title": event.title,
@@ -676,9 +999,13 @@ def _payment_detail_payload(request, payment: Payment):
}
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut, auth=jwt_auth)
def payment_by_ref(request, ref_id: str):
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id)
pay = get_object_or_404(
Payment.objects.select_related("event"),
ref_id=ref_id,
user=request.auth,
)
return _payment_detail_payload(request, pay)
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.5 on 2026-09-30 18:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0004_payment_idempotency_key_and_more'),
]
operations = [
migrations.AddField(
model_name='payment',
name='verification_attempts',
field=models.PositiveSmallIntegerField(default=0, editable=False),
),
migrations.AddField(
model_name='payment',
name='verification_dispatch_expires_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='verification_last_attempt_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='verification_lease_until',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='verification_manual_review_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='verification_next_attempt_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.5 on 2026-09-30 19:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0005_payment_verification_attempts_and_more'),
]
operations = [
migrations.AddField(
model_name='payment',
name='refund_attempts',
field=models.PositiveSmallIntegerField(default=0, editable=False),
),
migrations.AddField(
model_name='payment',
name='refund_last_attempt_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='refund_last_error',
field=models.CharField(blank=True, default='', editable=False, max_length=255),
),
migrations.AddField(
model_name='payment',
name='refund_next_attempt_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='refund_reference',
field=models.CharField(blank=True, editable=False, max_length=64, null=True),
),
migrations.AddField(
model_name='payment',
name='refund_status',
field=models.CharField(choices=[('none', 'No refund required'), ('pending', 'Refund pending'), ('succeeded', 'Refund completed'), ('manual_review', 'Refund needs review')], default='none', editable=False, max_length=20),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2026-10-01 04:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0006_payment_refund_attempts_and_more'),
]
operations = [
migrations.AddField(
model_name='payment',
name='gateway_paid_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]

View File

@@ -81,6 +81,12 @@ class Payment(BaseModel):
FAILED = 3, "Failed"
CANCELED = 4, "Canceled"
class RefundStatusChoices(models.TextChoices):
NONE = "none", "No refund required"
PENDING = "pending", "Refund pending"
SUCCEEDED = "succeeded", "Refund completed"
MANUAL_REVIEW = "manual_review", "Refund needs review"
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payments', editable=False)
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='payments', editable=False)
@@ -104,6 +110,24 @@ class Payment(BaseModel):
card_pan = models.CharField(max_length=32, null=True, blank=True, editable=False)
card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False)
verified_at = models.DateTimeField(null=True, blank=True, editable=False)
gateway_paid_at = models.DateTimeField(null=True, blank=True, editable=False)
verification_attempts = models.PositiveSmallIntegerField(default=0, editable=False)
verification_last_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
verification_next_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
verification_lease_until = models.DateTimeField(null=True, blank=True, editable=False)
verification_dispatch_expires_at = models.DateTimeField(null=True, blank=True, editable=False)
verification_manual_review_at = models.DateTimeField(null=True, blank=True, editable=False)
refund_status = models.CharField(
max_length=20,
choices=RefundStatusChoices.choices,
default=RefundStatusChoices.NONE,
editable=False,
)
refund_attempts = models.PositiveSmallIntegerField(default=0, editable=False)
refund_last_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
refund_next_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
refund_reference = models.CharField(max_length=64, null=True, blank=True, editable=False)
refund_last_error = models.CharField(max_length=255, blank=True, default='', editable=False)
class Meta:
constraints = [
@@ -127,5 +151,24 @@ class Payment(BaseModel):
"""Human-readable label for the payment status."""
return self.get_status_display()
@property
def verification_state(self):
"""Server-owned verification state for callback and support screens."""
if self.status == self.OrderStatusChoices.PAID or self.verified_at is not None:
return "verified"
if self.status == self.OrderStatusChoices.FAILED:
return "failed"
if self.status == self.OrderStatusChoices.CANCELED:
return "canceled"
if self.verification_manual_review_at is not None or not self.authority:
return "manual_review"
now = timezone.now()
if self.verification_lease_until and self.verification_lease_until > now:
return "checking"
if self.verification_next_attempt_at:
return "retry_scheduled"
return "awaiting_verification"
def __str__(self):
return f"{self.user.email}:{self.event} - {self.get_status_display()}"

View File

@@ -1,6 +1,10 @@
"""Gateway operations whose responses must be interpreted conservatively."""
"""Gateway operations and durable verification scheduling."""
from datetime import timedelta
from django.conf import settings
from django.db import transaction
from django.utils import timezone
import requests
@@ -8,6 +12,14 @@ class PaymentVerificationUncertain(Exception):
"""The gateway response does not prove either payment success or failure."""
class PaymentReversalUncertain(Exception):
"""The gateway did not give a conclusive response to a reversal request."""
class PaymentReversalRejected(Exception):
"""The gateway definitively rejected the reversal and needs human review."""
def request_payment_verification(payment):
try:
response = requests.post(
@@ -46,3 +58,184 @@ def request_payment_verification(payment):
)
return code, body["data"]
def request_payment_reversal(payment):
"""Reverse a paid transaction through ZarinPal's transaction reversal API."""
try:
response = requests.post(
settings.ZARINPAL_REVERSE_URL,
json={
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
"authority": payment.authority,
},
headers={"accept": "application/json", "content-type": "application/json"},
timeout=15,
)
status_code = getattr(response, "status_code", 200)
if isinstance(status_code, int) and status_code >= 500:
raise PaymentReversalUncertain(
f"ZarinPal reversal returned HTTP {status_code}"
)
if isinstance(status_code, int) and status_code >= 400:
raise PaymentReversalRejected(
f"ZarinPal reversal was rejected with HTTP {status_code}"
)
body = response.json()
except (PaymentReversalUncertain, PaymentReversalRejected):
raise
except Exception as exc:
raise PaymentReversalUncertain("ZarinPal reversal request failed") from exc
if not isinstance(body, dict) or not isinstance(body.get("data"), dict):
if isinstance(body, dict) and isinstance(body.get("errors"), dict):
raise PaymentReversalRejected("ZarinPal rejected the reversal request")
raise PaymentReversalUncertain("ZarinPal returned an invalid reversal response")
code = body["data"].get("code")
try:
code = int(code)
except (TypeError, ValueError) as exc:
raise PaymentReversalUncertain("ZarinPal returned no reversal status code") from exc
if code != 100:
raise PaymentReversalRejected(f"ZarinPal rejected the reversal with code {code}")
return body["data"]
def claim_payment_verification(
payment_id: int,
*,
lease_seconds: int = 120,
allow_terminal: bool = False,
allow_manual_review: bool = False,
):
"""Claim one bounded verification attempt using the database as the lock."""
from apps.payments.models import Payment
with transaction.atomic():
try:
payment = Payment.objects.select_for_update().get(pk=payment_id)
except Payment.DoesNotExist:
return None, False
if payment.status == Payment.OrderStatusChoices.PAID or (
payment.status in (
Payment.OrderStatusChoices.FAILED,
Payment.OrderStatusChoices.CANCELED,
)
and not allow_terminal
):
return payment, False
now = timezone.now()
if payment.verification_manual_review_at is not None and not allow_manual_review:
return payment, False
if not payment.authority:
_mark_payment_manual_review(payment, now)
return payment, False
if payment.verification_lease_until and payment.verification_lease_until > now:
return payment, False
was_dispatched = bool(
payment.verification_dispatch_expires_at
and payment.verification_dispatch_expires_at > now
)
if (
payment.verification_next_attempt_at
and payment.verification_next_attempt_at > now
and not was_dispatched
):
return payment, False
max_attempts = getattr(settings, "PAYMENT_VERIFICATION_MAX_ATTEMPTS", 6)
if payment.verification_attempts >= max_attempts and not allow_manual_review:
_mark_payment_manual_review(payment, now)
return payment, False
payment.verification_attempts += 1
payment.verification_last_attempt_at = now
payment.verification_next_attempt_at = None
payment.verification_lease_until = now + timedelta(seconds=lease_seconds)
payment.verification_dispatch_expires_at = None
payment.verification_manual_review_at = None
payment.save(update_fields=[
"verification_attempts",
"verification_last_attempt_at",
"verification_next_attempt_at",
"verification_lease_until",
"verification_dispatch_expires_at",
"verification_manual_review_at",
])
return payment, True
def schedule_payment_verification_retry(payment_id: int):
"""Persist the next backoff step or stop at manual review after exhaustion."""
from apps.payments.models import Payment
with transaction.atomic():
try:
payment = Payment.objects.select_for_update().get(pk=payment_id)
except Payment.DoesNotExist:
return None
payment.verification_lease_until = None
payment.verification_dispatch_expires_at = None
if payment.status in (
Payment.OrderStatusChoices.PAID,
Payment.OrderStatusChoices.FAILED,
Payment.OrderStatusChoices.CANCELED,
):
payment.verification_next_attempt_at = None
payment.verification_manual_review_at = None
payment.save(update_fields=[
"verification_lease_until",
"verification_dispatch_expires_at",
"verification_next_attempt_at",
"verification_manual_review_at",
])
return None
max_attempts = getattr(settings, "PAYMENT_VERIFICATION_MAX_ATTEMPTS", 6)
if payment.verification_attempts >= max_attempts:
_mark_payment_manual_review(payment, timezone.now())
return None
attempt_index = max(payment.verification_attempts - 1, 0)
delay = min(60 * (2 ** attempt_index), 15 * 60)
payment.verification_next_attempt_at = timezone.now() + timedelta(seconds=delay)
payment.verification_manual_review_at = None
payment.save(update_fields=[
"verification_lease_until",
"verification_dispatch_expires_at",
"verification_next_attempt_at",
"verification_manual_review_at",
])
return delay
def mark_payment_manual_review(payment_id: int):
"""Keep an unresolved payment pending and make support review durable."""
from apps.payments.models import Payment
with transaction.atomic():
try:
payment = Payment.objects.select_for_update().get(pk=payment_id)
except Payment.DoesNotExist:
return None
_mark_payment_manual_review(payment, timezone.now())
return payment
def _mark_payment_manual_review(payment, now):
payment.verification_manual_review_at = payment.verification_manual_review_at or now
payment.verification_next_attempt_at = None
payment.verification_lease_until = None
payment.verification_dispatch_expires_at = None
payment.save(update_fields=[
"verification_manual_review_at",
"verification_next_attempt_at",
"verification_lease_until",
"verification_dispatch_expires_at",
])

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

View File

@@ -86,7 +86,8 @@ def test_payment_callback_verification_failures_notify_once(client, user_factory
else:
gateway.return_value.json.return_value = gateway_result
response = client.get("/api/payments/callback", {"Authority": "AUTH-FAIL", "Status": "OK"})
assert response.status_code == 302 and "status=pending" in response.url
assert response.status_code == 302
assert f"/events/{event.slug}/registration?registration_id={registration.ticket_id}" in response.url
payment.refresh_from_db()
registration.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.PENDING

View File

@@ -1,4 +1,7 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from apps.events.models import Registration
from apps.payments.models import Payment
@@ -39,13 +42,16 @@ def test_reconciliation_retry_confirms_payment_after_transient_timeout(
mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}}),
]
notify = mocker.patch("apps.payments.api.views._notify_payment_status")
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
retry = mocker.patch.object(reconcile_payment, "retry", side_effect=RuntimeError("retry scheduled"))
with pytest.raises(RuntimeError, match="retry scheduled"):
reconcile_payment.run(payment.id)
retry.assert_called_once()
assert retry.call_args.kwargs["countdown"] == 60
assert reconcile_payment.run(payment.id) == "retry_scheduled"
payment.refresh_from_db()
assert payment.verification_attempts == 1
assert payment.verification_next_attempt_at is not None
enqueue.assert_called_once_with(args=(payment.id,), countdown=60)
Payment.objects.filter(pk=payment.pk).update(
verification_next_attempt_at=timezone.now() - timedelta(seconds=1)
)
gateway.side_effect = [
mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}})
]
@@ -57,6 +63,8 @@ def test_reconciliation_retry_confirms_payment_after_transient_timeout(
assert gateway.call_count == 2
assert payment.status == Payment.OrderStatusChoices.PAID
assert payment.ref_id == "REF-RETRY"
assert payment.verification_attempts == 2
assert payment.verification_manual_review_at is None
assert registration.status == Registration.StatusChoices.CONFIRMED
notify.assert_called_once()
@@ -67,27 +75,27 @@ def test_reconciliation_exhaustion_leaves_payment_pending_for_review(
payment, registration = _pending_payment(user_factory, event_factory)
settings.ZARINPAL_MERCHANT_ID = "test"
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS = 6
gateway = mocker.patch("apps.payments.services.requests.post", side_effect=RuntimeError("gateway offline"))
notify = mocker.patch("apps.payments.api.views._notify_payment_status")
retry = mocker.patch.object(reconcile_payment, "retry", side_effect=RuntimeError("retry scheduled"))
mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
for retry_number in range(reconcile_payment.max_retries + 1):
reconcile_payment.push_request(retries=retry_number)
try:
if retry_number < reconcile_payment.max_retries:
with pytest.raises(RuntimeError, match="retry scheduled"):
reconcile_payment.run(payment.id)
else:
result = reconcile_payment.run(payment.id)
finally:
reconcile_payment.pop_request()
for attempt_number in range(settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS):
Payment.objects.filter(pk=payment.pk).update(
verification_next_attempt_at=timezone.now() - timedelta(seconds=1)
)
result = reconcile_payment.run(payment.id)
if attempt_number < settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS - 1:
assert result == "retry_scheduled"
payment.refresh_from_db()
registration.refresh_from_db()
assert result == "pending_manual_review"
assert gateway.call_count == reconcile_payment.max_retries + 1
assert [call.kwargs["countdown"] for call in retry.call_args_list] == [60, 120, 240, 480, 900]
assert gateway.call_count == settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS
assert payment.verification_attempts == settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS
assert payment.status == Payment.OrderStatusChoices.PENDING
assert payment.verification_manual_review_at is not None
assert payment.verification_next_attempt_at is None
assert registration.status == Registration.StatusChoices.PENDING
notify.assert_not_called()
@@ -137,3 +145,162 @@ def test_failed_attempt_does_not_cancel_registration_used_by_another_active_paym
registration.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.FAILED
assert registration.status == Registration.StatusChoices.PENDING
def _expired_checkout_payment(user_factory, event_factory):
now = timezone.now()
user = user_factory()
event = event_factory(price=25000, capacity=2)
registration = Registration.objects.create(
event=event,
user=user,
status=Registration.StatusChoices.CANCELLED,
checkout_started_at=now - timedelta(minutes=20),
checkout_expires_at=now - timedelta(minutes=5),
cancellation_reason=Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
final_price=25000,
)
payment = Payment.objects.create(
user=user,
event=event,
registration=registration,
base_amount=25000,
amount=25000,
authority=f"AUTH-LATE-{user.pk}",
status=Payment.OrderStatusChoices.PENDING,
)
return payment, registration
def test_late_success_without_provider_payment_time_keeps_registration_cancelled(
user_factory, event_factory, mocker
):
from apps.payments.api.views import _mark_payment_paid
payment, registration = _expired_checkout_payment(user_factory, event_factory)
mocker.patch("apps.payments.tasks.refund_payment.apply_async")
payment, newly_verified = _mark_payment_paid(payment.id, {"ref_id": "LATE-REF"})
registration.refresh_from_db()
assert newly_verified is True
assert payment.status == Payment.OrderStatusChoices.PAID
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
assert registration.status == Registration.StatusChoices.CANCELLED
def test_late_success_with_proven_pre_expiry_payment_and_available_seat_is_honored(
user_factory, event_factory
):
from apps.payments.api.views import _mark_payment_paid
payment, registration = _expired_checkout_payment(user_factory, event_factory)
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "ONTIME-REF", "paid_at": paid_at})
registration.refresh_from_db()
assert registration.status == Registration.StatusChoices.CONFIRMED
assert payment.refund_status == Payment.RefundStatusChoices.NONE
def test_late_success_with_proven_pre_expiry_time_is_refunded_when_capacity_is_full(
user_factory, event_factory
):
from apps.payments.api.views import _mark_payment_paid
payment, registration = _expired_checkout_payment(user_factory, event_factory)
payment.event.capacity = 1
payment.event.save(update_fields=["capacity"])
other_user = user_factory()
Registration.objects.create(
event=payment.event,
user=other_user,
status=Registration.StatusChoices.CONFIRMED,
)
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "FULL-REF", "paid_at": paid_at})
registration.refresh_from_db()
assert registration.status == Registration.StatusChoices.CANCELLED
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
@pytest.mark.parametrize(
("reason", "cancel_event"),
[
(Registration.CancellationReasonChoices.USER_CANCELLED, False),
(Registration.CancellationReasonChoices.EVENT_CANCELLED, True),
],
)
def test_late_success_after_user_or_event_cancellation_is_refunded(
user_factory, event_factory, mocker, reason, cancel_event
):
from apps.payments.api.views import _mark_payment_paid
payment, registration = _expired_checkout_payment(user_factory, event_factory)
registration.cancellation_reason = reason
registration.save(update_fields=["cancellation_reason"])
if cancel_event:
payment.event.status = payment.event.StatusChoices.CANCELLED
payment.event.save(update_fields=["status"])
mocker.patch("apps.payments.tasks.refund_payment.apply_async")
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "CANCELLED-REF", "paid_at": paid_at})
registration.refresh_from_db()
assert registration.status == Registration.StatusChoices.CANCELLED
assert registration.cancellation_reason == reason
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
def test_late_success_refund_task_persists_provider_reversal(mocker, user_factory, event_factory):
from apps.payments.tasks import refund_payment
payment, registration = _expired_checkout_payment(user_factory, event_factory)
Payment.objects.filter(pk=payment.pk).update(
status=Payment.OrderStatusChoices.PAID,
refund_status=Payment.RefundStatusChoices.PENDING,
)
reverse = mocker.patch(
"apps.payments.tasks.request_payment_reversal",
return_value={"code": 100, "ref_id": "REVERSAL-1"},
)
result = refund_payment.run(payment.id)
payment.refresh_from_db()
assert result == "refunded"
assert payment.refund_status == Payment.RefundStatusChoices.SUCCEEDED
assert payment.refund_reference == "REVERSAL-1"
assert payment.refund_attempts == 1
reverse.assert_called_once()
def test_uncertain_reversal_retries_then_keeps_admin_review_state(mocker, user_factory, event_factory, settings):
from apps.payments.services import PaymentReversalUncertain
from apps.payments.tasks import refund_payment
payment, _ = _expired_checkout_payment(user_factory, event_factory)
Payment.objects.filter(pk=payment.pk).update(
status=Payment.OrderStatusChoices.PAID,
refund_status=Payment.RefundStatusChoices.PENDING,
refund_attempts=2,
)
settings.PAYMENT_REFUND_MAX_ATTEMPTS = 3
mocker.patch(
"apps.payments.tasks.request_payment_reversal",
side_effect=PaymentReversalUncertain("gateway timed out"),
)
enqueue = mocker.patch("apps.payments.tasks.refund_payment.apply_async")
result = refund_payment.run(payment.id)
payment.refresh_from_db()
assert result == "manual_review"
assert payment.refund_status == Payment.RefundStatusChoices.MANUAL_REVIEW
assert payment.refund_attempts == 3
assert payment.refund_next_attempt_at is None
enqueue.assert_not_called()

View File

@@ -0,0 +1,82 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from apps.payments.models import Payment
from apps.payments.tasks import sweep_stale_payment_verifications
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_sweep_schedules_only_old_unresolved_payments_and_deduplicates_dispatch(
user_factory, event_factory, mocker, settings
):
settings.PAYMENT_VERIFICATION_GRACE_SECONDS = 1800
user = user_factory()
event = event_factory(price=25000)
old_payment = Payment.objects.create(
user=user,
event=event,
base_amount=25000,
amount=25000,
authority="AUTH-RECOVERY-OLD",
status=Payment.OrderStatusChoices.PENDING,
)
recent_payment = Payment.objects.create(
user=user,
event=event,
base_amount=25000,
amount=25000,
authority="AUTH-RECOVERY-NEW",
status=Payment.OrderStatusChoices.PENDING,
)
Payment.objects.filter(pk=old_payment.pk).update(
created_at=timezone.now() - timedelta(seconds=1801)
)
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
scheduled = sweep_stale_payment_verifications.run()
old_payment.refresh_from_db()
recent_payment.refresh_from_db()
assert scheduled == 1
enqueue.assert_called_once_with(args=(old_payment.pk,))
assert old_payment.verification_dispatch_expires_at is not None
assert old_payment.verification_dispatch_expires_at > timezone.now()
assert recent_payment.verification_next_attempt_at is None
assert sweep_stale_payment_verifications.run() == 0
enqueue.assert_called_once()
def test_sweep_does_not_restart_exhausted_or_terminal_attempts(
user_factory, event_factory, mocker, settings
):
settings.PAYMENT_VERIFICATION_GRACE_SECONDS = 60
settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS = 3
user = user_factory()
event = event_factory(price=25000)
payments = [
Payment.objects.create(
user=user,
event=event,
base_amount=25000,
amount=25000,
authority=f"AUTH-RECOVERY-{status}-{index}",
status=status,
verification_attempts=3,
)
for index, status in enumerate(
[Payment.OrderStatusChoices.PENDING, Payment.OrderStatusChoices.FAILED]
)
]
Payment.objects.filter(pk__in=[payment.pk for payment in payments]).update(
created_at=timezone.now() - timedelta(minutes=5)
)
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
scheduled = sweep_stale_payment_verifications.run()
assert scheduled == 0
enqueue.assert_not_called()

View File

@@ -168,7 +168,8 @@ class TestPaymentsAPIIntegrationTests:
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH123', 'Status': 'OK'})
payment.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.PAID
assert 'status=success' in response.url
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
assert 'status=' not in response.url
def test_callback_failure_redirects_failed(self, mocker):
mock_post = mocker.patch('apps.payments.api.views.requests.post')
@@ -179,11 +180,21 @@ class TestPaymentsAPIIntegrationTests:
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH456', 'Status': 'OK'})
payment.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.PAID
assert 'status=success' in response.url
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
def test_callback_missing_authority_returns_error(self):
def test_callback_missing_authority_redirects_to_safe_unknown_result(self):
response = self.client.get('/api/payments/callback', {'Status': 'OK'})
assert response.status_code == 400
assert response.status_code == 302
assert '/events?payment_callback=unmatched' in response.url
def test_callback_unknown_authority_redirects_to_safe_unknown_result(self):
response = self.client.get(
'/api/payments/callback',
{'Authority': 'AUTH-UNKNOWN', 'Status': 'OK'},
)
assert response.status_code == 302
assert '/events?payment_callback=unmatched' in response.url
assert 'AUTH-UNKNOWN' not in response.url
def test_callback_not_ok_is_verified_before_being_canceled(self, mocker):
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH789')
@@ -191,8 +202,8 @@ class TestPaymentsAPIIntegrationTests:
gateway.return_value.json.return_value = {'data': {'code': -51}}
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'})
payment.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.FAILED
assert 'status=failed' in response.url
assert payment.status == Payment.OrderStatusChoices.CANCELED
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
def test_late_canceled_callback_does_not_overwrite_verified_payment(self, mocker):
registration = Registration.objects.create(
@@ -229,8 +240,9 @@ class TestPaymentsAPIIntegrationTests:
assert payment.status == Payment.OrderStatusChoices.PAID
assert payment.verified_at is not None
assert registration.status == Registration.StatusChoices.CONFIRMED
assert 'status=success' in success_response.url
assert 'status=success' in late_failure_response.url
expected_return = f'/events/{self.event.slug}/registration?registration_id={registration.ticket_id}'
assert expected_return in success_response.url
assert expected_return in late_failure_response.url
def test_create_payment_gateway_timeout_stays_pending(self, mocker):
mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down'))
@@ -249,8 +261,7 @@ class TestPaymentsAPIIntegrationTests:
payment.refresh_from_db()
assert response.status_code == 302
assert 'status=pending' in response.url
assert f'payment_id={payment.id}' in response.url
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
assert payment.status == Payment.OrderStatusChoices.PENDING
reconcile.assert_called_once_with(args=(payment.id,), countdown=60)
notify.assert_not_called()
@@ -261,12 +272,45 @@ class TestPaymentsAPIIntegrationTests:
assert own.status_code == 200
assert own.json()['payment_id'] == payment.id
assert own.json()['status'] == 'pending'
assert own.json()['verification_state'] == 'awaiting_verification'
assert own.json()['verification_attempts'] == 0
assert own.json()['last_verification_at'] is None
assert own.json()['manual_review_at'] is None
other = User.objects.create_user(username='other_payment_owner', email='other-payment@example.com', mobile='09123456780', password=self.password)
other_token = create_jwt_token(other)
forbidden = self.client.get(f'/api/payments/status/{payment.id}', HTTP_AUTHORIZATION=f'Bearer {other_token}')
assert forbidden.status_code == 404
def test_payment_reference_lookup_requires_authentication_and_is_user_scoped(self):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
ref_id="OWNER-ONLY-REF",
)
unauthenticated = self.client.get("/api/payments/by-ref/OWNER-ONLY-REF")
owned = self.client.get("/api/payments/by-ref/OWNER-ONLY-REF", **self._headers())
other = User.objects.create_user(
username="other_ref_owner",
email="other-ref-owner@example.com",
mobile="09123456781",
password=self.password,
)
other_token = create_jwt_token(other)
forbidden = self.client.get(
"/api/payments/by-ref/OWNER-ONLY-REF",
HTTP_AUTHORIZATION=f"Bearer {other_token}",
)
assert unauthenticated.status_code == 401
assert owned.status_code == 200
assert owned.json()["payment_id"] == payment.id
assert forbidden.status_code == 404
def test_create_payment_when_already_paid(self):
Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PAID)
response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Duplicate'}), content_type='application/json', **self._headers())
@@ -341,10 +385,12 @@ def test_my_payments_are_user_scoped_and_omit_gateway_identifiers(client, user_f
payload = response.json()
assert len(payload) == 1
assert payload[0]["event"]["title"] == own_event.title
assert payload[0]["event"]["id"] == own_event.id
assert payload[0]["amount"] == 100000
assert payload[0]["discount_amount"] == 20000
assert payload[0]["status"] == "paid"
assert set(payload[0]) == {"event", "base_amount", "discount_amount", "amount", "status", "created_at", "paid_at"}
assert set(payload[0]) == {"payment_id", "event", "base_amount", "discount_amount", "amount", "status", "refund_status", "created_at", "paid_at"}
assert payload[0]["payment_id"] == Payment.objects.get(user=current_user, event=own_event).id
assert "internal-authority-owner" not in response.content.decode()
assert "internal-reference-owner" not in response.content.decode()
assert "internal-card-hash" not in response.content.decode()