feat(events): harden registration checkout lifecycle

This commit is contained in:
2026-10-01 12:26:49 +03:30
parent 1daa755543
commit 962238c32c
29 changed files with 3048 additions and 207 deletions

View File

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

View File

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