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 from ninja.errors import HttpError 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 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 { "event": { "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], "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, } @payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth) def create_payment(request, payload: CreatePaymentIn): idempotency_key = payload.idempotency_key or uuid4() # 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, ) 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 Payment.objects.filter( status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event, ).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], ) .order_by("-created_at", "-id") .first() ) if active_payment: 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 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 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.save(update_fields=["status"]) 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.save(update_fields=["status"]) return _payment_checkout_payload(pay) pay.authority = authority pay.status = Payment.OrderStatusChoices.PENDING pay.save(update_fields=["authority","status"]) 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): if not Authority: raise HttpError(400, "Missing Authority") pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first() if not pay: raise HttpError(404, "Payment not found") 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: 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)) if vcode in (100, 101): pay, newly_verified = _mark_payment_paid(pay.id, data) if newly_verified: _notify_payment_status( pay, title=f"پرداخت رویداد «{pay.event.title}» تأیید شد", message="پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است.", level="success", ) return _callback_redirect(pay, "success") 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 _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) 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, "کد تخفیف معتبر نیست")