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.models.functions import Coalesce from django.db import transaction from typing import List, Literal from ninja import Query, Router from ninja.errors import HttpError import requests import redis from kombu.exceptions import OperationalError as KombuOperationalError from apps.payments.models import Payment, DiscountCode from apps.events.models import Event, Registration from apps.events.checkout import ( CHECKOUT_TIMEOUT, expire_user_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), status: Literal["all", "initiated", "pending", "paid", "failed", "canceled"] = Query("all"), sort_by: Literal["time", "amount"] = Query("time"), sort_order: Literal["asc", "desc"] = Query("desc"), ): 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") status_values = { "initiated": Payment.OrderStatusChoices.INIT, "pending": Payment.OrderStatusChoices.PENDING, "paid": Payment.OrderStatusChoices.PAID, "failed": Payment.OrderStatusChoices.FAILED, "canceled": Payment.OrderStatusChoices.CANCELED, } if status != "all": queryset = queryset.filter(status=status_values[status]) sort_field = "amount" if sort_by == "amount" else "_payment_time" sort_prefix = "" if sort_order == "asc" else "-" queryset = queryset.annotate(_payment_time=Coalesce("verified_at", "created_at")).order_by( f"{sort_prefix}{sort_field}", "-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): try: 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, }, }, ) except redis.exceptions.RedisError: logger.exception( "Unable to store payment status notification for payment %s; payment state is already saved", payment.id, ) if payment.user.mobile and payment.user.is_mobile_verified: try: send_critical_sms.delay(payment.user.mobile, "payment_status", payment.event.title) except (KombuOperationalError, redis.exceptions.RedisError): logger.exception( "Unable to queue payment status SMS for payment %s; payment state is already saved", payment.id, ) 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() with transaction.atomic(): event = get_object_or_404( Event.objects.select_for_update(), pk=payload.event_id, is_deleted=False, ) expire_user_registration_checkouts(event=event, user=request.auth, now=now) # 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.is_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.get_display_status(now=now) != "registration_open": 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.is_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.is_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.is_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, "کد تخفیف معتبر نیست")