Files
guilan-ace-backend/apps/payments/api/views.py

1029 lines
42 KiB
Python

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
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
from ninja.errors import HttpError
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,
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
from core.api.schemas import ErrorSchema, MessageSchema
from apps.payments.api.schemas import (
CouponVerifyIn,
CouponVerifyOut,
CreatePaymentIn,
CreatePaymentOut,
DiscountCodeSchema,
DiscountCodeWriteSchema,
PagedDiscountCodeSchema,
PaymentDetailOut,
PaginatedUserPaymentsSchema,
UserPaymentSchema,
)
payments_router = Router(tags=["Payments"])
logger = logging.getLogger(__name__)
@payments_router.get("/me", response=List[UserPaymentSchema], auth=jwt_auth)
def my_payments(request):
status_names = {
Payment.OrderStatusChoices.INIT: "initiated",
Payment.OrderStatusChoices.PENDING: "pending",
Payment.OrderStatusChoices.PAID: "paid",
Payment.OrderStatusChoices.FAILED: "failed",
Payment.OrderStatusChoices.CANCELED: "canceled",
}
payments = Payment.objects.filter(user=request.auth).select_related("event").order_by("-created_at")[:50]
return [
_user_payment_payload(request, payment, status_names)
for payment in payments
]
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)
if payment.event.featured_image
else None,
},
"base_amount": payment.base_amount,
"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,
}
@payments_router.get("/me/paginated", response=PaginatedUserPaymentsSchema, auth=jwt_auth)
def my_payments_paginated(request, page: int = Query(1, ge=1)):
page_size = 10
status_names = {
Payment.OrderStatusChoices.INIT: "initiated",
Payment.OrderStatusChoices.PENDING: "pending",
Payment.OrderStatusChoices.PAID: "paid",
Payment.OrderStatusChoices.FAILED: "failed",
Payment.OrderStatusChoices.CANCELED: "canceled",
}
queryset = Payment.objects.filter(user=request.auth).select_related("event").order_by("-created_at", "-id")
count = queryset.count()
total_pages = max(1, (count + page_size - 1) // page_size)
start = (page - 1) * page_size
return {
"count": count,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"results": [_user_payment_payload(request, item, status_names) for item in queryset[start : start + page_size]],
}
def _staff_required(user):
return bool(user and (user.is_staff or user.is_superuser))
def _discount_payload(code: DiscountCode):
return {
"id": code.id,
"code": code.code,
"type": code.type,
"value": code.value,
"max_discount": code.max_discount,
"is_active": code.is_active,
"starts_at": code.starts_at,
"ends_at": code.ends_at,
"usage_limit_total": code.usage_limit_total,
"usage_limit_per_user": code.usage_limit_per_user,
"min_amount": code.min_amount,
"applicable_event_ids": list(code.applicable_events.values_list("id", flat=True)),
"usage_count": getattr(code, "usage_count", None) or code.payments.filter(
status__in=[Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.PENDING]
).count(),
"created_at": code.created_at,
"updated_at": code.updated_at,
}
def _apply_discount_payload(instance: DiscountCode, payload: DiscountCodeWriteSchema):
data = payload.dict()
event_ids = data.pop("applicable_event_ids", [])
for field, value in data.items():
setattr(instance, field, value)
instance.code = instance.code.strip().upper()
instance.full_clean()
instance.save()
instance.applicable_events.set(Event.objects.filter(id__in=event_ids, is_deleted=False))
return instance
@payments_router.get("/admin/discount-codes", response={200: PagedDiscountCodeSchema, 403: ErrorSchema}, auth=jwt_auth)
def admin_list_discount_codes(
request,
search: str | None = Query(None),
is_active: bool | None = Query(None),
type: str | None = Query(None),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
):
if not _staff_required(request.auth):
return 403, {"error": "Permission denied"}
queryset = DiscountCode.objects.annotate(
usage_count=Count(
"payments",
filter=Q(
payments__status__in=[
Payment.OrderStatusChoices.PAID,
Payment.OrderStatusChoices.PENDING,
]
),
)
).prefetch_related("applicable_events").order_by("-created_at")
if search:
queryset = queryset.filter(code__icontains=search)
if is_active is not None:
queryset = queryset.filter(is_active=is_active)
if type:
queryset = queryset.filter(type=type)
count = queryset.count()
return 200, {"count": count, "results": [_discount_payload(item) for item in queryset[offset : offset + limit]]}
@payments_router.post("/admin/discount-codes", response={201: DiscountCodeSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
def admin_create_discount_code(request, payload: DiscountCodeWriteSchema):
if not _staff_required(request.auth):
return 403, {"error": "Permission denied"}
if DiscountCode.all_objects.filter(code=payload.code.strip().upper()).exists():
return 400, {"error": "Discount code already exists"}
try:
code = _apply_discount_payload(DiscountCode(), payload)
except Exception as exc:
return 400, {"error": str(exc)}
return 201, _discount_payload(code)
@payments_router.put("/admin/discount-codes/{int:code_id}", response={200: DiscountCodeSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
def admin_update_discount_code(request, code_id: int, payload: DiscountCodeWriteSchema):
if not _staff_required(request.auth):
return 403, {"error": "Permission denied"}
code = get_object_or_404(DiscountCode, id=code_id, is_deleted=False)
normalized = payload.code.strip().upper()
if DiscountCode.all_objects.filter(code=normalized).exclude(id=code_id).exists():
return 400, {"error": "Discount code already exists"}
try:
code = _apply_discount_payload(code, payload)
except Exception as exc:
return 400, {"error": str(exc)}
return 200, _discount_payload(code)
@payments_router.delete("/admin/discount-codes/{int:code_id}", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
def admin_delete_discount_code(request, code_id: int):
if not request.auth.is_superuser:
return 403, {"error": "Only superusers can delete discount codes"}
code = get_object_or_404(DiscountCode, id=code_id, is_deleted=False)
code.delete()
return 200, {"message": "Discount code deleted"}
@payments_router.post("/admin/discount-codes/{int:code_id}/restore", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
def admin_restore_discount_code(request, code_id: int):
if not request.auth.is_superuser:
return 403, {"error": "Only superusers can restore discount codes"}
try:
code = DiscountCode.deleted_objects.get(id=code_id)
except DiscountCode.DoesNotExist:
return 400, {"error": "Discount code not found"}
code.restore()
return 200, {"message": "Discount code restored"}
def _event_action_url(event: Event) -> str:
root = getattr(settings, "FRONTEND_ROOT", "/") or "/"
if not root.endswith("/"):
root = f"{root}/"
return f"{root}events/{event.slug or event.id}"
def _notify_payment_status(payment: Payment, *, title: str, message: str, level: str):
notify_user(
payment.user_id,
{
"type": "payment_status",
"title": title,
"message": message,
"level": level,
"action_url": _event_action_url(payment.event),
"entity_type": "payment",
"entity_id": payment.id,
"meta": {
"event_id": payment.event_id,
"payment_id": payment.id,
"ref_id": payment.ref_id,
"status": payment.status,
},
},
)
if payment.user.mobile and payment.user.is_mobile_verified:
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,
}
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.
with transaction.atomic():
event = get_object_or_404(
Event.objects.select_for_update(),
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", "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")
active_payment = (
Payment.objects.filter(
user=request.auth,
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 = (
Registration.objects.select_for_update()
.filter(event=event, user=request.auth, is_deleted=False)
.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:
registration.final_price = event.price
registration.save(update_fields=["final_price"])
discount_code = None
discount_amount = 0
final_amount = event.price
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,
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 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,
idempotency_key=idempotency_key,
)
callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback")
body = {
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
"amount": final_amount,
"callback_url": callback_url,
"description": payload.description,
"metadata": {
k: v for k, v in {
"mobile": payload.mobile,
"email": payload.email,
"event_id": event.id,
"user_id": request.auth.id,
"payment_id": pay.id,
"discount_code": discount_code.code if discount_code else None,
}.items() if v
}
}
try:
response = requests.post(
settings.ZARINPAL_REQUEST_URL,
json=body,
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:
logger.exception(
"Gateway payment request outcome is unknown for payment %s; leaving it pending for review",
pay.id,
)
pay.status = Payment.OrderStatusChoices.PENDING
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
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.verification_manual_review_at = timezone.now()
pay.save(update_fields=["status", "verification_manual_review_at"])
return _payment_checkout_payload(pay)
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 _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 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()
.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 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)
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
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.FAILED,
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."""
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()
.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.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
else "failed"
)
transitioned = True
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,
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,
cancellation_reason=Registration.CancellationReasonChoices.PAYMENT_FAILED,
)
return payment, result, transitioned
@payments_router.get("callback")
def callback(request, Authority: str | None = None, Status: str | None = None):
if not Authority:
return _callback_unknown_redirect()
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
if not pay:
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)
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=delay)
except Exception:
logger.exception(
"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}» تأیید شد؛ بازگشت وجه در حال پیگیری است"
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")
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}» لغو شد"
if canceled
else f"پرداخت رویداد «{pay.event.title}» ناموفق بود"
),
message=(
"پرداخت تکمیل نشد و ثبت‌نام نهایی نشد. برای شروع دوباره به صفحه رویداد برگردید."
if canceled
else "تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید."
),
level="warning" if canceled else "error",
)
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,
"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,
"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, 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,
user=request.auth,
)
return _payment_detail_payload(request, pay)
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
def check_coupon(request, payload: CouponVerifyIn):
event = get_object_or_404(Event, id=payload.event_id)
code = payload.code
if not code:
raise HttpError(404, "لطفا کد تخفیف را وارد کنید")
try:
c = DiscountCode.objects.get(code=code, applicable_events=event, is_active=True)
final_price, disc = c.calculate_discount(event, request.auth)
return {
"discount_amount": disc,
"final_price": final_price,
}
except DiscountCode.DoesNotExist:
raise HttpError(404, "کد تخفیف معتبر نیست")