feat(events): harden registration checkout lifecycle
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user