Replace persisted event statuses with publication and cancellation flags, and compute public lifecycle labels from event timing and registration capacity. Keep filters, registration checks, and profile responses aligned with the derived status.
1064 lines
43 KiB
Python
1064 lines
43 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.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, "کد تخفیف معتبر نیست")
|