fix(payments): make checkout attempts idempotent and reconcilable
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.db.models import Count, Q
|
||||
from django.db import transaction
|
||||
from typing import List
|
||||
|
||||
from ninja import Query, Router
|
||||
@@ -11,6 +15,8 @@ import requests
|
||||
from apps.payments.models import Payment, DiscountCode
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.notifications.services import notify_user
|
||||
from apps.payments.services import PaymentVerificationUncertain, request_payment_verification
|
||||
from apps.payments.tasks import reconcile_payment
|
||||
from apps.users.tasks import send_critical_sms
|
||||
from core.authentication import jwt_auth
|
||||
from core.api.schemas import ErrorSchema, MessageSchema
|
||||
@@ -28,6 +34,7 @@ from apps.payments.api.schemas import (
|
||||
)
|
||||
|
||||
payments_router = Router(tags=["Payments"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@payments_router.get("/me", response=List[UserPaymentSchema], auth=jwt_auth)
|
||||
@@ -236,80 +243,148 @@ def _notify_payment_status(payment: Payment, *, title: str, message: str, level:
|
||||
send_critical_sms.delay(payment.user.mobile, "payment_status", payment.event.title)
|
||||
|
||||
|
||||
def _payment_status_name(payment: Payment) -> str:
|
||||
return {
|
||||
Payment.OrderStatusChoices.INIT: "initiated",
|
||||
Payment.OrderStatusChoices.PENDING: "pending",
|
||||
Payment.OrderStatusChoices.PAID: "paid",
|
||||
Payment.OrderStatusChoices.FAILED: "failed",
|
||||
Payment.OrderStatusChoices.CANCELED: "canceled",
|
||||
}[payment.status]
|
||||
|
||||
|
||||
def _payment_checkout_payload(payment: Payment):
|
||||
return {
|
||||
"start_pay_url": (
|
||||
f"{settings.ZARINPAL_STARTPAY}{payment.authority}"
|
||||
if payment.authority and payment.status == Payment.OrderStatusChoices.PENDING
|
||||
else None
|
||||
),
|
||||
"authority": payment.authority,
|
||||
"payment_id": payment.id,
|
||||
"idempotency_key": payment.idempotency_key,
|
||||
"status": _payment_status_name(payment),
|
||||
"base_amount": payment.base_amount,
|
||||
"discount_amount": payment.discount_amount or 0,
|
||||
"amount": payment.amount,
|
||||
}
|
||||
|
||||
|
||||
@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
|
||||
def create_payment(request, payload: CreatePaymentIn):
|
||||
event = get_object_or_404(Event, pk=payload.event_id)
|
||||
idempotency_key = payload.idempotency_key or uuid4()
|
||||
|
||||
if Payment.objects.filter(status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event).exists():
|
||||
raise HttpError(400, "You have already registered in this event")
|
||||
|
||||
registration = (
|
||||
Registration.objects.filter(event=event, user=request.auth, is_deleted=False)
|
||||
.order_by("-registered_at")
|
||||
.first()
|
||||
)
|
||||
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
|
||||
registration = Registration.objects.create(
|
||||
event=event,
|
||||
user=request.auth,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=event.price,
|
||||
# Serialize checkout creation for this event so concurrent taps cannot
|
||||
# both create a new active attempt for the same user/event pair.
|
||||
with transaction.atomic():
|
||||
event = get_object_or_404(
|
||||
Event.objects.select_for_update(),
|
||||
pk=payload.event_id,
|
||||
is_deleted=False,
|
||||
)
|
||||
elif registration.final_price is None:
|
||||
registration.final_price = event.price
|
||||
registration.save(update_fields=["final_price"])
|
||||
|
||||
discount_code = None
|
||||
discount_amount = 0
|
||||
final_amount = event.price
|
||||
keyed_payment = Payment.objects.filter(
|
||||
user=request.auth,
|
||||
event=event,
|
||||
idempotency_key=idempotency_key,
|
||||
).select_related("event").first()
|
||||
if keyed_payment:
|
||||
return _payment_checkout_payload(keyed_payment)
|
||||
|
||||
if payload.discount_code:
|
||||
discount_code = DiscountCode.objects.filter(code=payload.discount_code, applicable_events=event, is_active=True).first()
|
||||
if Payment.objects.filter(
|
||||
status=Payment.OrderStatusChoices.PAID,
|
||||
user=request.auth,
|
||||
event=event,
|
||||
).exists():
|
||||
raise HttpError(400, "You have already registered in this event")
|
||||
|
||||
if discount_code:
|
||||
final_amount, discount_amount = discount_code.calculate_discount(event, request.auth)
|
||||
active_payment = (
|
||||
Payment.objects.filter(
|
||||
user=request.auth,
|
||||
event=event,
|
||||
status__in=[Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING],
|
||||
)
|
||||
.order_by("-created_at", "-id")
|
||||
.first()
|
||||
)
|
||||
if active_payment:
|
||||
return _payment_checkout_payload(active_payment)
|
||||
|
||||
registration_updates = []
|
||||
if discount_code and registration.discount_code_id != discount_code.id:
|
||||
registration.discount_code = discount_code
|
||||
registration_updates.append("discount_code")
|
||||
if registration.discount_amount != discount_amount:
|
||||
registration.discount_amount = discount_amount
|
||||
registration_updates.append("discount_amount")
|
||||
if registration.final_price != final_amount:
|
||||
registration.final_price = final_amount
|
||||
registration_updates.append("final_price")
|
||||
registration = (
|
||||
Registration.objects.select_for_update()
|
||||
.filter(event=event, user=request.auth, is_deleted=False)
|
||||
.order_by("-registered_at")
|
||||
.first()
|
||||
)
|
||||
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
|
||||
registration = Registration.objects.create(
|
||||
event=event,
|
||||
user=request.auth,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=event.price,
|
||||
)
|
||||
elif registration.final_price is None:
|
||||
registration.final_price = event.price
|
||||
registration.save(update_fields=["final_price"])
|
||||
|
||||
discount_code = None
|
||||
discount_amount = 0
|
||||
final_amount = event.price
|
||||
|
||||
if payload.discount_code:
|
||||
discount_code = DiscountCode.objects.filter(
|
||||
code=payload.discount_code,
|
||||
applicable_events=event,
|
||||
is_active=True,
|
||||
).first()
|
||||
if discount_code:
|
||||
final_amount, discount_amount = discount_code.calculate_discount(event, request.auth)
|
||||
|
||||
registration_updates = []
|
||||
if discount_code and registration.discount_code_id != discount_code.id:
|
||||
registration.discount_code = discount_code
|
||||
registration_updates.append("discount_code")
|
||||
if registration.discount_amount != discount_amount:
|
||||
registration.discount_amount = discount_amount
|
||||
registration_updates.append("discount_amount")
|
||||
if registration.final_price != final_amount:
|
||||
registration.final_price = final_amount
|
||||
registration_updates.append("final_price")
|
||||
|
||||
if final_amount == 0:
|
||||
if registration.status != Registration.StatusChoices.CONFIRMED:
|
||||
registration.status = Registration.StatusChoices.CONFIRMED
|
||||
registration_updates.append("status")
|
||||
if registration_updates:
|
||||
registration.save(update_fields=list(set(registration_updates)))
|
||||
else:
|
||||
registration.save(update_fields=["status"])
|
||||
|
||||
return {
|
||||
"start_pay_url": None,
|
||||
"authority": None,
|
||||
"payment_id": None,
|
||||
"idempotency_key": idempotency_key,
|
||||
"status": "paid",
|
||||
"base_amount": event.price,
|
||||
"discount_amount": discount_amount,
|
||||
"amount": 0,
|
||||
}
|
||||
|
||||
if final_amount == 0:
|
||||
if registration.status != Registration.StatusChoices.CONFIRMED:
|
||||
registration.status = Registration.StatusChoices.CONFIRMED
|
||||
registration_updates.append("status")
|
||||
if registration_updates:
|
||||
registration.save(update_fields=list(set(registration_updates)))
|
||||
else:
|
||||
registration.save(update_fields=["status"])
|
||||
|
||||
return {
|
||||
"start_pay_url": None,
|
||||
"authority": None,
|
||||
"base_amount": event.price,
|
||||
"discount_amount": discount_amount if discount_amount else 0,
|
||||
"amount": 0,
|
||||
}
|
||||
|
||||
if registration_updates:
|
||||
registration.save(update_fields=list(set(registration_updates)))
|
||||
|
||||
pay = Payment.objects.create(
|
||||
user=request.auth,
|
||||
event=event,
|
||||
base_amount=event.price,
|
||||
discount_code=discount_code,
|
||||
discount_amount=discount_amount,
|
||||
amount=final_amount,
|
||||
status=Payment.OrderStatusChoices.INIT,
|
||||
registration=registration,
|
||||
)
|
||||
pay = Payment.objects.create(
|
||||
user=request.auth,
|
||||
event=event,
|
||||
base_amount=event.price,
|
||||
discount_code=discount_code,
|
||||
discount_amount=discount_amount,
|
||||
amount=final_amount,
|
||||
status=Payment.OrderStatusChoices.INIT,
|
||||
registration=registration,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback")
|
||||
body = {
|
||||
@@ -336,28 +411,188 @@ def create_payment(request, payload: CreatePaymentIn):
|
||||
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 RuntimeError(f"ZarinPal payment request returned HTTP {status_code}")
|
||||
jd = response.json()
|
||||
except Exception as e:
|
||||
pay.delete()
|
||||
raise HttpError(502, f"Gateway request failed: {e}")
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Gateway payment request outcome is unknown for payment %s; leaving it pending for review",
|
||||
pay.id,
|
||||
)
|
||||
pay.status = Payment.OrderStatusChoices.PENDING
|
||||
pay.save(update_fields=["status"])
|
||||
return _payment_checkout_payload(pay)
|
||||
|
||||
code = (jd.get("data") or {}).get("code")
|
||||
if code != 100:
|
||||
pay.delete()
|
||||
raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}")
|
||||
data = jd.get("data") if isinstance(jd, dict) else None
|
||||
code = data.get("code") if isinstance(data, dict) else None
|
||||
try:
|
||||
code = int(code)
|
||||
except (TypeError, ValueError):
|
||||
code = None
|
||||
|
||||
authority = data.get("authority") if isinstance(data, dict) else None
|
||||
if code != 100 or not authority:
|
||||
if isinstance(jd, dict) and isinstance(jd.get("errors"), dict):
|
||||
pay, _, _ = _mark_payment_unsuccessful(pay.id, Payment.OrderStatusChoices.FAILED)
|
||||
else:
|
||||
logger.error(
|
||||
"Gateway payment request outcome is unknown for payment %s; response had no usable authority",
|
||||
pay.id,
|
||||
)
|
||||
pay.status = Payment.OrderStatusChoices.PENDING
|
||||
pay.save(update_fields=["status"])
|
||||
return _payment_checkout_payload(pay)
|
||||
|
||||
authority = jd["data"]["authority"]
|
||||
pay.authority = authority
|
||||
pay.status = Payment.OrderStatusChoices.PENDING
|
||||
pay.save(update_fields=["authority","status"])
|
||||
|
||||
return {
|
||||
"start_pay_url": f"{settings.ZARINPAL_STARTPAY}{authority}",
|
||||
"authority": authority,
|
||||
"base_amount": event.price,
|
||||
"discount_amount": discount_amount if discount_amount else 0,
|
||||
"amount": final_amount,
|
||||
}
|
||||
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}"
|
||||
return redirect(url)
|
||||
|
||||
|
||||
def _mark_payment_paid(payment_id: int, verification_data: dict | None = None):
|
||||
"""Persist a verified payment and its registration as one idempotent transition."""
|
||||
with transaction.atomic():
|
||||
payment = (
|
||||
Payment.objects.select_for_update()
|
||||
.select_related("event", "user")
|
||||
.get(pk=payment_id)
|
||||
)
|
||||
was_already_verified = (
|
||||
payment.status == Payment.OrderStatusChoices.PAID
|
||||
or payment.verified_at is not None
|
||||
)
|
||||
|
||||
updates = []
|
||||
if payment.status != Payment.OrderStatusChoices.PAID:
|
||||
payment.status = Payment.OrderStatusChoices.PAID
|
||||
updates.append("status")
|
||||
if verification_data is not None and payment.verified_at is None:
|
||||
payment.ref_id = verification_data.get("ref_id")
|
||||
payment.card_pan = verification_data.get("card_pan")
|
||||
payment.card_hash = verification_data.get("card_hash")
|
||||
payment.verified_at = timezone.now()
|
||||
updates.extend(["ref_id", "card_pan", "card_hash", "verified_at"])
|
||||
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"]
|
||||
if registration.final_price is None:
|
||||
registration.final_price = payment.amount
|
||||
registration_updates.append("final_price")
|
||||
registration.save(update_fields=registration_updates)
|
||||
|
||||
return payment, not was_already_verified
|
||||
|
||||
|
||||
def _mark_payment_pending(payment_id: int):
|
||||
"""Do not let an inconclusive response turn into a terminal failure."""
|
||||
with transaction.atomic():
|
||||
payment = Payment.objects.select_for_update().get(pk=payment_id)
|
||||
if payment.verified_at is not None or payment.status == Payment.OrderStatusChoices.PAID:
|
||||
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):
|
||||
payment.status = Payment.OrderStatusChoices.PENDING
|
||||
payment.save(update_fields=["status"])
|
||||
return payment
|
||||
|
||||
|
||||
def _mark_payment_unsuccessful(payment_id: int, target_status: int):
|
||||
"""Apply a failure only while the payment has not been verified successfully."""
|
||||
with transaction.atomic():
|
||||
payment = (
|
||||
Payment.objects.select_for_update()
|
||||
.select_related("event", "user")
|
||||
.get(pk=payment_id)
|
||||
)
|
||||
if (
|
||||
payment.status == Payment.OrderStatusChoices.PAID
|
||||
or payment.verified_at is not None
|
||||
):
|
||||
if payment.status != Payment.OrderStatusChoices.PAID:
|
||||
payment.status = Payment.OrderStatusChoices.PAID
|
||||
payment.save(update_fields=["status"])
|
||||
result = "success"
|
||||
transitioned = False
|
||||
elif payment.status in (
|
||||
Payment.OrderStatusChoices.FAILED,
|
||||
Payment.OrderStatusChoices.CANCELED,
|
||||
):
|
||||
result = (
|
||||
"canceled"
|
||||
if payment.status == Payment.OrderStatusChoices.CANCELED
|
||||
else "failed"
|
||||
)
|
||||
transitioned = False
|
||||
else:
|
||||
payment.status = target_status
|
||||
payment.save(update_fields=["status"])
|
||||
result = (
|
||||
"canceled"
|
||||
if target_status == Payment.OrderStatusChoices.CANCELED
|
||||
else "failed"
|
||||
)
|
||||
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:
|
||||
registration = Registration.objects.select_for_update().filter(
|
||||
user_id=payment.user_id,
|
||||
event_id=payment.event_id,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
is_deleted=False,
|
||||
).order_by("-registered_at").first()
|
||||
|
||||
if registration:
|
||||
another_live_payment = Payment.objects.filter(
|
||||
user_id=payment.user_id,
|
||||
event_id=payment.event_id,
|
||||
status__in=[
|
||||
Payment.OrderStatusChoices.INIT,
|
||||
Payment.OrderStatusChoices.PENDING,
|
||||
Payment.OrderStatusChoices.PAID,
|
||||
],
|
||||
).exclude(pk=payment.pk).exists()
|
||||
if not another_live_payment:
|
||||
# The payment failure notification already informs the user;
|
||||
# update directly to avoid sending a duplicate cancellation notice.
|
||||
Registration.objects.filter(
|
||||
pk=registration.pk,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
).update(status=Registration.StatusChoices.CANCELLED)
|
||||
|
||||
return payment, result, transitioned
|
||||
|
||||
|
||||
@payments_router.get("callback")
|
||||
def callback(request, Authority: str | None = None, Status: str | None = None):
|
||||
@@ -367,70 +602,28 @@ def callback(request, Authority: str | None = None, Status: str | None = None):
|
||||
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
|
||||
if not pay:
|
||||
raise HttpError(404, "Payment not found")
|
||||
previous_status = pay.status
|
||||
|
||||
if Status != "OK":
|
||||
pay.status = Payment.OrderStatusChoices.CANCELED
|
||||
pay.save(update_fields=["status"])
|
||||
if previous_status != Payment.OrderStatusChoices.CANCELED:
|
||||
_notify_payment_status(
|
||||
pay,
|
||||
title=f"پرداخت رویداد «{pay.event.title}» لغو شد",
|
||||
message="پرداخت شما کامل نشد و ثبتنام نهایی انجام نگرفت.",
|
||||
level="warning",
|
||||
)
|
||||
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||
|
||||
verify_body = {
|
||||
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
||||
"amount": pay.amount,
|
||||
"authority": Authority,
|
||||
}
|
||||
if pay.status == Payment.OrderStatusChoices.PAID or pay.verified_at is not None:
|
||||
pay, _ = _mark_payment_paid(pay.id)
|
||||
return _callback_redirect(pay, "success")
|
||||
|
||||
try:
|
||||
vresp = requests.post(
|
||||
settings.ZARINPAL_VERIFY_URL,
|
||||
json=verify_body,
|
||||
headers={"accept":"application/json","content-type":"application/json"},
|
||||
timeout=15
|
||||
)
|
||||
vjd = vresp.json()
|
||||
except Exception:
|
||||
pay.status = Payment.OrderStatusChoices.FAILED
|
||||
pay.save(update_fields=["status"])
|
||||
if previous_status != Payment.OrderStatusChoices.FAILED:
|
||||
_notify_payment_status(
|
||||
pay,
|
||||
title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود",
|
||||
message="خطا در تأیید پرداخت رخ داد. در صورت نیاز دوباره تلاش کنید.",
|
||||
level="error",
|
||||
)
|
||||
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||
vcode, data = request_payment_verification(pay)
|
||||
except PaymentVerificationUncertain:
|
||||
pay = _mark_payment_pending(pay.id)
|
||||
if pay.status == Payment.OrderStatusChoices.PENDING and pay.authority:
|
||||
try:
|
||||
reconcile_payment.apply_async(args=(pay.id,), countdown=60)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Unable to queue payment verification retry for payment %s; it remains pending for manual review",
|
||||
pay.id,
|
||||
)
|
||||
return _callback_redirect(pay, _payment_status_name(pay))
|
||||
|
||||
vcode = (vjd.get("data") or {}).get("code")
|
||||
if vcode in (100, 101):
|
||||
data = vjd.get("data") or {}
|
||||
pay.status = Payment.OrderStatusChoices.PAID
|
||||
pay.ref_id = data.get("ref_id")
|
||||
pay.card_pan = data.get("card_pan")
|
||||
pay.card_hash = data.get("card_hash")
|
||||
pay.verified_at = timezone.now()
|
||||
pay.save(update_fields=["status", "ref_id", "card_pan", "card_hash", "verified_at"])
|
||||
pay, newly_verified = _mark_payment_paid(pay.id, data)
|
||||
|
||||
registration = pay.registration or Registration.objects.filter(
|
||||
user=pay.user,
|
||||
event=pay.event,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
).first()
|
||||
if registration:
|
||||
registration.status = Registration.StatusChoices.CONFIRMED
|
||||
updates = ["status"]
|
||||
if registration.final_price is None:
|
||||
registration.final_price = pay.amount
|
||||
updates.append("final_price")
|
||||
registration.save(update_fields=updates)
|
||||
|
||||
if previous_status != Payment.OrderStatusChoices.PAID:
|
||||
if newly_verified:
|
||||
_notify_payment_status(
|
||||
pay,
|
||||
title=f"پرداخت رویداد «{pay.event.title}» تأیید شد",
|
||||
@@ -438,39 +631,55 @@ def callback(request, Authority: str | None = None, Status: str | None = None):
|
||||
level="success",
|
||||
)
|
||||
|
||||
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=success&event_id={pay.event_id}&ref_id={pay.ref_id}")
|
||||
return _callback_redirect(pay, "success")
|
||||
|
||||
pay.status = Payment.OrderStatusChoices.FAILED
|
||||
pay.save(update_fields=["status"])
|
||||
if previous_status != Payment.OrderStatusChoices.FAILED:
|
||||
pay, result, transitioned = _mark_payment_unsuccessful(pay.id, Payment.OrderStatusChoices.FAILED)
|
||||
if transitioned:
|
||||
_notify_payment_status(
|
||||
pay,
|
||||
title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود",
|
||||
message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.",
|
||||
level="error",
|
||||
)
|
||||
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||
return _callback_redirect(pay, result)
|
||||
|
||||
|
||||
@payments_router.get("status/{int:payment_id}", response=PaymentDetailOut, auth=jwt_auth)
|
||||
def payment_status(request, payment_id: int):
|
||||
payment = get_object_or_404(
|
||||
Payment.objects.select_related("event"),
|
||||
pk=payment_id,
|
||||
user=request.auth,
|
||||
)
|
||||
return _payment_detail_payload(request, payment)
|
||||
|
||||
|
||||
def _payment_detail_payload(request, payment: Payment):
|
||||
event = payment.event
|
||||
return {
|
||||
"payment_id": payment.id,
|
||||
"event_id": event.id,
|
||||
"ref_id": payment.ref_id,
|
||||
"authority": payment.authority,
|
||||
"base_amount": payment.base_amount,
|
||||
"discount_amount": payment.discount_amount or 0,
|
||||
"amount": payment.amount,
|
||||
"status": _payment_status_name(payment),
|
||||
"verified_at": payment.verified_at.isoformat() if payment.verified_at else None,
|
||||
"event": {
|
||||
"id": event.id,
|
||||
"title": event.title,
|
||||
"slug": event.slug,
|
||||
"image_url": request.build_absolute_uri(event.featured_image.url) if event.featured_image else None,
|
||||
"success_markdown": event.registration_success_markdown,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)
|
||||
def payment_by_ref(request, ref_id: str):
|
||||
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id)
|
||||
ev = pay.event
|
||||
return {
|
||||
"ref_id": pay.ref_id,
|
||||
"authority": pay.authority,
|
||||
"base_amount": pay.base_amount,
|
||||
"discount_amount": pay.discount_amount or 0,
|
||||
"amount": pay.amount,
|
||||
"status": pay.get_status_display(),
|
||||
"verified_at": pay.verified_at.isoformat() if pay.verified_at else None,
|
||||
"event": {
|
||||
"id": ev.id,
|
||||
"title": ev.title,
|
||||
"slug": ev.slug,
|
||||
"image_url": request.build_absolute_uri(ev.featured_image.url) if ev.featured_image else None,
|
||||
"success_markdown": ev.registration_success_markdown,
|
||||
},
|
||||
}
|
||||
return _payment_detail_payload(request, pay)
|
||||
|
||||
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
|
||||
def check_coupon(request, payload: CouponVerifyIn):
|
||||
|
||||
Reference in New Issue
Block a user