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 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.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, ) payments_router = Router(tags=["Payments"]) 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) @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) 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, ) 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, "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, ) 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 ) jd = response.json() except Exception as e: pay.delete() raise HttpError(502, f"Gateway request failed: {e}") code = (jd.get("data") or {}).get("code") if code != 100: pay.delete() raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}") 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, } @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") 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, } 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 = (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"]) 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: _notify_payment_status( pay, title=f"پرداخت {pay.event.title} تأیید شد", message="پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است.", level="success", ) return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=success&event_id={pay.event_id}&ref_id={pay.ref_id}") 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}") @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, }, } @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, "کد تخفیف معتبر نیست")