1029 lines
42 KiB
Python
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, "کد تخفیف معتبر نیست")
|