feat(events): harden registration checkout lifecycle
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
18
apps/payments/migrations/0007_payment_gateway_paid_at.py
Normal file
18
apps/payments/migrations/0007_payment_gateway_paid_at.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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()}"
|
||||
|
||||
@@ -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",
|
||||
])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user