feat(events): harden registration checkout lifecycle

This commit is contained in:
2026-10-01 12:26:49 +03:30
parent 1daa755543
commit 962238c32c
29 changed files with 3048 additions and 207 deletions

View File

@@ -27,6 +27,9 @@ SMS_PAYMENT_STATUS_TEMPLATE_ID=
FRONTEND_ROOT=http://localhost:8080 FRONTEND_ROOT=http://localhost:8080
FRONTEND_CALLBACK_URL=http://localhost:8080/payments/result FRONTEND_CALLBACK_URL=http://localhost:8080/payments/result
PAYMENT_VERIFICATION_GRACE_SECONDS=1800
PAYMENT_VERIFICATION_MAX_ATTEMPTS=6
PAYMENT_VERIFICATION_DISPATCH_LEASE_SECONDS=900
GOOGLE_OAUTH_REDIRECT_URI=http://127.0.0.1:8000/api/auth/oauth/google/callback GOOGLE_OAUTH_REDIRECT_URI=http://127.0.0.1:8000/api/auth/oauth/google/callback
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=http://localhost:8080/auth/google/callback GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=http://localhost:8080/auth/google/callback

View File

@@ -58,6 +58,9 @@ CSRF_COOKIE_SECURE=True
SESSION_COOKIE_SECURE=True SESSION_COOKIE_SECURE=True
FRONTEND_ROOT=https://frontend.example.com FRONTEND_ROOT=https://frontend.example.com
FRONTEND_CALLBACK_URL=https://frontend.example.com/payments/result FRONTEND_CALLBACK_URL=https://frontend.example.com/payments/result
PAYMENT_VERIFICATION_GRACE_SECONDS=1800
PAYMENT_VERIFICATION_MAX_ATTEMPTS=6
PAYMENT_VERIFICATION_DISPATCH_LEASE_SECONDS=900
# SSE Notifications # SSE Notifications
NOTIFICATIONS_ENABLED=True NOTIFICATIONS_ENABLED=True

View File

@@ -87,7 +87,7 @@ ABOUT_MOCK = {
"members_intro": "با اعضایی آشنا شوید که برای ساختن تجربه‌های بهتر در انجمن همکاری می‌کنند.", "members_intro": "با اعضایی آشنا شوید که برای ساختن تجربه‌های بهتر در انجمن همکاری می‌کنند.",
"gallery_heading": "خاطرات انجمن", "gallery_heading": "خاطرات انجمن",
"gallery_intro": "تصاویری از گردهمایی‌ها، برنامه‌ها و لحظه‌هایی که کنار هم ساخته‌ایم.", "gallery_intro": "تصاویری از گردهمایی‌ها، برنامه‌ها و لحظه‌هایی که کنار هم ساخته‌ایم.",
"past_events_heading": "رویدادهای برگزارشده", "past_events_heading": "رویدادهای برگزار شده",
"history_heading": "مسیر انجمن", "history_heading": "مسیر انجمن",
"audience_heading": "برای چه کسانی؟", "audience_heading": "برای چه کسانی؟",
"year_events_heading": "لحظه‌های هر سال", "year_events_heading": "لحظه‌های هر سال",

View File

@@ -7,6 +7,7 @@ from core.templatetags.jalali import jdate
from apps.events.models import Event, Registration from apps.events.models import Event, Registration
from apps.events.resources import EventResource, RegistrationResource from apps.events.resources import EventResource, RegistrationResource
from apps.events.services import verify_registration_attendance from apps.events.services import verify_registration_attendance
from apps.events.checkout import cancel_event_checkouts
@admin.register(Event) @admin.register(Event)
@@ -79,8 +80,14 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin):
@admin.action(description='Mark selected events as cancelled') @admin.action(description='Mark selected events as cancelled')
def make_cancelled(self, request, queryset): def make_cancelled(self, request, queryset):
queryset.update(status=Event.StatusChoices.CANCELLED) count = 0
self.message_user(request, f'Cancelled {queryset.count()} events.') for event in queryset:
if event.status != Event.StatusChoices.CANCELLED:
event.status = Event.StatusChoices.CANCELLED
event.save(update_fields=['status'])
cancel_event_checkouts(event.pk)
count += 1
self.message_user(request, f'Cancelled {count} events.')
@admin.action(description='Mark selected events as completed') @admin.action(description='Mark selected events as completed')
def make_completed(self, request, queryset): def make_completed(self, request, queryset):
@@ -93,15 +100,27 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin):
event.restore() event.restore()
self.message_user(request, f'Restored {queryset.count()} events.') self.message_user(request, f'Restored {queryset.count()} events.')
def save_model(self, request, obj, form, change):
previous_status = None
if change and obj.pk:
previous_status = Event.all_objects.filter(pk=obj.pk).values_list("status", flat=True).first()
super().save_model(request, obj, form, change)
if previous_status != Event.StatusChoices.CANCELLED and obj.status == Event.StatusChoices.CANCELLED:
cancel_event_checkouts(obj.pk)
@admin.register(Registration) @admin.register(Registration)
class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin): class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
resource_class = RegistrationResource resource_class = RegistrationResource
list_display = ( list_display = (
'user', 'event', 'status', 'registered_at', 'ticket_id', 'user', 'event', 'status', 'registered_at', 'checkout_expires_at',
'cancellation_reason', 'ticket_id',
'discount_code', 'discount_amount', 'final_price', 'discount_code', 'discount_amount', 'final_price',
) )
list_filter = ('status', 'event', 'is_deleted', 'registered_at', SoftDeleteListFilter) list_filter = (
'status', 'cancellation_reason', 'event', 'is_deleted', 'registered_at',
SoftDeleteListFilter,
)
search_fields = ( search_fields = (
'user__username', 'user__email', 'user__first_name', 'user__last_name', 'user__username', 'user__email', 'user__first_name', 'user__last_name',
'event__title', 'ticket_id', 'event__title', 'ticket_id',
@@ -109,13 +128,15 @@ class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
readonly_fields = ( readonly_fields = (
'ticket_id', 'registered_at', 'confirmation_notification_sent_at', 'ticket_id', 'registered_at', 'confirmation_notification_sent_at',
'cancellation_notification_sent_at', 'discount_code', 'discount_amount', 'cancellation_notification_sent_at', 'discount_code', 'discount_amount',
'final_price', 'deleted_at', 'attendance_verified_at', 'final_price', 'checkout_started_at', 'checkout_expires_at',
'cancellation_reason', 'deleted_at', 'attendance_verified_at',
'attendance_verified_by', 'attendance_verification_source', 'attendance_verified_by', 'attendance_verification_source',
) )
fieldsets = ( fieldsets = (
('Registration Details', {'fields': ( ('Registration Details', {'fields': (
'user', 'event', 'status', 'registered_at', 'ticket_id', 'user', 'event', 'status', 'registered_at', 'ticket_id',
'confirmation_notification_sent_at', 'cancellation_notification_sent_at', 'confirmation_notification_sent_at', 'cancellation_notification_sent_at',
'checkout_started_at', 'checkout_expires_at', 'cancellation_reason',
)}), )}),
('Attendance verification', {'fields': ( ('Attendance verification', {'fields': (
'attendance_verified_at', 'attendance_verified_by', 'attendance_verification_source', 'attendance_verified_at', 'attendance_verified_by', 'attendance_verification_source',

View File

@@ -5,6 +5,8 @@ from ninja import ModelSchema, Schema
from pydantic import field_validator from pydantic import field_validator
from typing import Literal, Optional, List from typing import Literal, Optional, List
from datetime import datetime from datetime import datetime
from django.db.models import Q
from django.utils import timezone
from apps.blog.api.schemas import AuthorSchema from apps.blog.api.schemas import AuthorSchema
from apps.events.models import Event, Registration from apps.events.models import Event, Registration
@@ -85,7 +87,11 @@ class EventSchema(ModelSchema):
@staticmethod @staticmethod
def resolve_registration_count(obj): def resolve_registration_count(obj):
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count() now = timezone.now()
return obj.registrations.filter(is_deleted=False).filter(
Q(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
| Q(status=Registration.StatusChoices.PENDING, checkout_expires_at__gt=now)
).count()
@staticmethod @staticmethod
def resolve_description_html(obj): def resolve_description_html(obj):
@@ -102,6 +108,7 @@ class EventListSchema(Schema):
absolute_featured_image_thumbnail_url: Optional[str] = None absolute_featured_image_thumbnail_url: Optional[str] = None
absolute_featured_image_preview_url: Optional[str] = None absolute_featured_image_preview_url: Optional[str] = None
event_type: str event_type: str
address: Optional[str] = None
start_time: datetime start_time: datetime
end_time: datetime end_time: datetime
registration_start_date: Optional[datetime] = None registration_start_date: Optional[datetime] = None
@@ -133,7 +140,22 @@ class EventListSchema(Schema):
@staticmethod @staticmethod
def resolve_registration_count(obj): def resolve_registration_count(obj):
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count() if hasattr(obj, "browse_registration_count"):
return obj.browse_registration_count
now = timezone.now()
return obj.registrations.filter(is_deleted=False).filter(
Q(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
| Q(status=Registration.StatusChoices.PENDING, checkout_expires_at__gt=now)
).count()
class PaginatedEventBrowseSchema(Schema):
count: int
page: int
page_size: int
total_pages: int
price_ceiling: int
results: List[EventListSchema]
class EventCreateSchema(Schema): class EventCreateSchema(Schema):
"""Payload for creating events via the API.""" """Payload for creating events via the API."""
@@ -185,6 +207,9 @@ class RegistrationSchema(ModelSchema):
'id', 'id',
'status', 'status',
'registered_at', 'registered_at',
'checkout_started_at',
'checkout_expires_at',
'cancellation_reason',
'ticket_id', 'ticket_id',
'discount_amount', 'discount_amount',
'final_price', 'final_price',
@@ -326,9 +351,16 @@ class EventBriefSchema(Schema):
class MyEventRegistrationOut(Schema): class MyEventRegistrationOut(Schema):
"""Registration information as returned to authenticated users.""" """Registration information as returned to authenticated users."""
id: int id: int
ticket_id: UUID
created_at: datetime created_at: datetime
status: Literal["pending", "confirmed", "cancelled", "attended"] status: Literal["pending", "confirmed", "cancelled", "attended"]
event: EventBriefSchema event: EventBriefSchema
checkout_state: Literal["none", "active", "legacy_pending", "expired", "unresolved", "cancelled"] = "none"
checkout_expires_at: Optional[datetime] = None
payment_id: Optional[int] = None
payment_status: Optional[Literal["initiated", "pending", "paid", "failed", "canceled"]] = None
refund_status: Optional[Literal["none", "pending", "succeeded", "manual_review"]] = None
can_resume: bool = False
class PaginatedMyEventRegistrationsSchema(Schema): class PaginatedMyEventRegistrationsSchema(Schema):
@@ -340,6 +372,79 @@ class PaginatedMyEventRegistrationsSchema(Schema):
class RegistrationStatusOut(Schema): class RegistrationStatusOut(Schema):
is_registered: bool is_registered: bool
checkout_state: Literal[
"none", "confirmed", "active", "legacy_pending", "expired", "unresolved", "cancelled"
] = "none"
registration_id: Optional[int] = None
registration_status: Optional[str] = None
ticket_id: Optional[UUID] = None
checkout_expires_at: Optional[datetime] = None
payment_id: Optional[int] = None
payment_status: Optional[Literal["initiated", "pending", "paid", "failed", "canceled"]] = None
refund_status: Optional[Literal["none", "pending", "succeeded", "manual_review"]] = None
can_resume: bool = False
can_cancel: bool = False
class RegistrationHubEventOut(Schema):
id: int
title: str
slug: str
description: str
event_type: str
address: Optional[str] = None
location: Optional[str] = None
start_time: datetime
end_time: datetime
registration_start_date: Optional[datetime] = None
registration_end_date: Optional[datetime] = None
capacity: Optional[int] = None
price: int
status: str
registration_count: int
image_url: Optional[str] = None
registration_success_markdown: Optional[str] = None
class RegistrationHubRegistrationOut(Schema):
ticket_id: UUID
status: str
registered_at: datetime
checkout_started_at: Optional[datetime] = None
checkout_expires_at: Optional[datetime] = None
cancellation_reason: str = ""
final_price: Optional[int] = None
discount_amount: int = 0
class RegistrationHubPaymentOut(Schema):
id: int
status: Literal["initiated", "pending", "paid", "failed", "canceled"]
verification_state: Literal[
"awaiting_verification", "checking", "retry_scheduled", "manual_review",
"verified", "failed", "canceled",
]
refund_status: Literal["none", "pending", "succeeded", "manual_review"]
base_amount: int
discount_amount: int
amount: int
ref_id: Optional[str] = None
created_at: datetime
gateway_paid_at: Optional[datetime] = None
class RegistrationHubOut(Schema):
state: Literal[
"ready", "active_checkout", "legacy_pending", "verification_pending",
"manual_review", "paid_success", "free_success", "failed", "cancelled",
"expired", "refund_pending", "refund_manual_review", "refund_succeeded",
]
event: RegistrationHubEventOut
registration: Optional[RegistrationHubRegistrationOut] = None
payment: Optional[RegistrationHubPaymentOut] = None
can_register: bool = False
can_resume: bool = False
can_cancel: bool = False
class RegistrationCreateSchema(Schema): class RegistrationCreateSchema(Schema):

View File

@@ -1,7 +1,11 @@
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.db.models import Q, Case, When, IntegerField, F from django.db import transaction
from datetime import date, datetime, time as datetime_time, timedelta
from math import ceil
from django.db.models import Q, Case, When, IntegerField, F, Count, Max
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.utils.text import slugify from django.utils.text import slugify
from django.utils import timezone from django.utils import timezone
@@ -17,6 +21,7 @@ from apps.events.api.schemas import (
EventCreateSchema, EventCreateSchema,
EventGallerySchema, EventGallerySchema,
EventListSchema, EventListSchema,
PaginatedEventBrowseSchema,
EventSchema, EventSchema,
EventUpdateSchema, EventUpdateSchema,
MyEventRegistrationOut, MyEventRegistrationOut,
@@ -26,14 +31,22 @@ from apps.events.api.schemas import (
RegistrationCreateSchema, RegistrationCreateSchema,
RegistrationSchema, RegistrationSchema,
RegistrationStatusOut, RegistrationStatusOut,
RegistrationHubOut,
RegistrationStatusUpdateSchema, RegistrationStatusUpdateSchema,
) )
from core.authentication import jwt_auth from core.authentication import jwt_auth
from apps.events.models import Event, Registration from apps.events.models import Event, Registration
from apps.events.checkout import (
CHECKOUT_TIMEOUT,
cancel_event_checkouts,
expire_stale_registration_checkouts,
mark_checkout_cancelled,
queue_checkout_reconciliation,
)
from apps.gallery.models import Gallery from apps.gallery.models import Gallery
from apps.gallery.tasks import process_uploaded_image from apps.gallery.tasks import process_uploaded_image
from apps.notifications.services import notify_user from apps.notifications.services import notify_user
from apps.payments.models import DiscountCode from apps.payments.models import DiscountCode, Payment
from apps.users.tasks import send_critical_sms from apps.users.tasks import send_critical_sms
from core.api.schemas import ErrorSchema, MessageSchema from core.api.schemas import ErrorSchema, MessageSchema
@@ -138,6 +151,143 @@ def list_events(
events = queryset[offset:offset + limit] events = queryset[offset:offset + limit]
return events return events
def _event_registration_open_query(now):
return (
Q(status=Event.StatusChoices.PUBLISHED)
& (Q(registration_start_date__isnull=True) | Q(registration_start_date__lte=now))
& (Q(registration_end_date__isnull=True) | Q(registration_end_date__gte=now))
& (Q(capacity__isnull=True) | Q(capacity__gt=F("browse_registration_count")))
)
def _parse_browse_date(value: Optional[str], field_name: str):
if not value:
return None
try:
parsed = date.fromisoformat(value)
except ValueError as error:
raise HttpError(400, f"{field_name} باید به شکل YYYY-MM-DD باشد.") from error
if parsed.isoformat() != value:
raise HttpError(400, f"{field_name} باید به شکل YYYY-MM-DD باشد.")
return timezone.make_aware(
datetime.combine(parsed, datetime_time.min),
timezone.get_current_timezone(),
)
@events_router.get("/browse/", response=PaginatedEventBrowseSchema)
def browse_events(
request,
state: Literal["all", "completed", "upcoming", "open"] = "all",
event_type: Optional[str] = None,
search: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
price_min: Optional[int] = None,
price_max: Optional[int] = None,
free_only: bool = False,
page: int = 1,
):
"""Browse public events; price bounds and ceiling are in tomans, stored event prices are rials."""
page_size = 12
if page < 1:
raise HttpError(400, "شماره صفحه باید بزرگ‌تر از صفر باشد.")
if event_type and event_type not in Event.TypeChoices.values:
raise HttpError(400, "نوع رویداد معتبر نیست.")
if price_min is not None and price_min < 0:
raise HttpError(400, "حداقل قیمت نمی‌تواند منفی باشد.")
if price_max is not None and price_max < 0:
raise HttpError(400, "حداکثر قیمت نمی‌تواند منفی باشد.")
if price_min is not None and price_max is not None and price_min > price_max:
raise HttpError(400, "حداقل قیمت نمی‌تواند از حداکثر قیمت بیشتر باشد.")
selected_from = _parse_browse_date(date_from, "date_from")
selected_to = _parse_browse_date(date_to, "date_to")
if selected_from and selected_to and selected_from > selected_to:
raise HttpError(400, "تاریخ شروع نمی‌تواند بعد از تاریخ پایان باشد.")
public_statuses = [Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED]
public_events = Event.objects.filter(is_deleted=False, status__in=public_statuses)
highest_price_rial = public_events.aggregate(value=Max("price"))["value"] or 0
price_ceiling = ceil(highest_price_rial / 10 / 1_000_000) * 1_000_000
now = timezone.now()
queryset = (
public_events
.annotate(
browse_registration_count=Count(
"registrations",
filter=Q(
registrations__is_deleted=False,
) & (
Q(registrations__status__in=[
Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED,
])
| Q(
registrations__status=Registration.StatusChoices.PENDING,
registrations__checkout_expires_at__gt=now,
)
),
distinct=True,
)
)
.prefetch_related("gallery_images")
)
if state == "completed":
queryset = queryset.filter(
Q(status=Event.StatusChoices.COMPLETED) | Q(end_time__lte=now)
)
elif state == "upcoming":
queryset = queryset.filter(
status=Event.StatusChoices.PUBLISHED,
start_time__gt=now,
).exclude(_event_registration_open_query(now))
elif state == "open":
queryset = queryset.filter(_event_registration_open_query(now))
if event_type:
queryset = queryset.filter(event_type=event_type)
if search and search.strip():
queryset = queryset.filter(
Q(title__icontains=search.strip()) | Q(description__icontains=search.strip())
)
if selected_from:
queryset = queryset.filter(start_time__gte=selected_from)
if selected_to:
queryset = queryset.filter(start_time__lt=selected_to + timedelta(days=1))
if free_only:
queryset = queryset.filter(Q(price__isnull=True) | Q(price__lte=0))
else:
if price_min is not None:
queryset = queryset.filter(price__gte=price_min * 10)
if price_max is not None:
queryset = queryset.filter(price__lte=price_max * 10)
queryset = queryset.annotate(
published_first=Case(
When(status=Event.StatusChoices.PUBLISHED, then=0),
default=1,
output_field=IntegerField(),
)
).order_by("published_first", "-start_time", "-id")
count = queryset.count()
total_pages = ceil(count / page_size) if count else 0
current_page = min(page, total_pages) if total_pages else 1
results = queryset[(current_page - 1) * page_size:current_page * page_size]
return {
"count": count,
"page": current_page,
"page_size": page_size,
"total_pages": total_pages,
"price_ceiling": price_ceiling,
"results": results,
}
@events_router.get("/{int:event_id}", response=EventSchema) @events_router.get("/{int:event_id}", response=EventSchema)
def get_event(request, event_id: int): def get_event(request, event_id: int):
"""Get event details by ID""" """Get event details by ID"""
@@ -224,6 +374,7 @@ def update_event(request, event_id: int, payload: EventUpdateSchema):
) )
if cancelled_now: if cancelled_now:
cancel_event_checkouts(event.id)
_notify_event_update( _notify_event_update(
event, event,
notification_type="event_cancelled", notification_type="event_cancelled",
@@ -244,6 +395,45 @@ def update_event(request, event_id: int, payload: EventUpdateSchema):
return 200, event return 200, event
@events_router.post("/{int:event_id}/checkout/cancel", response=MessageSchema, auth=jwt_auth)
def cancel_registration_checkout(request, event_id: int):
"""Cancel the user's active paid checkout and release its seat."""
with transaction.atomic():
event = get_object_or_404(
Event.objects.select_for_update(),
id=event_id,
is_deleted=False,
)
registration = (
Registration.objects.select_for_update()
.filter(
event=event,
user=request.auth,
status=Registration.StatusChoices.PENDING,
checkout_expires_at__isnull=False,
is_deleted=False,
)
.order_by("-registered_at", "-id")
.first()
)
if registration is None:
raise HttpError(409, "ثبت‌نام فعالی برای لغو وجود ندارد.")
payment = Payment.objects.select_for_update().filter(
registration=registration,
).order_by("-created_at", "-id").first()
if payment and payment.status == Payment.OrderStatusChoices.PAID:
raise HttpError(409, "پرداخت تأیید شده است و امکان لغو پرداخت وجود ندارد.")
mark_checkout_cancelled(
registration,
Registration.CancellationReasonChoices.USER_CANCELLED,
)
queue_checkout_reconciliation(registration)
return {"message": "درخواست پرداخت لغو شد و ظرفیت آزاد شد."}
@events_router.delete("/{int:event_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth) @events_router.delete("/{int:event_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
def delete_event(request, event_id: int): def delete_event(request, event_id: int):
"""Soft delete an event""" """Soft delete an event"""
@@ -407,79 +597,109 @@ def register_for_event(
payload: RegistrationCreateSchema | None = None, payload: RegistrationCreateSchema | None = None,
): ):
"""Register current user for an event""" """Register current user for an event"""
event = get_object_or_404(Event, id=event_id, is_deleted=False)
user = request.auth user = request.auth
now = timezone.now()
expire_stale_registration_checkouts(now=now, event_id=event_id)
if Registration.objects.filter(event=event, user=user, status=Registration.StatusChoices.CONFIRMED).exists(): with transaction.atomic():
raise HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید.") event = get_object_or_404(
Event.objects.select_for_update(),
id=event_id,
is_deleted=False,
)
if Registration.objects.filter(
event=event,
user=user,
status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED],
is_deleted=False,
).exists():
raise HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید.")
if event.registration_end_date and event.registration_end_date < timezone.now(): registration = (
raise HttpError(400, "مهلت ثبت‌نام به پایان رسیده‌است") Registration.objects.select_for_update()
.filter(event=event, user=user, is_deleted=False)
if event.registration_start_date and event.registration_start_date > timezone.now(): .order_by("-registered_at", "-id")
raise HttpError(400, "زمان ثبت‌نام هنوز آغاز نشده است") .first()
)
if registration and registration.status == Registration.StatusChoices.PENDING:
if registration.checkout_expires_at is None:
# Preserve pre-timeout rows without backfilling or expiring them.
if registration.final_price is None:
registration.final_price = event.price
registration.save(update_fields=["final_price"])
return registration
if registration.checkout_expires_at > now:
# Existing checkouts may finish through their original window,
# even if registration has since closed or its held seat is full.
return registration
mark_checkout_cancelled(
registration,
Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
)
if not event.has_available_slots: unresolved_attempt = Payment.objects.filter(
raise HttpError(400, "ظرفیت شرکت‌کنندگان تکمیل است") user=user,
event=event,
registration__cancellation_reason__in=[
Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
Registration.CancellationReasonChoices.USER_CANCELLED,
Registration.CancellationReasonChoices.EVENT_CANCELLED,
],
).filter(
Q(status__in=[Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING])
| Q(
status=Payment.OrderStatusChoices.PAID,
refund_status__in=[
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
],
)
).exists()
if unresolved_attempt:
raise HttpError(409, "وضعیت پرداخت قبلی هنوز مشخص نشده است. پس از بررسی دوباره تلاش کنید.")
# Create or get existing registration if event.status != Event.StatusChoices.PUBLISHED:
discount_code = None raise HttpError(400, "ثبت‌نام این رویداد فعال نیست.")
if payload and payload.discount_code: if event.registration_end_date and event.registration_end_date < now:
discount_code = payload.discount_code raise HttpError(400, "مهلت ثبت‌نام به پایان رسیده‌است")
elif request.GET.get("discount_code"): if event.registration_start_date and event.registration_start_date > now:
discount_code = request.GET.get("discount_code") raise HttpError(400, "زمان ثبت‌نام هنوز آغاز نشده است")
if not event.has_available_slots:
raise HttpError(400, "ظرفیت شرکت‌کنندگان تکمیل است")
registration, created = Registration.objects.get_or_create( discount_code_value = (
event=event, payload.discount_code if payload and payload.discount_code
user=user, else request.GET.get("discount_code")
status=Registration.StatusChoices.PENDING, )
defaults={"final_price": event.price}, applied_code = None
) discount_amount = 0
final_price = event.price or 0
if discount_code_value:
applied_code = DiscountCode.objects.filter(
code=discount_code_value,
applicable_events=event,
is_active=True,
).first()
if not applied_code:
raise HttpError(400, "کد تخفیف برای این رویداد معتبر نیست.")
final_price, discount_amount = applied_code.calculate_discount(event, user)
if registration.status == Registration.StatusChoices.CONFIRMED: is_free = final_price == 0
return HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید")
if registration.status == Registration.StatusChoices.CANCELLED:
registration = Registration.objects.create( registration = Registration.objects.create(
event=event, event=event,
user=user, user=user,
status=Registration.StatusChoices.PENDING, status=(
final_price=event.price, Registration.StatusChoices.CONFIRMED
if is_free
else Registration.StatusChoices.PENDING
),
checkout_started_at=None if is_free else now,
checkout_expires_at=None if is_free else now + CHECKOUT_TIMEOUT,
discount_code=applied_code,
discount_amount=discount_amount,
final_price=final_price,
) )
elif not created and registration.final_price is None: return registration
registration.final_price = event.price
registration.save(update_fields=["final_price"])
applied_code = None
discount_amount = 0
final_price = event.price
fields_to_update = []
if discount_code:
applied_code = DiscountCode.objects.filter(
code=discount_code,
applicable_events=event,
is_active=True,
).first()
if not applied_code:
raise HttpError(400, "UcO_ O<>OrU?UOU? U.O1O<31>O\"O<EFBFBD> U+UOO3O<33>")
final_price, discount_amount = applied_code.calculate_discount(event, user)
registration.discount_code = applied_code
registration.discount_amount = discount_amount
fields_to_update.extend(["discount_code", "discount_amount"])
if registration.final_price != final_price:
registration.final_price = final_price
fields_to_update.append("final_price")
if not event.price or final_price == 0:
registration.status = Registration.StatusChoices.CONFIRMED
fields_to_update.append("status")
if fields_to_update:
registration.save(update_fields=list(set(fields_to_update)))
return registration
@events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth) @events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth)
def update_registration_status(request, registration_id: int, payload: RegistrationStatusUpdateSchema): def update_registration_status(request, registration_id: int, payload: RegistrationStatusUpdateSchema):
@@ -488,11 +708,36 @@ def update_registration_status(request, registration_id: int, payload: Registrat
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False) registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
requested_status = payload.dict(exclude_unset=True).get('status') requested_status = payload.dict(exclude_unset=True).get('status')
if requested_status == Registration.StatusChoices.ATTENDED: if requested_status != Registration.StatusChoices.CANCELLED:
raise HttpError(403, "حضور رویداد فقط توسط برگزارکننده تأیید می‌شود.") raise HttpError(403, "وضعیت ثبت‌نام فقط پس از تأیید پرداخت توسط سامانه تغییر می‌کند.")
registration.status = requested_status with transaction.atomic():
registration.full_clean() Event.objects.select_for_update().get(pk=registration.event_id)
registration.save() registration = Registration.objects.select_for_update().get(pk=registration.pk)
payment = Payment.objects.select_for_update().filter(
registration=registration,
).order_by("-created_at", "-id").first()
if payment and payment.status == Payment.OrderStatusChoices.PAID:
raise HttpError(409, "پرداخت تأیید شده است؛ امکان لغو این ثبت‌نام وجود ندارد.")
if payment and payment.status in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
) and not registration.checkout_expires_at:
raise HttpError(409, "تراکنش ثبت‌نام در حال بررسی است؛ برای لغو با پشتیبانی تماس بگیرید.")
if registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at:
mark_checkout_cancelled(
registration,
Registration.CancellationReasonChoices.USER_CANCELLED,
)
queue_checkout_reconciliation(registration)
elif payment and payment.status in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
):
raise HttpError(409, "تراکنش ثبت‌نام در حال بررسی است؛ برای لغو با پشتیبانی تماس بگیرید.")
else:
registration.status = requested_status
registration.full_clean()
registration.save()
return registration return registration
@@ -501,8 +746,33 @@ def cancel_registration(request, registration_id: int):
"""Cancel a registration""" """Cancel a registration"""
user = request.auth user = request.auth
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False) registration = get_object_or_404(
registration.delete() Registration,
id=registration_id,
user=user,
is_deleted=False,
)
with transaction.atomic():
Event.objects.select_for_update().get(pk=registration.event_id)
registration = Registration.objects.select_for_update().get(pk=registration.pk)
payment = Payment.objects.select_for_update().filter(
registration=registration,
).order_by("-created_at", "-id").first()
if payment and payment.status == Payment.OrderStatusChoices.PAID:
raise HttpError(409, "پرداخت این ثبت‌نام تأیید شده است؛ برای پیگیری با پشتیبانی تماس بگیرید.")
if registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at:
mark_checkout_cancelled(
registration,
Registration.CancellationReasonChoices.USER_CANCELLED,
)
queue_checkout_reconciliation(registration)
elif payment and payment.status in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
):
raise HttpError(409, "تراکنش ثبت‌نام در حال بررسی است؛ برای لغو با پشتیبانی تماس بگیرید.")
else:
registration.delete()
return {"message": "ثبت‌نام شما لغو شد :("} return {"message": "ثبت‌نام شما لغو شد :("}
@events_router.get("/registerations/verify/{UUID:ticket_id}", response=RegisterationDetailSchema, auth=jwt_auth) @events_router.get("/registerations/verify/{UUID:ticket_id}", response=RegisterationDetailSchema, auth=jwt_auth)
@@ -523,6 +793,53 @@ def verify_my_registration(request, ticket_id: UUID):
def _my_registration_checkout_fields(registration, now):
payment = registration.payments.order_by("-created_at", "-id").first()
checkout_state = "none"
can_resume = False
if registration.status == Registration.StatusChoices.PENDING:
if payment and payment.status == Payment.OrderStatusChoices.PAID:
checkout_state = "unresolved"
elif registration.checkout_expires_at is None:
checkout_state = "legacy_pending"
elif registration.checkout_expires_at > now:
checkout_state = "active"
can_resume = not bool(payment and payment.verification_manual_review_at)
else:
checkout_state = "expired"
elif registration.status == Registration.StatusChoices.CANCELLED:
checkout_state = "cancelled"
if payment and (
payment.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING)
or (
payment.status == Payment.OrderStatusChoices.PAID
and payment.refund_status in (
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
)
)
):
checkout_state = "unresolved"
payment_status = None
if payment:
payment_status = {
Payment.OrderStatusChoices.INIT: "initiated",
Payment.OrderStatusChoices.PENDING: "pending",
Payment.OrderStatusChoices.PAID: "paid",
Payment.OrderStatusChoices.FAILED: "failed",
Payment.OrderStatusChoices.CANCELED: "canceled",
}[payment.status]
return {
"checkout_state": checkout_state,
"checkout_expires_at": registration.checkout_expires_at,
"payment_id": payment.id if payment else None,
"payment_status": payment_status,
"refund_status": payment.refund_status if payment else None,
"can_resume": can_resume,
}
@events_router.get("/my-registrations/paginated", response=PaginatedMyEventRegistrationsSchema, auth=jwt_auth) @events_router.get("/my-registrations/paginated", response=PaginatedMyEventRegistrationsSchema, auth=jwt_auth)
def my_registrations_paginated( def my_registrations_paginated(
request, request,
@@ -542,11 +859,14 @@ def my_registrations_paginated(
count = queryset.count() count = queryset.count()
total_pages = max(1, (count + page_size - 1) // page_size) total_pages = max(1, (count + page_size - 1) // page_size)
start = (page - 1) * page_size start = (page - 1) * page_size
now = timezone.now()
results = [ results = [
MyEventRegistrationOut( MyEventRegistrationOut(
id=registration.id, id=registration.id,
ticket_id=registration.ticket_id,
created_at=registration.created_at, created_at=registration.created_at,
status=registration.status, status=registration.status,
**_my_registration_checkout_fields(registration, now),
event=EventBriefSchema( event=EventBriefSchema(
id=registration.event.id, id=registration.event.id,
title=registration.event.title, title=registration.event.title,
@@ -582,12 +902,15 @@ def my_registrations(request):
.order_by("-created_at") .order_by("-created_at")
) )
out: List[MyEventRegistrationOut] = [] out: List[MyEventRegistrationOut] = []
now = timezone.now()
for r in qs: for r in qs:
out.append( out.append(
MyEventRegistrationOut( MyEventRegistrationOut(
id=r.id, id=r.id,
ticket_id=r.ticket_id,
created_at=r.created_at, created_at=r.created_at,
status=r.status, status=r.status,
**_my_registration_checkout_fields(r, now),
event=EventBriefSchema( event=EventBriefSchema(
id=r.event.id, id=r.event.id,
title=r.event.title, title=r.event.title,
@@ -604,12 +927,337 @@ def my_registrations(request):
@events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth) @events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth)
def is_registered(request, event_id: int): def is_registered(request, event_id: int):
exists = Registration.objects.filter( now = timezone.now()
user=request.auth, registration = (
event_id=event_id, Registration.objects.filter(
status=Registration.StatusChoices.CONFIRMED user=request.auth,
).exists() event_id=event_id,
return {"is_registered": exists} is_deleted=False,
)
.order_by("-registered_at", "-id")
.first()
)
if registration and registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at and registration.checkout_expires_at <= now:
expire_stale_registration_checkouts(now=now, event_id=event_id)
registration.refresh_from_db()
payment = None
if registration:
payment = registration.payments.order_by("-created_at", "-id").first()
is_confirmed = bool(
registration
and registration.status in [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]
)
checkout_state = "none"
can_resume = False
can_cancel = False
if is_confirmed:
checkout_state = "confirmed"
elif registration and registration.status == Registration.StatusChoices.PENDING:
if payment and payment.status == Payment.OrderStatusChoices.PAID:
checkout_state = "unresolved"
elif registration.checkout_expires_at is None:
checkout_state = "legacy_pending"
elif registration.checkout_expires_at > now:
checkout_state = "active"
can_resume = not bool(payment and payment.verification_manual_review_at)
can_cancel = True
else:
checkout_state = "expired"
elif registration and registration.status == Registration.StatusChoices.CANCELLED:
checkout_state = "cancelled"
if payment and (
payment.status in [Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING]
or (
payment.status == Payment.OrderStatusChoices.PAID
and payment.refund_status in [
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
]
)
):
checkout_state = "unresolved"
payment_status = None
if payment:
payment_status = {
Payment.OrderStatusChoices.INIT: "initiated",
Payment.OrderStatusChoices.PENDING: "pending",
Payment.OrderStatusChoices.PAID: "paid",
Payment.OrderStatusChoices.FAILED: "failed",
Payment.OrderStatusChoices.CANCELED: "canceled",
}[payment.status]
return {
"is_registered": is_confirmed,
"checkout_state": checkout_state,
"registration_id": registration.id if registration else None,
"registration_status": registration.status if registration else None,
"ticket_id": str(registration.ticket_id) if registration else None,
"checkout_expires_at": registration.checkout_expires_at if registration else None,
"payment_id": payment.id if payment else None,
"payment_status": payment_status,
"refund_status": payment.refund_status if payment else None,
"can_resume": can_resume,
"can_cancel": can_cancel,
}
def _registration_hub_state(registration, payment, now):
if payment:
if payment.refund_status == Payment.RefundStatusChoices.PENDING:
return "refund_pending"
if payment.refund_status == Payment.RefundStatusChoices.MANUAL_REVIEW:
return "refund_manual_review"
if payment.refund_status == Payment.RefundStatusChoices.SUCCEEDED:
return "refund_succeeded"
if payment.status == Payment.OrderStatusChoices.FAILED:
return "failed"
if payment.status == Payment.OrderStatusChoices.CANCELED:
return "cancelled"
if payment.verification_manual_review_at:
return "manual_review"
if payment.status == Payment.OrderStatusChoices.PAID:
if registration and registration.status in (
Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED,
):
return "paid_success"
# A charge without a confirmed registration must not be presented as
# a successful registration. Keep the saved transaction visible for review.
return "manual_review"
if payment.status in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
):
if registration and registration.status == Registration.StatusChoices.PENDING:
if registration.checkout_expires_at is None:
return "legacy_pending"
if registration.checkout_expires_at > now:
return "active_checkout"
return "verification_pending"
if registration:
if registration.status in (
Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED,
):
return "free_success" if (registration.final_price or 0) <= 0 else "manual_review"
if registration.status == Registration.StatusChoices.PENDING:
if registration.checkout_expires_at is None:
return "legacy_pending"
if registration.checkout_expires_at > now:
return "active_checkout"
return "expired"
if registration.status == Registration.StatusChoices.CANCELLED:
if registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED:
return "expired"
if registration.cancellation_reason == Registration.CancellationReasonChoices.PAYMENT_FAILED:
return "failed"
return "cancelled"
return "ready"
def _registration_hub_records(event, user, registration_id, payment_id, now):
if registration_id:
registration = Registration.objects.filter(
ticket_id=registration_id,
event=event,
user=user,
is_deleted=False,
).first()
if registration is None:
return None, None
payment = registration.payments.order_by("-created_at", "-id").first()
return registration, payment
if payment_id:
payment = Payment.objects.select_related("registration").filter(
pk=payment_id,
event=event,
user=user,
).first()
if payment is None:
return None, None
registration = payment.registration
if registration is not None and (
registration.event_id != event.id or registration.user_id != user.id
):
registration = None
return registration, payment
registrations = list(
Registration.objects.filter(event=event, user=user, is_deleted=False)
.prefetch_related("payments")
.order_by("-registered_at", "-id")
)
registration_payments = {
registration.id: registration.payments.order_by("-created_at", "-id").first()
for registration in registrations
}
payments = list(
Payment.objects.filter(event=event, user=user)
.select_related("registration")
.order_by("-created_at", "-id")
)
unresolved = []
for registration in registrations:
payment = registration_payments[registration.id]
state = _registration_hub_state(registration, payment, now)
if state in {
"active_checkout", "legacy_pending", "verification_pending", "manual_review",
"refund_pending", "refund_manual_review",
}:
unresolved.append((registration.registered_at, registration, payment))
linked_registration_ids = set(registration_payments)
for payment in payments:
if payment.registration_id in linked_registration_ids:
continue
state = _registration_hub_state(payment.registration, payment, now)
if state in {
"active_checkout", "legacy_pending", "verification_pending", "manual_review",
"refund_pending", "refund_manual_review",
}:
unresolved.append((payment.created_at, payment.registration, payment))
if unresolved:
_, registration, payment = max(unresolved, key=lambda item: item[0])
return registration, payment
if registrations:
registration = registrations[0]
return registration, registration_payments[registration.id]
if payments:
return payments[0].registration, payments[0]
return None, None
@events_router.get("/{str:slug}/registration/status", response=RegistrationHubOut, auth=jwt_auth)
def registration_hub_status(
request,
slug: str,
registration_id: Optional[UUID] = None,
payment_id: Optional[int] = None,
):
"""Return the authenticated user's authoritative registration/payment view for an event."""
if registration_id and payment_id:
raise HttpError(400, "فقط یکی از شناسه‌های ثبت‌نام یا پرداخت را ارسال کنید.")
event = Event.objects.filter(slug=slug, is_deleted=False).first()
if event is None:
lookup = {"pk": int(slug)} if slug.isdecimal() else {"slug": slug}
event = get_object_or_404(Event, is_deleted=False, **lookup)
now = timezone.now()
expire_stale_registration_checkouts(now=now, event_id=event.id)
registration, payment = _registration_hub_records(
event,
request.auth,
registration_id,
payment_id,
now,
)
if (registration_id or payment_id) and registration is None and payment is None:
raise HttpError(404, "وضعیت ثبت‌نام یا پرداخت پیدا نشد.")
state = _registration_hub_state(registration, payment, now)
unresolved = state in {
"active_checkout", "legacy_pending", "verification_pending", "manual_review",
"refund_pending", "refund_manual_review",
}
can_resume = bool(
state == "active_checkout"
and registration
and registration.status == Registration.StatusChoices.PENDING
and (payment is None or not payment.verification_manual_review_at)
)
can_cancel = bool(
state == "active_checkout"
and registration
and registration.status == Registration.StatusChoices.PENDING
and registration.checkout_expires_at
and registration.checkout_expires_at > now
)
registration_is_settled = bool(
registration
and registration.status in (
Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED,
)
)
registration_window_open = bool(
event.status == Event.StatusChoices.PUBLISHED
and event.is_registration_open
and event.has_available_slots
)
can_register = bool(registration_window_open and not unresolved and not registration_is_settled)
image_url = None
if event.featured_image and hasattr(event.featured_image, "url"):
image_url = request.build_absolute_uri(event.featured_image.url)
payment_status = None
if payment:
payment_status = {
Payment.OrderStatusChoices.INIT: "initiated",
Payment.OrderStatusChoices.PENDING: "pending",
Payment.OrderStatusChoices.PAID: "paid",
Payment.OrderStatusChoices.FAILED: "failed",
Payment.OrderStatusChoices.CANCELED: "canceled",
}[payment.status]
return {
"state": state,
"event": {
"id": event.id,
"title": event.title,
"slug": event.slug,
"description": event.description,
"event_type": event.event_type,
"address": event.address,
"location": event.location,
"start_time": event.start_time,
"end_time": event.end_time,
"registration_start_date": event.registration_start_date,
"registration_end_date": event.registration_end_date,
"capacity": event.capacity,
"price": event.price,
"status": event.status,
"registration_count": event.current_attendees_count,
"image_url": image_url,
"registration_success_markdown": event.registration_success_markdown,
},
"registration": {
"ticket_id": registration.ticket_id,
"status": registration.status,
"registered_at": registration.registered_at,
"checkout_started_at": registration.checkout_started_at,
"checkout_expires_at": registration.checkout_expires_at,
"cancellation_reason": registration.cancellation_reason,
"final_price": registration.final_price,
"discount_amount": registration.discount_amount,
} if registration else None,
"payment": {
"id": payment.id,
"status": payment_status,
"verification_state": payment.verification_state,
"refund_status": payment.refund_status,
"base_amount": payment.base_amount,
"discount_amount": payment.discount_amount,
"amount": payment.amount,
"ref_id": payment.ref_id,
"created_at": payment.created_at,
"gateway_paid_at": payment.gateway_paid_at,
} if payment else None,
"can_register": can_register,
"can_resume": can_resume,
"can_cancel": can_cancel,
}
@events_router.get("/{int:event_id}/admin-detail", response=EventAdminDetailSchema, auth=jwt_auth) @events_router.get("/{int:event_id}/admin-detail", response=EventAdminDetailSchema, auth=jwt_auth)
def event_admin_detail(request, event_id: int): def event_admin_detail(request, event_id: int):
user = request.auth user = request.auth

136
apps/events/checkout.py Normal file
View File

@@ -0,0 +1,136 @@
"""Registration checkout lifecycle rules shared by API views and workers."""
import logging
from datetime import timedelta
from django.db import transaction
from django.utils import timezone
from apps.events.models import Event, Registration
CHECKOUT_TIMEOUT = timedelta(minutes=15)
logger = logging.getLogger(__name__)
def _dispatch_reconciliation(payment_id):
from apps.payments.tasks import reconcile_payment
try:
reconcile_payment.apply_async(args=(payment_id,), countdown=0)
except Exception:
logger.exception("Unable to dispatch checkout payment %s for reconciliation", payment_id)
def mark_checkout_cancelled(registration, reason):
"""Cancel a pending checkout without deleting its historical registration."""
if registration.status != Registration.StatusChoices.PENDING:
return False
registration.status = Registration.StatusChoices.CANCELLED
registration.cancellation_reason = reason
registration.save(update_fields=[
"status",
"cancellation_reason",
"updated_at",
])
return True
def queue_checkout_reconciliation(registration):
"""Reconcile each unresolved payment after a checkout is cancelled."""
from apps.payments.models import Payment
payments = list(
Payment.objects.filter(
registration=registration,
status__in=[
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
],
).only("id", "authority")
)
for payment in payments:
if payment.authority:
transaction.on_commit(
lambda payment_id=payment.id: _dispatch_reconciliation(payment_id)
)
else:
# The payment request may have reached the gateway before its
# authority was persisted. Keep it visible for review until its
# request response arrives or an administrator resolves it.
Payment.objects.filter(
pk=payment.id,
status__in=[
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
],
authority__isnull=True,
).update(verification_manual_review_at=timezone.now())
@transaction.atomic
def expire_stale_registration_checkouts(*, now=None, event_id=None):
"""Release seats for new-format pending checkouts whose deadline passed.
Legacy registrations have a null checkout deadline and are intentionally
excluded from this timeout sweep.
"""
now = now or timezone.now()
candidates = Registration.objects.filter(
status=Registration.StatusChoices.PENDING,
is_deleted=False,
checkout_expires_at__lte=now,
)
if event_id is not None:
candidates = candidates.filter(event_id=event_id)
expired_count = 0
for registration_id in candidates.values_list("id", flat=True).iterator():
registration = (
Registration.objects.select_for_update()
.filter(pk=registration_id)
.first()
)
if registration is None or registration.status != Registration.StatusChoices.PENDING:
continue
reason = (
Registration.CancellationReasonChoices.EVENT_CANCELLED
if registration.event.status == Event.StatusChoices.CANCELLED
else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
)
if mark_checkout_cancelled(registration, reason):
expired_count += 1
queue_checkout_reconciliation(registration)
return expired_count
@transaction.atomic
def cancel_event_checkouts(event_id):
"""Cancel active new-format checkouts when an organizer cancels an event."""
event = Event.objects.select_for_update().filter(pk=event_id).first()
if event is None:
return 0
now = timezone.now()
registrations = (
Registration.objects.select_for_update()
.filter(
event=event,
is_deleted=False,
status=Registration.StatusChoices.PENDING,
checkout_expires_at__isnull=False,
)
.order_by("pk")
)
cancelled_count = 0
for registration in registrations:
if mark_checkout_cancelled(
registration,
Registration.CancellationReasonChoices.EVENT_CANCELLED,
):
cancelled_count += 1
queue_checkout_reconciliation(registration)
return cancelled_count

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.5 on 2026-09-30 19:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('events', '0015_registration_attendance_verification_source_and_more'),
]
operations = [
migrations.AddField(
model_name='registration',
name='cancellation_reason',
field=models.CharField(blank=True, choices=[('checkout_expired', 'Checkout expired'), ('user_cancelled', 'User cancelled checkout'), ('event_cancelled', 'Event cancelled'), ('payment_failed', 'Payment failed')], default='', editable=False, max_length=32),
),
migrations.AddField(
model_name='registration',
name='checkout_expires_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='registration',
name='checkout_started_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]

View File

@@ -108,8 +108,15 @@ class Event(BaseModel):
@property @property
def current_attendees_count(self): def current_attendees_count(self):
"""Count confirmed attendees""" """Count confirmed attendees and currently held paid-checkout seats."""
return self.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], is_deleted=False).count() now = timezone.now()
return self.registrations.filter(is_deleted=False).filter(
models.Q(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
| models.Q(
status=Registration.StatusChoices.PENDING,
checkout_expires_at__gt=now,
)
).count()
@property @property
def has_available_slots(self): def has_available_slots(self):
@@ -132,9 +139,24 @@ class Registration(BaseModel):
SYSTEM = 'system', 'System' SYSTEM = 'system', 'System'
HISTORICAL = 'historical', 'Historical backfill' HISTORICAL = 'historical', 'Historical backfill'
class CancellationReasonChoices(models.TextChoices):
CHECKOUT_EXPIRED = 'checkout_expired', 'Checkout expired'
USER_CANCELLED = 'user_cancelled', 'User cancelled checkout'
EVENT_CANCELLED = 'event_cancelled', 'Event cancelled'
PAYMENT_FAILED = 'payment_failed', 'Payment failed'
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations') event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations')
registered_at = models.DateTimeField(auto_now_add=True) registered_at = models.DateTimeField(auto_now_add=True)
checkout_started_at = models.DateTimeField(null=True, blank=True, editable=False)
checkout_expires_at = models.DateTimeField(null=True, blank=True, editable=False)
cancellation_reason = models.CharField(
max_length=32,
choices=CancellationReasonChoices.choices,
blank=True,
default='',
editable=False,
)
status = models.CharField(max_length=10, choices=StatusChoices.choices, status = models.CharField(max_length=10, choices=StatusChoices.choices,
default=StatusChoices.PENDING) default=StatusChoices.PENDING)
attendance_verified_at = models.DateTimeField(null=True, blank=True) attendance_verified_at = models.DateTimeField(null=True, blank=True)

View File

@@ -5,12 +5,19 @@ import logging
from celery import shared_task from celery import shared_task
from apps.events.models import Registration from apps.events.models import Registration
from apps.events.checkout import expire_stale_registration_checkouts
from apps.notifications.services import notify_user from apps.notifications.services import notify_user
from apps.users.tasks import send_critical_sms from apps.users.tasks import send_critical_sms
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@shared_task(name="apps.events.expire_stale_registration_checkouts")
def expire_stale_registration_checkouts_task():
"""Release seats held by new checkout attempts that reached their deadline."""
return expire_stale_registration_checkouts()
@shared_task(bind=True, max_retries=3) @shared_task(bind=True, max_retries=3)
def send_registration_confirmation_notification(self, registration_pk: str): def send_registration_confirmation_notification(self, registration_pk: str):
try: try:

View File

@@ -3,7 +3,7 @@ import io
import json import json
import tempfile import tempfile
import uuid import uuid
from datetime import timedelta from datetime import datetime, time as datetime_time, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from PIL import Image from PIL import Image
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
@@ -110,6 +110,115 @@ class TestEventsAPIIntegrationTests:
assert response.status_code == 200 assert response.status_code == 200
assert any((item['id'] == event.id for item in response.json())) assert any((item['id'] == event.id for item in response.json()))
def test_browse_events_returns_numbered_pages_and_global_price_ceiling(self):
self._create_event(title='Price ceiling 650k toman', price=6500000)
self._create_event(title='Price ceiling 4.3m toman', price=43000000)
for index in range(13):
self._create_event(title=f'Browse page event {index}', price=100000)
first_page = self.client.get('/api/events/browse/', {'page': 1})
second_page = self.client.get('/api/events/browse/', {'page': 2})
assert first_page.status_code == 200
assert first_page.json()['count'] == 17
assert first_page.json()['page'] == 1
assert first_page.json()['page_size'] == 12
assert first_page.json()['total_pages'] == 2
assert len(first_page.json()['results']) == 12
assert second_page.json()['page'] == 2
assert len(second_page.json()['results']) == 5
assert first_page.json()['price_ceiling'] == 5000000
assert 'address' in first_page.json()['results'][0]
def test_browse_events_upcoming_includes_future_events_that_cannot_register(self):
now = timezone.now()
open_event = self._create_event(
title='Browse open event',
start_time=now + timedelta(days=4),
end_time=now + timedelta(days=4, hours=2),
registration_start_date=now - timedelta(days=1),
registration_end_date=now + timedelta(days=2),
)
not_started = self._create_event(
title='Browse not started event',
start_time=now + timedelta(days=5),
end_time=now + timedelta(days=5, hours=2),
registration_start_date=now + timedelta(days=1),
)
closed = self._create_event(
title='Browse closed event',
start_time=now + timedelta(days=6),
end_time=now + timedelta(days=6, hours=2),
registration_end_date=now - timedelta(minutes=1),
)
full = self._create_event(
title='Browse full event',
start_time=now + timedelta(days=7),
end_time=now + timedelta(days=7, hours=2),
capacity=1,
)
self._create_registration(full, self.user, status=Registration.StatusChoices.CONFIRMED)
upcoming = self.client.get('/api/events/browse/', {'state': 'upcoming', 'search': 'Browse'}).json()
open_results = self.client.get('/api/events/browse/', {'state': 'open', 'search': 'Browse'}).json()
upcoming_ids = {item['id'] for item in upcoming['results']}
open_ids = {item['id'] for item in open_results['results']}
assert {not_started.id, closed.id, full.id}.issubset(upcoming_ids)
assert open_event.id not in upcoming_ids
assert open_ids == {open_event.id}
def test_browse_events_filters_start_dates_inclusive_and_combines_type_price_and_free(self):
selected_day = timezone.localdate()
local_timezone = timezone.get_current_timezone()
def at(day, hour, minute):
return timezone.make_aware(datetime.combine(day, datetime_time(hour, minute)), local_timezone)
start_day = self._create_event(
title='Date range probe free',
event_type=Event.TypeChoices.ONLINE,
price=0,
start_time=at(selected_day, 0, 0),
end_time=at(selected_day, 2, 0),
)
end_day = self._create_event(
title='Date range probe paid online',
event_type=Event.TypeChoices.ONLINE,
price=300000,
start_time=at(selected_day, 23, 59),
end_time=at(selected_day + timedelta(days=1), 1, 0),
)
self._create_event(
title='Date range probe paid onsite',
event_type=Event.TypeChoices.ON_SITE,
price=300000,
start_time=at(selected_day, 12, 0),
end_time=at(selected_day, 14, 0),
)
outside_day = self._create_event(
title='Date range probe outside',
event_type=Event.TypeChoices.ONLINE,
price=300000,
start_time=at(selected_day + timedelta(days=1), 0, 0),
end_time=at(selected_day + timedelta(days=1), 2, 0),
)
params = {
'search': 'Date range probe',
'date_from': selected_day.isoformat(),
'date_to': selected_day.isoformat(),
'event_type': Event.TypeChoices.ONLINE,
'price_min': 10000,
'price_max': 40000,
}
paid_results = self.client.get('/api/events/browse/', params).json()
free_results = self.client.get('/api/events/browse/', {**params, 'free_only': 'true'}).json()
assert {item['id'] for item in paid_results['results']} == {end_day.id}
assert {item['id'] for item in free_results['results']} == {start_day.id}
assert outside_day.id not in {item['id'] for item in paid_results['results']}
def test_list_events_handles_comma_status_parameter(self): def test_list_events_handles_comma_status_parameter(self):
event = self._create_event(title='Comma Event', status=Event.StatusChoices.PUBLISHED) event = self._create_event(title='Comma Event', status=Event.StatusChoices.PUBLISHED)
results = list_events(None, status=f'{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}', event_type=None, search=None, limit=10, offset=0) results = list_events(None, status=f'{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}', event_type=None, search=None, limit=10, offset=0)

View File

@@ -0,0 +1,480 @@
import json
import uuid
from datetime import timedelta
import pytest
from django.test import Client
from django.utils import timezone
from apps.events.models import Event, Registration
from apps.events.checkout import cancel_event_checkouts, expire_stale_registration_checkouts
from apps.payments.models import Payment
from apps.users.models import User
from core.authentication import create_jwt_token
@pytest.mark.django_db
@pytest.mark.integration
class TestRegistrationCheckout:
def setup_method(self):
self.client = Client()
suffix = uuid.uuid4().hex[:10]
self.user = User.objects.create_user(
username=f"checkout-{suffix}",
email=f"checkout-{suffix}@example.com",
mobile=f"0912{suffix[:7]}",
password="CheckoutPass!123",
)
self.token = create_jwt_token(self.user)
now = timezone.now()
self.event = Event.objects.create(
title="Checkout event",
description="Paid checkout behavior test",
start_time=now + timedelta(days=2),
end_time=now + timedelta(days=2, hours=1),
registration_start_date=now - timedelta(days=1),
registration_end_date=now + timedelta(days=1),
slug=f"checkout-{suffix}",
price=1250000,
capacity=1,
status=Event.StatusChoices.PUBLISHED,
)
def auth_headers(self):
return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
def create_pending_checkout(self, *, expires_at=None):
now = timezone.now()
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.PENDING,
final_price=self.event.price,
checkout_started_at=now,
checkout_expires_at=expires_at or now + timedelta(minutes=15),
)
payment = Payment.objects.create(
user=self.user,
event=self.event,
registration=registration,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority=f"AUTH-{uuid.uuid4().hex}",
idempotency_key=uuid.uuid4(),
)
return registration, payment
def test_active_checkout_reserves_capacity_but_can_be_resumed_by_its_owner(self):
registration, _ = self.create_pending_checkout()
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 1
resume_response = self.client.post(
f"/api/events/{self.event.id}/register",
data=json.dumps({}),
content_type="application/json",
**self.auth_headers(),
)
other = User.objects.create_user(
username=f"other-{uuid.uuid4().hex[:8]}",
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
password="CheckoutPass!123",
)
other_token = create_jwt_token(other)
other_response = self.client.post(
f"/api/events/{self.event.id}/register",
data=json.dumps({}),
content_type="application/json",
HTTP_AUTHORIZATION=f"Bearer {other_token}",
)
assert resume_response.status_code == 200
assert resume_response.json()["id"] == registration.id
assert other_response.status_code == 400
assert "ظرفیت" in other_response.json().get("detail", "")
def test_active_checkout_remains_resumable_after_registration_closes(self):
registration, _ = self.create_pending_checkout()
self.event.registration_end_date = timezone.now() - timedelta(minutes=1)
self.event.save(update_fields=["registration_end_date"])
response = self.client.post(
f"/api/events/{self.event.id}/register",
data=json.dumps({}),
content_type="application/json",
**self.auth_headers(),
)
assert response.status_code == 200
assert response.json()["id"] == registration.id
def test_registration_status_returns_resumable_payment_without_changing_legacy_flag(self):
registration, payment = self.create_pending_checkout()
response = self.client.get(
f"/api/events/{self.event.id}/is-registered",
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["is_registered"] is False
assert payload["checkout_state"] == "active"
assert payload["can_resume"] is True
assert payload["registration_id"] == registration.id
assert payload["payment_id"] == payment.id
expected_expiry = registration.checkout_expires_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
assert payload["checkout_expires_at"] == expected_expiry
def test_registration_hub_returns_ready_state_for_user_without_registration(self):
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "ready"
assert payload["event"]["slug"] == self.event.slug
assert payload["registration"] is None
assert payload["payment"] is None
assert payload["can_register"] is True
def test_registration_hub_requires_authentication(self):
response = self.client.get(f"/api/events/{self.event.slug}/registration/status")
assert response.status_code == 401
def test_registration_hub_does_not_trust_a_status_query_parameter(self):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=self.event.price,
)
Payment.objects.create(
user=self.user,
event=self.event,
registration=registration,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
authority=f"AUTH-{uuid.uuid4().hex}",
)
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status?status=failed",
**self.auth_headers(),
)
assert response.status_code == 200
assert response.json()["state"] == "paid_success"
def test_registration_hub_shows_confirmed_free_registration(self):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=0,
)
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"registration_id": str(registration.ticket_id)},
**self.auth_headers(),
)
assert response.status_code == 200
assert response.json()["state"] == "free_success"
def test_registration_hub_returns_404_for_foreign_payment(self):
other = User.objects.create_user(
username=f"other-{uuid.uuid4().hex[:8]}",
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
password="CheckoutPass!123",
)
payment = Payment.objects.create(
user=other,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority=f"AUTH-{uuid.uuid4().hex}",
)
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"payment_id": payment.id},
**self.auth_headers(),
)
assert response.status_code == 404
def test_registration_hub_uses_registration_ticket_uuid_and_exposes_active_payment(self):
registration, payment = self.create_pending_checkout()
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"registration_id": str(registration.ticket_id)},
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "active_checkout"
assert payload["registration"]["ticket_id"] == str(registration.ticket_id)
assert payload["payment"]["id"] == payment.id
assert payload["can_resume"] is True
assert payload["can_cancel"] is True
def test_registration_hub_hides_another_users_registration(self):
other = User.objects.create_user(
username=f"other-{uuid.uuid4().hex[:8]}",
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
password="CheckoutPass!123",
)
registration = Registration.objects.create(
event=self.event,
user=other,
status=Registration.StatusChoices.CONFIRMED,
final_price=self.event.price,
)
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"registration_id": str(registration.ticket_id)},
**self.auth_headers(),
)
assert response.status_code == 404
def test_registration_hub_accepts_legacy_payment_without_linked_registration(self):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PENDING,
authority=f"AUTH-{uuid.uuid4().hex}",
)
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"payment_id": payment.id},
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "verification_pending"
assert payload["registration"] is None
assert payload["payment"]["id"] == payment.id
assert payload["can_register"] is False
def test_registration_hub_uses_event_id_for_legacy_events_without_a_slug(self):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=self.event.price,
confirmation_notification_sent_at=timezone.now(),
)
payment = Payment.objects.create(
user=self.user,
event=self.event,
registration=registration,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
authority=f"AUTH-{uuid.uuid4().hex}",
)
Event.objects.filter(pk=self.event.pk).update(slug="")
response = self.client.get(
f"/api/events/{self.event.pk}/registration/status",
{"payment_id": payment.id},
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "paid_success"
assert payload["event"]["slug"] == ""
assert payload["payment"]["id"] == payment.id
def test_registration_hub_prefers_active_attempt_over_newer_settled_attempt(self):
confirmed = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CONFIRMED,
final_price=self.event.price,
)
Payment.objects.create(
user=self.user,
event=self.event,
registration=confirmed,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
authority=f"AUTH-{uuid.uuid4().hex}",
)
active, active_payment = self.create_pending_checkout()
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "active_checkout"
assert payload["registration"]["ticket_id"] == str(active.ticket_id)
assert payload["payment"]["id"] == active_payment.id
def test_registration_hub_returns_manual_review_and_refund_state_from_saved_records(self):
registration, payment = self.create_pending_checkout()
payment.verification_manual_review_at = timezone.now()
payment.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
payment.save(update_fields=["verification_manual_review_at", "refund_status"])
response = self.client.get(
f"/api/events/{self.event.slug}/registration/status",
{"payment_id": payment.id},
**self.auth_headers(),
)
assert response.status_code == 200
payload = response.json()
assert payload["state"] == "refund_manual_review"
assert payload["registration"]["ticket_id"] == str(registration.ticket_id)
assert payload["payment"]["verification_state"] == "manual_review"
assert payload["payment"]["refund_status"] == "manual_review"
def test_expired_unresolved_checkout_releases_registration_and_blocks_duplicate_payment(self):
expired_at = timezone.now() - timedelta(seconds=1)
registration, payment = self.create_pending_checkout(expires_at=expired_at)
response = self.client.post(
"/api/payments/create",
data=json.dumps({
"event_id": self.event.id,
"description": "Retry after abandoned checkout",
"idempotency_key": str(uuid.uuid4()),
}),
content_type="application/json",
**self.auth_headers(),
)
registration.refresh_from_db()
payment.refresh_from_db()
assert response.status_code == 409
assert registration.status == Registration.StatusChoices.CANCELLED
assert registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
assert payment.status == Payment.OrderStatusChoices.PENDING
assert Payment.objects.filter(event=self.event, user=self.user).count() == 1
def test_expiry_sweep_cancels_only_new_expired_checkouts_and_releases_the_seat(self):
expired_at = timezone.now() - timedelta(seconds=1)
registration, payment = self.create_pending_checkout(expires_at=expired_at)
legacy = Registration.objects.create(
event=self.event,
user=User.objects.create_user(
username=f"legacy-{uuid.uuid4().hex[:8]}",
email=f"legacy-{uuid.uuid4().hex[:8]}@example.com",
mobile=f"0914{uuid.uuid4().int % 10**7:07d}",
password="CheckoutPass!123",
),
status=Registration.StatusChoices.PENDING,
final_price=self.event.price,
)
count = expire_stale_registration_checkouts()
registration.refresh_from_db()
payment.refresh_from_db()
legacy.refresh_from_db()
assert count == 1
assert registration.status == Registration.StatusChoices.CANCELLED
assert registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
assert payment.status == Payment.OrderStatusChoices.PENDING
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
assert legacy.status == Registration.StatusChoices.PENDING
def test_user_can_cancel_active_checkout_without_deleting_registration(self, mocker):
registration, payment = self.create_pending_checkout()
mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
response = self.client.post(
f"/api/events/{self.event.id}/checkout/cancel",
**self.auth_headers(),
)
registration.refresh_from_db()
payment.refresh_from_db()
assert response.status_code == 200
assert registration.status == Registration.StatusChoices.CANCELLED
assert registration.cancellation_reason == Registration.CancellationReasonChoices.USER_CANCELLED
assert Registration.objects.filter(pk=registration.pk).exists()
assert payment.status == Payment.OrderStatusChoices.PENDING
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
def test_new_checkout_is_blocked_while_late_payment_refund_is_unresolved(self):
registration = Registration.objects.create(
event=self.event,
user=self.user,
status=Registration.StatusChoices.CANCELLED,
cancellation_reason=Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
checkout_started_at=timezone.now() - timedelta(minutes=20),
checkout_expires_at=timezone.now() - timedelta(minutes=5),
final_price=self.event.price,
)
Payment.objects.create(
user=self.user,
event=self.event,
registration=registration,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
refund_status=Payment.RefundStatusChoices.PENDING,
authority=f"AUTH-{uuid.uuid4().hex}",
)
register_response = self.client.post(
f"/api/events/{self.event.id}/register",
data=json.dumps({}),
content_type="application/json",
**self.auth_headers(),
)
payment_response = self.client.post(
"/api/payments/create",
data=json.dumps({
"event_id": self.event.id,
"description": "Do not charge twice while refund is unresolved",
"idempotency_key": str(uuid.uuid4()),
}),
content_type="application/json",
**self.auth_headers(),
)
assert register_response.status_code == 409
assert payment_response.status_code == 409
assert Registration.objects.filter(event=self.event, user=self.user).count() == 1
def test_event_cancellation_ends_checkouts_and_queues_payment_reconciliation(self, mocker):
registration, payment = self.create_pending_checkout()
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
mocker.patch("apps.events.checkout.transaction.on_commit", side_effect=lambda callback: callback())
self.event.status = Event.StatusChoices.CANCELLED
self.event.save(update_fields=["status"])
cancelled = cancel_event_checkouts(self.event.id)
registration.refresh_from_db()
assert cancelled == 1
assert registration.status == Registration.StatusChoices.CANCELLED
assert registration.cancellation_reason == Registration.CancellationReasonChoices.EVENT_CANCELLED
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
enqueue.assert_called_once_with(args=(payment.id,), countdown=0)

View File

@@ -22,13 +22,21 @@ def test_event_admin_formats_empty_price_and_capacity():
def test_event_admin_status_actions_update_selection(mocker): def test_event_admin_status_actions_update_selection(mocker):
model_admin = EventAdmin(Event, admin.site) model_admin = EventAdmin(Event, admin.site)
mocker.patch.object(model_admin, "message_user") mocker.patch.object(model_admin, "message_user")
cancel_checkouts = mocker.patch("apps.events.admin.cancel_event_checkouts")
queryset = mocker.Mock() queryset = mocker.Mock()
queryset.count.return_value = 2 queryset.count.return_value = 2
for action, status in ( for action, status in (
(model_admin.make_published, Event.StatusChoices.PUBLISHED), (model_admin.make_published, Event.StatusChoices.PUBLISHED),
(model_admin.make_draft, Event.StatusChoices.DRAFT), (model_admin.make_draft, Event.StatusChoices.DRAFT),
(model_admin.make_cancelled, Event.StatusChoices.CANCELLED),
(model_admin.make_completed, Event.StatusChoices.COMPLETED), (model_admin.make_completed, Event.StatusChoices.COMPLETED),
): ):
action(SimpleNamespace(), queryset) action(SimpleNamespace(), queryset)
queryset.update.assert_called_with(status=status) queryset.update.assert_called_with(status=status)
published = SimpleNamespace(status=Event.StatusChoices.PUBLISHED, pk=10, save=mocker.Mock())
already_cancelled = SimpleNamespace(status=Event.StatusChoices.CANCELLED, pk=11, save=mocker.Mock())
model_admin.make_cancelled(SimpleNamespace(), [published, already_cancelled])
published.save.assert_called_once_with(update_fields=["status"])
already_cancelled.save.assert_not_called()
cancel_checkouts.assert_called_once_with(published.pk)

View File

@@ -52,18 +52,26 @@ class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin):
list_display = ( list_display = (
'id', 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount', 'id', 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount',
'status', 'created_at', 'verified_at', 'is_deleted' 'status', 'refund_status', 'refund_attempts', 'verification_attempts',
'verification_manual_review_at',
'created_at', 'verified_at', 'is_deleted'
) )
list_filter = ( list_filter = (
'status', 'event', 'status', 'refund_status', 'event', 'verification_manual_review_at',
SoftDeleteListFilter, SoftDeleteListFilter,
) )
search_fields = ( search_fields = (
'user__email', 'authority', 'ref_id', 'discount_code__code' 'user__email', 'authority', 'ref_id', 'discount_code__code'
) )
readonly_fields = ( readonly_fields = (
'user', 'event', 'base_amount', 'discount_code', 'discount_code', 'discount_amount', 'amount', 'authority', 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount', 'authority',
'status', 'ref_id', 'card_pan', 'card_hash', 'created_at', 'updated_at', 'deleted_at' 'status', 'ref_id', 'card_pan', 'card_hash', 'verified_at', 'gateway_paid_at',
'verification_attempts',
'verification_last_attempt_at', 'verification_next_attempt_at', 'verification_lease_until',
'verification_dispatch_expires_at', 'verification_manual_review_at', 'idempotency_key',
'refund_status', 'refund_attempts', 'refund_last_attempt_at', 'refund_next_attempt_at',
'refund_reference', 'refund_last_error',
'created_at', 'updated_at', 'deleted_at'
) )
fieldsets = ( fieldsets = (
@@ -74,7 +82,20 @@ class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin):
'fields': ('base_amount', 'discount_code', 'discount_amount', 'amount') 'fields': ('base_amount', 'discount_code', 'discount_amount', 'amount')
}), }),
('Others', { ('Others', {
'fields': ('authority', 'ref_id', 'card_pan', 'card_hash') 'fields': ('authority', 'ref_id', 'card_pan', 'card_hash', 'verified_at', 'gateway_paid_at', 'idempotency_key')
}),
('Verification and Support Review', {
'fields': (
'verification_attempts', 'verification_last_attempt_at',
'verification_next_attempt_at', 'verification_lease_until',
'verification_dispatch_expires_at', 'verification_manual_review_at',
)
}),
('Refund and Reversal Review', {
'fields': (
'refund_status', 'refund_attempts', 'refund_last_attempt_at',
'refund_next_attempt_at', 'refund_reference', 'refund_last_error',
)
}), }),
('Soft Delete', { ('Soft Delete', {
'fields': ('is_deleted', 'deleted_at'), 'fields': ('is_deleted', 'deleted_at'),

View File

@@ -71,21 +71,44 @@ class PaymentDetailOut(Schema):
amount: int amount: int
status: str status: str
verified_at: str | None = None verified_at: str | None = None
gateway_paid_at: str | None = None
refund_status: Literal["none", "pending", "succeeded", "manual_review"] = "none"
refund_attempts: int = 0
refund_last_attempt_at: str | None = None
refund_next_attempt_at: str | None = None
refund_reference: str | None = None
refund_last_error: str | None = None
verification_state: Literal[
"awaiting_verification",
"checking",
"retry_scheduled",
"manual_review",
"verified",
"failed",
"canceled",
]
verification_attempts: int
last_verification_at: str | None = None
next_verification_at: str | None = None
manual_review_at: str | None = None
event: dict event: dict
class UserPaymentEventSchema(Schema): class UserPaymentEventSchema(Schema):
id: int
title: str title: str
slug: str slug: str
image_url: Optional[str] = None image_url: Optional[str] = None
class UserPaymentSchema(Schema): class UserPaymentSchema(Schema):
payment_id: int
event: UserPaymentEventSchema event: UserPaymentEventSchema
base_amount: int base_amount: int
discount_amount: int discount_amount: int
amount: int amount: int
status: Literal["initiated", "pending", "paid", "failed", "canceled"] status: Literal["initiated", "pending", "paid", "failed", "canceled"]
refund_status: Literal["none", "pending", "succeeded", "manual_review"] = "none"
created_at: datetime created_at: datetime
paid_at: Optional[datetime] = None paid_at: Optional[datetime] = None

View File

@@ -1,5 +1,7 @@
import logging import logging
from datetime import datetime
from uuid import uuid4 from uuid import uuid4
from urllib.parse import quote
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect, get_object_or_404 from django.shortcuts import redirect, get_object_or_404
@@ -14,8 +16,18 @@ import requests
from apps.payments.models import Payment, DiscountCode from apps.payments.models import Payment, DiscountCode
from apps.events.models import Event, Registration 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.notifications.services import notify_user
from apps.payments.services import PaymentVerificationUncertain, request_payment_verification 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.payments.tasks import reconcile_payment
from apps.users.tasks import send_critical_sms from apps.users.tasks import send_critical_sms
from core.authentication import jwt_auth from core.authentication import jwt_auth
@@ -55,7 +67,9 @@ def my_payments(request):
def _user_payment_payload(request, payment, status_names): def _user_payment_payload(request, payment, status_names):
return { return {
"payment_id": payment.id,
"event": { "event": {
"id": payment.event_id,
"title": payment.event.title, "title": payment.event.title,
"slug": payment.event.slug, "slug": payment.event.slug,
"image_url": request.build_absolute_uri(payment.event.featured_image.url) "image_url": request.build_absolute_uri(payment.event.featured_image.url)
@@ -66,6 +80,7 @@ def _user_payment_payload(request, payment, status_names):
"discount_amount": payment.discount_amount, "discount_amount": payment.discount_amount,
"amount": payment.amount, "amount": payment.amount,
"status": status_names[payment.status], "status": status_names[payment.status],
"refund_status": payment.refund_status,
"created_at": payment.created_at, "created_at": payment.created_at,
"paid_at": payment.verified_at, "paid_at": payment.verified_at,
} }
@@ -270,9 +285,38 @@ def _payment_checkout_payload(payment: Payment):
} }
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) @payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
def create_payment(request, payload: CreatePaymentIn): def create_payment(request, payload: CreatePaymentIn):
idempotency_key = payload.idempotency_key or uuid4() 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 # Serialize checkout creation for this event so concurrent taps cannot
# both create a new active attempt for the same user/event pair. # both create a new active attempt for the same user/event pair.
@@ -282,19 +326,48 @@ def create_payment(request, payload: CreatePaymentIn):
pk=payload.event_id, pk=payload.event_id,
is_deleted=False, is_deleted=False,
) )
if event.status == Event.StatusChoices.CANCELLED:
raise HttpError(400, "این رویداد لغو شده است و امکان پرداخت ندارد.")
keyed_payment = Payment.objects.filter( keyed_payment = Payment.objects.filter(
user=request.auth, user=request.auth,
event=event, event=event,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
).select_related("event").first() ).select_related("event", "registration").first()
if keyed_payment: if keyed_payment:
_ensure_checkout_attempt_can_be_reused(
keyed_payment,
now=now,
allow_review_replay=True,
)
return _payment_checkout_payload(keyed_payment) 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( if Payment.objects.filter(
status=Payment.OrderStatusChoices.PAID, status=Payment.OrderStatusChoices.PAID,
user=request.auth, user=request.auth,
event=event, event=event,
refund_status__in=[
Payment.RefundStatusChoices.NONE,
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
],
).exists(): ).exists():
raise HttpError(400, "You have already registered in this event") raise HttpError(400, "You have already registered in this event")
@@ -304,10 +377,12 @@ def create_payment(request, payload: CreatePaymentIn):
event=event, event=event,
status__in=[Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING], status__in=[Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING],
) )
.select_related("registration")
.order_by("-created_at", "-id") .order_by("-created_at", "-id")
.first() .first()
) )
if active_payment: if active_payment:
_ensure_checkout_attempt_can_be_reused(active_payment, now=now)
return _payment_checkout_payload(active_payment) return _payment_checkout_payload(active_payment)
registration = ( registration = (
@@ -316,11 +391,29 @@ def create_payment(request, payload: CreatePaymentIn):
.order_by("-registered_at") .order_by("-registered_at")
.first() .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: if not registration or registration.status == Registration.StatusChoices.CANCELLED:
registration = Registration.objects.create( registration = Registration.objects.create(
event=event, event=event,
user=request.auth, user=request.auth,
status=Registration.StatusChoices.PENDING, status=Registration.StatusChoices.PENDING,
checkout_started_at=now,
checkout_expires_at=now + CHECKOUT_TIMEOUT,
final_price=event.price, final_price=event.price,
) )
elif registration.final_price is None: elif registration.final_price is None:
@@ -331,7 +424,17 @@ def create_payment(request, payload: CreatePaymentIn):
discount_amount = 0 discount_amount = 0
final_amount = event.price final_amount = event.price
if payload.discount_code: 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( discount_code = DiscountCode.objects.filter(
code=payload.discount_code, code=payload.discount_code,
applicable_events=event, applicable_events=event,
@@ -421,7 +524,8 @@ def create_payment(request, payload: CreatePaymentIn):
pay.id, pay.id,
) )
pay.status = Payment.OrderStatusChoices.PENDING pay.status = Payment.OrderStatusChoices.PENDING
pay.save(update_fields=["status"]) pay.verification_manual_review_at = timezone.now()
pay.save(update_fields=["status", "verification_manual_review_at"])
return _payment_checkout_payload(pay) return _payment_checkout_payload(pay)
data = jd.get("data") if isinstance(jd, dict) else None data = jd.get("data") if isinstance(jd, dict) else None
@@ -441,31 +545,122 @@ def create_payment(request, payload: CreatePaymentIn):
pay.id, pay.id,
) )
pay.status = Payment.OrderStatusChoices.PENDING pay.status = Payment.OrderStatusChoices.PENDING
pay.save(update_fields=["status"]) pay.verification_manual_review_at = timezone.now()
pay.save(update_fields=["status", "verification_manual_review_at"])
return _payment_checkout_payload(pay) return _payment_checkout_payload(pay)
pay.authority = authority with transaction.atomic():
pay.status = Payment.OrderStatusChoices.PENDING event = Event.objects.select_for_update().get(pk=pay.event_id)
pay.save(update_fields=["authority","status"]) 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) return _payment_checkout_payload(pay)
def _callback_redirect(payment: Payment, status: str): def _frontend_root_url():
url = ( root = (getattr(settings, "FRONTEND_ROOT", "") or settings.FRONTEND_CALLBACK_URL).rstrip("/")
f"{settings.FRONTEND_CALLBACK_URL}?status={status}" return root
f"&event_id={payment.event_id}&payment_id={payment.id}"
)
if status == "success" and payment.ref_id: def _callback_redirect(payment: Payment, _status: str):
url += f"&ref_id={payment.ref_id}" """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) 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): def _mark_payment_paid(payment_id: int, verification_data: dict | None = None):
"""Persist a verified payment and its registration as one idempotent transition.""" """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(): 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 = (
Payment.objects.select_for_update() Payment.objects.select_for_update()
.select_related("event", "user")
.get(pk=payment_id) .get(pk=payment_id)
) )
was_already_verified = ( was_already_verified = (
@@ -483,27 +678,63 @@ def _mark_payment_paid(payment_id: int, verification_data: dict | None = None):
payment.card_hash = verification_data.get("card_hash") payment.card_hash = verification_data.get("card_hash")
payment.verified_at = timezone.now() payment.verified_at = timezone.now()
updates.extend(["ref_id", "card_pan", "card_hash", "verified_at"]) 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: if updates:
payment.save(update_fields=updates) payment.save(update_fields=updates)
registration = None now = timezone.now()
if payment.registration_id: if registration and registration.status == Registration.StatusChoices.PENDING:
registration = Registration.objects.select_for_update().filter( if event.status == Event.StatusChoices.CANCELLED:
pk=payment.registration_id registration.status = Registration.StatusChoices.CANCELLED
).first() registration.cancellation_reason = Registration.CancellationReasonChoices.EVENT_CANCELLED
if registration is None: registration.save(update_fields=["status", "cancellation_reason", "updated_at"])
registration = Registration.objects.select_for_update().filter( elif (
user_id=payment.user_id, registration.checkout_expires_at is not None
event_id=payment.event_id, and registration.checkout_expires_at <= now
status=Registration.StatusChoices.PENDING, and not registration.cancellation_reason
).first() ):
if registration and registration.status != Registration.StatusChoices.CONFIRMED: registration.status = Registration.StatusChoices.CANCELLED
registration.status = Registration.StatusChoices.CONFIRMED registration.cancellation_reason = Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
registration_updates = ["status"] 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: if registration.final_price is None:
registration.final_price = payment.amount registration.final_price = payment.amount
registration_updates.append("final_price") registration_updates.append("final_price")
registration.save(update_fields=registration_updates) 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 return payment, not was_already_verified
@@ -516,7 +747,11 @@ def _mark_payment_pending(payment_id: int):
if payment.status != Payment.OrderStatusChoices.PAID: if payment.status != Payment.OrderStatusChoices.PAID:
payment.status = Payment.OrderStatusChoices.PAID payment.status = Payment.OrderStatusChoices.PAID
payment.save(update_fields=["status"]) payment.save(update_fields=["status"])
elif payment.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.CANCELED): elif payment.status in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.FAILED,
Payment.OrderStatusChoices.CANCELED,
):
payment.status = Payment.OrderStatusChoices.PENDING payment.status = Payment.OrderStatusChoices.PENDING
payment.save(update_fields=["status"]) payment.save(update_fields=["status"])
return payment return payment
@@ -524,10 +759,16 @@ def _mark_payment_pending(payment_id: int):
def _mark_payment_unsuccessful(payment_id: int, target_status: int): def _mark_payment_unsuccessful(payment_id: int, target_status: int):
"""Apply a failure only while the payment has not been verified successfully.""" """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(): 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 = (
Payment.objects.select_for_update() Payment.objects.select_for_update()
.select_related("event", "user")
.get(pk=payment_id) .get(pk=payment_id)
) )
if ( if (
@@ -551,7 +792,17 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
transitioned = False transitioned = False
else: else:
payment.status = target_status payment.status = target_status
payment.save(update_fields=["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 = ( result = (
"canceled" "canceled"
if target_status == Payment.OrderStatusChoices.CANCELED if target_status == Payment.OrderStatusChoices.CANCELED
@@ -559,13 +810,7 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
) )
transitioned = True transitioned = True
registration = None if registration is 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( registration = Registration.objects.select_for_update().filter(
user_id=payment.user_id, user_id=payment.user_id,
event_id=payment.event_id, event_id=payment.event_id,
@@ -589,7 +834,10 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
Registration.objects.filter( Registration.objects.filter(
pk=registration.pk, pk=registration.pk,
status=Registration.StatusChoices.PENDING, status=Registration.StatusChoices.PENDING,
).update(status=Registration.StatusChoices.CANCELLED) ).update(
status=Registration.StatusChoices.CANCELLED,
cancellation_reason=Registration.CancellationReasonChoices.PAYMENT_FAILED,
)
return payment, result, transitioned return payment, result, transitioned
@@ -597,49 +845,96 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
@payments_router.get("callback") @payments_router.get("callback")
def callback(request, Authority: str | None = None, Status: str | None = None): def callback(request, Authority: str | None = None, Status: str | None = None):
if not Authority: if not Authority:
raise HttpError(400, "Missing Authority") return _callback_unknown_redirect()
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first() pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
if not pay: if not pay:
raise HttpError(404, "Payment not found") 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: if pay.status == Payment.OrderStatusChoices.PAID or pay.verified_at is not None:
pay, _ = _mark_payment_paid(pay.id) pay, _ = _mark_payment_paid(pay.id)
return _callback_redirect(pay, "success") 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: try:
vcode, data = request_payment_verification(pay) vcode, data = request_payment_verification(pay)
except PaymentVerificationUncertain: except PaymentVerificationUncertain:
pay = _mark_payment_pending(pay.id) pay = _mark_payment_pending(pay.id)
if pay.status == Payment.OrderStatusChoices.PENDING and pay.authority: delay = schedule_payment_verification_retry(pay.id)
pay.refresh_from_db()
if delay is not None and pay.status == Payment.OrderStatusChoices.PENDING:
try: try:
reconcile_payment.apply_async(args=(pay.id,), countdown=60) reconcile_payment.apply_async(args=(pay.id,), countdown=delay)
except Exception: except Exception:
logger.exception( logger.exception(
"Unable to queue payment verification retry for payment %s; it remains pending for manual review", "Unable to queue payment verification retry for payment %s; the recovery sweep will retry it",
pay.id, 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)) return _callback_redirect(pay, _payment_status_name(pay))
if vcode in (100, 101): if vcode in (100, 101):
pay, newly_verified = _mark_payment_paid(pay.id, data) pay, newly_verified = _mark_payment_paid(pay.id, data)
if newly_verified: if newly_verified:
refund_pending = pay.refund_status in (
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
)
_notify_payment_status( _notify_payment_status(
pay, pay,
title=f"پرداخت رویداد «{pay.event.title}» تأیید شد", title=(
message="پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است.", f"پرداخت رویداد «{pay.event.title}» تأیید شد؛ بازگشت وجه در حال پیگیری است"
level="success", 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") return _callback_redirect(pay, "success")
pay, result, transitioned = _mark_payment_unsuccessful(pay.id, Payment.OrderStatusChoices.FAILED) 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: if transitioned:
canceled = result == "canceled"
_notify_payment_status( _notify_payment_status(
pay, pay,
title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود", title=(
message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.", f"پرداخت رویداد «{pay.event.title}» لغو شد"
level="error", if canceled
else f"پرداخت رویداد «{pay.event.title}» ناموفق بود"
),
message=(
"پرداخت تکمیل نشد و ثبت‌نام نهایی نشد. برای شروع دوباره به صفحه رویداد برگردید."
if canceled
else "تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید."
),
level="warning" if canceled else "error",
) )
return _callback_redirect(pay, result) return _callback_redirect(pay, result)
@@ -666,6 +961,34 @@ def _payment_detail_payload(request, payment: Payment):
"amount": payment.amount, "amount": payment.amount,
"status": _payment_status_name(payment), "status": _payment_status_name(payment),
"verified_at": payment.verified_at.isoformat() if payment.verified_at else None, "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": { "event": {
"id": event.id, "id": event.id,
"title": event.title, "title": event.title,
@@ -676,9 +999,13 @@ def _payment_detail_payload(request, payment: Payment):
} }
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut) @payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut, auth=jwt_auth)
def payment_by_ref(request, ref_id: str): def payment_by_ref(request, ref_id: str):
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id) pay = get_object_or_404(
Payment.objects.select_related("event"),
ref_id=ref_id,
user=request.auth,
)
return _payment_detail_payload(request, pay) return _payment_detail_payload(request, pay)
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth) @payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.5 on 2026-09-30 18:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0004_payment_idempotency_key_and_more'),
]
operations = [
migrations.AddField(
model_name='payment',
name='verification_attempts',
field=models.PositiveSmallIntegerField(default=0, editable=False),
),
migrations.AddField(
model_name='payment',
name='verification_dispatch_expires_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='verification_last_attempt_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='verification_lease_until',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='verification_manual_review_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='verification_next_attempt_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 5.2.5 on 2026-09-30 19:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0005_payment_verification_attempts_and_more'),
]
operations = [
migrations.AddField(
model_name='payment',
name='refund_attempts',
field=models.PositiveSmallIntegerField(default=0, editable=False),
),
migrations.AddField(
model_name='payment',
name='refund_last_attempt_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='refund_last_error',
field=models.CharField(blank=True, default='', editable=False, max_length=255),
),
migrations.AddField(
model_name='payment',
name='refund_next_attempt_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
migrations.AddField(
model_name='payment',
name='refund_reference',
field=models.CharField(blank=True, editable=False, max_length=64, null=True),
),
migrations.AddField(
model_name='payment',
name='refund_status',
field=models.CharField(choices=[('none', 'No refund required'), ('pending', 'Refund pending'), ('succeeded', 'Refund completed'), ('manual_review', 'Refund needs review')], default='none', editable=False, max_length=20),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2026-10-01 04:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0006_payment_refund_attempts_and_more'),
]
operations = [
migrations.AddField(
model_name='payment',
name='gateway_paid_at',
field=models.DateTimeField(blank=True, editable=False, null=True),
),
]

View File

@@ -81,6 +81,12 @@ class Payment(BaseModel):
FAILED = 3, "Failed" FAILED = 3, "Failed"
CANCELED = 4, "Canceled" CANCELED = 4, "Canceled"
class RefundStatusChoices(models.TextChoices):
NONE = "none", "No refund required"
PENDING = "pending", "Refund pending"
SUCCEEDED = "succeeded", "Refund completed"
MANUAL_REVIEW = "manual_review", "Refund needs review"
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payments', editable=False) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payments', editable=False)
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='payments', editable=False) event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='payments', editable=False)
@@ -104,6 +110,24 @@ class Payment(BaseModel):
card_pan = models.CharField(max_length=32, null=True, blank=True, editable=False) card_pan = models.CharField(max_length=32, null=True, blank=True, editable=False)
card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False) card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False)
verified_at = models.DateTimeField(null=True, blank=True, editable=False) verified_at = models.DateTimeField(null=True, blank=True, editable=False)
gateway_paid_at = models.DateTimeField(null=True, blank=True, editable=False)
verification_attempts = models.PositiveSmallIntegerField(default=0, editable=False)
verification_last_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
verification_next_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
verification_lease_until = models.DateTimeField(null=True, blank=True, editable=False)
verification_dispatch_expires_at = models.DateTimeField(null=True, blank=True, editable=False)
verification_manual_review_at = models.DateTimeField(null=True, blank=True, editable=False)
refund_status = models.CharField(
max_length=20,
choices=RefundStatusChoices.choices,
default=RefundStatusChoices.NONE,
editable=False,
)
refund_attempts = models.PositiveSmallIntegerField(default=0, editable=False)
refund_last_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
refund_next_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
refund_reference = models.CharField(max_length=64, null=True, blank=True, editable=False)
refund_last_error = models.CharField(max_length=255, blank=True, default='', editable=False)
class Meta: class Meta:
constraints = [ constraints = [
@@ -127,5 +151,24 @@ class Payment(BaseModel):
"""Human-readable label for the payment status.""" """Human-readable label for the payment status."""
return self.get_status_display() return self.get_status_display()
@property
def verification_state(self):
"""Server-owned verification state for callback and support screens."""
if self.status == self.OrderStatusChoices.PAID or self.verified_at is not None:
return "verified"
if self.status == self.OrderStatusChoices.FAILED:
return "failed"
if self.status == self.OrderStatusChoices.CANCELED:
return "canceled"
if self.verification_manual_review_at is not None or not self.authority:
return "manual_review"
now = timezone.now()
if self.verification_lease_until and self.verification_lease_until > now:
return "checking"
if self.verification_next_attempt_at:
return "retry_scheduled"
return "awaiting_verification"
def __str__(self): def __str__(self):
return f"{self.user.email}:{self.event} - {self.get_status_display()}" return f"{self.user.email}:{self.event} - {self.get_status_display()}"

View File

@@ -1,6 +1,10 @@
"""Gateway operations whose responses must be interpreted conservatively.""" """Gateway operations and durable verification scheduling."""
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.db import transaction
from django.utils import timezone
import requests import requests
@@ -8,6 +12,14 @@ class PaymentVerificationUncertain(Exception):
"""The gateway response does not prove either payment success or failure.""" """The gateway response does not prove either payment success or failure."""
class PaymentReversalUncertain(Exception):
"""The gateway did not give a conclusive response to a reversal request."""
class PaymentReversalRejected(Exception):
"""The gateway definitively rejected the reversal and needs human review."""
def request_payment_verification(payment): def request_payment_verification(payment):
try: try:
response = requests.post( response = requests.post(
@@ -46,3 +58,184 @@ def request_payment_verification(payment):
) )
return code, body["data"] return code, body["data"]
def request_payment_reversal(payment):
"""Reverse a paid transaction through ZarinPal's transaction reversal API."""
try:
response = requests.post(
settings.ZARINPAL_REVERSE_URL,
json={
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
"authority": payment.authority,
},
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 PaymentReversalUncertain(
f"ZarinPal reversal returned HTTP {status_code}"
)
if isinstance(status_code, int) and status_code >= 400:
raise PaymentReversalRejected(
f"ZarinPal reversal was rejected with HTTP {status_code}"
)
body = response.json()
except (PaymentReversalUncertain, PaymentReversalRejected):
raise
except Exception as exc:
raise PaymentReversalUncertain("ZarinPal reversal request failed") from exc
if not isinstance(body, dict) or not isinstance(body.get("data"), dict):
if isinstance(body, dict) and isinstance(body.get("errors"), dict):
raise PaymentReversalRejected("ZarinPal rejected the reversal request")
raise PaymentReversalUncertain("ZarinPal returned an invalid reversal response")
code = body["data"].get("code")
try:
code = int(code)
except (TypeError, ValueError) as exc:
raise PaymentReversalUncertain("ZarinPal returned no reversal status code") from exc
if code != 100:
raise PaymentReversalRejected(f"ZarinPal rejected the reversal with code {code}")
return body["data"]
def claim_payment_verification(
payment_id: int,
*,
lease_seconds: int = 120,
allow_terminal: bool = False,
allow_manual_review: bool = False,
):
"""Claim one bounded verification attempt using the database as the lock."""
from apps.payments.models import Payment
with transaction.atomic():
try:
payment = Payment.objects.select_for_update().get(pk=payment_id)
except Payment.DoesNotExist:
return None, False
if payment.status == Payment.OrderStatusChoices.PAID or (
payment.status in (
Payment.OrderStatusChoices.FAILED,
Payment.OrderStatusChoices.CANCELED,
)
and not allow_terminal
):
return payment, False
now = timezone.now()
if payment.verification_manual_review_at is not None and not allow_manual_review:
return payment, False
if not payment.authority:
_mark_payment_manual_review(payment, now)
return payment, False
if payment.verification_lease_until and payment.verification_lease_until > now:
return payment, False
was_dispatched = bool(
payment.verification_dispatch_expires_at
and payment.verification_dispatch_expires_at > now
)
if (
payment.verification_next_attempt_at
and payment.verification_next_attempt_at > now
and not was_dispatched
):
return payment, False
max_attempts = getattr(settings, "PAYMENT_VERIFICATION_MAX_ATTEMPTS", 6)
if payment.verification_attempts >= max_attempts and not allow_manual_review:
_mark_payment_manual_review(payment, now)
return payment, False
payment.verification_attempts += 1
payment.verification_last_attempt_at = now
payment.verification_next_attempt_at = None
payment.verification_lease_until = now + timedelta(seconds=lease_seconds)
payment.verification_dispatch_expires_at = None
payment.verification_manual_review_at = None
payment.save(update_fields=[
"verification_attempts",
"verification_last_attempt_at",
"verification_next_attempt_at",
"verification_lease_until",
"verification_dispatch_expires_at",
"verification_manual_review_at",
])
return payment, True
def schedule_payment_verification_retry(payment_id: int):
"""Persist the next backoff step or stop at manual review after exhaustion."""
from apps.payments.models import Payment
with transaction.atomic():
try:
payment = Payment.objects.select_for_update().get(pk=payment_id)
except Payment.DoesNotExist:
return None
payment.verification_lease_until = None
payment.verification_dispatch_expires_at = None
if payment.status in (
Payment.OrderStatusChoices.PAID,
Payment.OrderStatusChoices.FAILED,
Payment.OrderStatusChoices.CANCELED,
):
payment.verification_next_attempt_at = None
payment.verification_manual_review_at = None
payment.save(update_fields=[
"verification_lease_until",
"verification_dispatch_expires_at",
"verification_next_attempt_at",
"verification_manual_review_at",
])
return None
max_attempts = getattr(settings, "PAYMENT_VERIFICATION_MAX_ATTEMPTS", 6)
if payment.verification_attempts >= max_attempts:
_mark_payment_manual_review(payment, timezone.now())
return None
attempt_index = max(payment.verification_attempts - 1, 0)
delay = min(60 * (2 ** attempt_index), 15 * 60)
payment.verification_next_attempt_at = timezone.now() + timedelta(seconds=delay)
payment.verification_manual_review_at = None
payment.save(update_fields=[
"verification_lease_until",
"verification_dispatch_expires_at",
"verification_next_attempt_at",
"verification_manual_review_at",
])
return delay
def mark_payment_manual_review(payment_id: int):
"""Keep an unresolved payment pending and make support review durable."""
from apps.payments.models import Payment
with transaction.atomic():
try:
payment = Payment.objects.select_for_update().get(pk=payment_id)
except Payment.DoesNotExist:
return None
_mark_payment_manual_review(payment, timezone.now())
return payment
def _mark_payment_manual_review(payment, now):
payment.verification_manual_review_at = payment.verification_manual_review_at or now
payment.verification_next_attempt_at = None
payment.verification_lease_until = None
payment.verification_dispatch_expires_at = None
payment.save(update_fields=[
"verification_manual_review_at",
"verification_next_attempt_at",
"verification_lease_until",
"verification_dispatch_expires_at",
])

View File

@@ -1,32 +1,63 @@
"""Bounded retries for payment verification after an inconclusive callback.""" """Bounded payment verification and recovery for lost gateway callbacks."""
import logging import logging
from datetime import timedelta
from celery import shared_task from celery import shared_task
from django.shortcuts import get_object_or_404 from django.conf import settings
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from apps.events.models import Registration
from apps.payments.models import Payment from apps.payments.models import Payment
from apps.payments.services import PaymentVerificationUncertain, request_payment_verification from apps.payments.services import (
PaymentReversalRejected,
PaymentReversalUncertain,
PaymentVerificationUncertain,
claim_payment_verification,
request_payment_verification,
request_payment_reversal,
schedule_payment_verification_retry,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=5, name="apps.payments.reconcile_payment") def _status_result(payment):
def reconcile_payment(self, payment_id: int): return {
payment = get_object_or_404( Payment.OrderStatusChoices.PAID: "paid",
Payment.objects.select_related("event", "user"), Payment.OrderStatusChoices.FAILED: "failed",
pk=payment_id, Payment.OrderStatusChoices.CANCELED: "canceled",
) }.get(payment.status)
if payment.status in (Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.FAILED):
return payment.get_status_display().lower()
if not payment.authority:
logger.error(
"Payment %s remains pending without a gateway authority; manual review is required",
payment.id,
)
return "pending_manual_review"
# Imported at task runtime to avoid a view/task import cycle.
def _queue_retry(payment_id: int, countdown: int):
try:
reconcile_payment.apply_async(args=(payment_id,), countdown=countdown)
except Exception:
logger.exception(
"Unable to queue payment verification for payment %s; the recovery sweep will retry it",
payment_id,
)
@shared_task(name="apps.payments.reconcile_payment")
def reconcile_payment(payment_id: int):
payment, claimed = claim_payment_verification(payment_id)
if payment is None:
return "not_found"
if not claimed:
terminal_result = _status_result(payment)
if terminal_result:
return terminal_result
if payment.verification_manual_review_at is not None or not payment.authority:
return "pending_manual_review"
if payment.verification_lease_until and payment.verification_lease_until > timezone.now():
return "verification_in_progress"
return "not_due"
# Imported here to avoid a view/task import cycle.
from apps.payments.api.views import ( from apps.payments.api.views import (
_mark_payment_paid, _mark_payment_paid,
_mark_payment_pending, _mark_payment_pending,
@@ -38,24 +69,37 @@ def reconcile_payment(self, payment_id: int):
code, data = request_payment_verification(payment) code, data = request_payment_verification(payment)
except PaymentVerificationUncertain as exc: except PaymentVerificationUncertain as exc:
_mark_payment_pending(payment.id) _mark_payment_pending(payment.id)
if self.request.retries >= self.max_retries: delay = schedule_payment_verification_retry(payment.id)
if delay is None:
logger.error( logger.error(
"Payment %s verification remains uncertain after bounded retries; manual review is required", "Payment %s remains pending after exhausting verification attempts; manual review is required",
payment.id, payment.id,
exc_info=exc, exc_info=exc,
) )
return "pending_manual_review" return "pending_manual_review"
countdown = min(60 * (2 ** self.request.retries), 15 * 60) _queue_retry(payment.id, delay)
raise self.retry(exc=exc, countdown=countdown) return "retry_scheduled"
if code in (100, 101): if code in (100, 101):
payment, newly_verified = _mark_payment_paid(payment.id, data) payment, newly_verified = _mark_payment_paid(payment.id, data)
if newly_verified: if newly_verified:
refund_pending = payment.refund_status in (
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
)
_notify_payment_status( _notify_payment_status(
payment, payment,
title=f"پرداخت رویداد «{payment.event.title}» تأیید شد", title=(
message="پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است.", f"پرداخت رویداد «{payment.event.title}» تأیید شد؛ بازگشت وجه در حال پیگیری است"
level="success", if refund_pending
else f"پرداخت رویداد «{payment.event.title}» تأیید شد"
),
message=(
"ثبت‌نام لغو شده است. بازگشت وجه به‌صورت خودکار پیگیری می‌شود؛ اگر نتیجه روشن نشود، پشتیبانی آن را بررسی می‌کند."
if refund_pending
else "پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است."
),
level="warning" if refund_pending else "success",
) )
return "paid" return "paid"
@@ -71,3 +115,204 @@ def reconcile_payment(self, payment_id: int):
level="error", level="error",
) )
return result return result
@shared_task(name="apps.payments.sweep_stale_payment_verifications")
def sweep_stale_payment_verifications():
"""Queue due old attempts so lost callbacks and broker failures recover safely."""
now = timezone.now()
grace_seconds = max(
0,
getattr(settings, "PAYMENT_VERIFICATION_GRACE_SECONDS", 1800),
)
cutoff = now - timedelta(seconds=grace_seconds)
max_attempts = getattr(settings, "PAYMENT_VERIFICATION_MAX_ATTEMPTS", 6)
dispatch_lease_seconds = max(
60,
getattr(settings, "PAYMENT_VERIFICATION_DISPATCH_LEASE_SECONDS", 900),
)
candidates = (
Payment.objects.filter(
status__in=[
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
],
authority__isnull=False,
verification_attempts__lt=max_attempts,
verification_manual_review_at__isnull=True,
)
.filter(
Q(created_at__lte=cutoff)
| Q(registration__cancellation_reason__in=[
"checkout_expired", "user_cancelled", "event_cancelled",
])
)
.filter(Q(verification_next_attempt_at__isnull=True) | Q(verification_next_attempt_at__lte=now))
.filter(Q(verification_lease_until__isnull=True) | Q(verification_lease_until__lte=now))
.filter(
Q(verification_dispatch_expires_at__isnull=True)
| Q(verification_dispatch_expires_at__lte=now)
)
.order_by("created_at", "pk")
.values_list("pk", flat=True)[:100]
)
scheduled = 0
for payment_id in candidates:
with transaction.atomic():
payment = Payment.objects.select_for_update().filter(pk=payment_id).first()
if payment is None:
continue
canceled_checkout = bool(
payment.registration_id
and Registration.objects.filter(
pk=payment.registration_id,
cancellation_reason__in=[
Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
Registration.CancellationReasonChoices.USER_CANCELLED,
Registration.CancellationReasonChoices.EVENT_CANCELLED,
],
).exists()
)
if (
payment.status not in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
)
or not payment.authority
or (payment.created_at > cutoff and not canceled_checkout)
or payment.verification_attempts >= max_attempts
or payment.verification_manual_review_at is not None
or (payment.verification_lease_until and payment.verification_lease_until > now)
or (
payment.verification_next_attempt_at
and payment.verification_next_attempt_at > now
)
or (
payment.verification_dispatch_expires_at
and payment.verification_dispatch_expires_at > now
)
):
continue
dispatch_expires_at = now + timedelta(seconds=dispatch_lease_seconds)
payment.verification_dispatch_expires_at = dispatch_expires_at
payment.save(update_fields=["verification_dispatch_expires_at"])
try:
reconcile_payment.apply_async(args=(payment.id,))
except Exception:
Payment.objects.filter(
pk=payment.id,
verification_dispatch_expires_at=dispatch_expires_at,
).update(verification_dispatch_expires_at=None)
logger.exception(
"Unable to dispatch stale payment %s for verification",
payment.id,
)
continue
scheduled += 1
return scheduled
@shared_task(name="apps.payments.refund_payment")
def refund_payment(payment_id: int):
"""Attempt a bounded ZarinPal reversal; surface unresolved refunds to admins."""
now = timezone.now()
lease = timedelta(seconds=max(60, getattr(settings, "PAYMENT_REFUND_LEASE_SECONDS", 120)))
with transaction.atomic():
payment = Payment.objects.select_for_update().filter(pk=payment_id).first()
if payment is None:
return "not_found"
if (
payment.status != Payment.OrderStatusChoices.PAID
or payment.refund_status != Payment.RefundStatusChoices.PENDING
):
return payment.refund_status
if payment.refund_next_attempt_at and payment.refund_next_attempt_at > now:
return "not_due"
if not payment.authority:
payment.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
payment.refund_last_error = "No gateway authority is available for reversal."
payment.refund_next_attempt_at = None
payment.save(update_fields=[
"refund_status", "refund_last_error", "refund_next_attempt_at",
])
return "manual_review"
max_attempts = max(1, getattr(settings, "PAYMENT_REFUND_MAX_ATTEMPTS", 3))
if payment.refund_attempts >= max_attempts:
payment.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
payment.refund_next_attempt_at = None
payment.save(update_fields=["refund_status", "refund_next_attempt_at"])
return "manual_review"
payment.refund_attempts += 1
payment.refund_last_attempt_at = now
# Duplicate task deliveries cannot issue concurrent reversals. If a
# worker dies after the external request, the sweep retries at lease end.
payment.refund_next_attempt_at = now + lease
payment.save(update_fields=[
"refund_attempts", "refund_last_attempt_at", "refund_next_attempt_at",
])
try:
data = request_payment_reversal(payment)
except PaymentReversalRejected as exc:
Payment.objects.filter(pk=payment_id).update(
refund_status=Payment.RefundStatusChoices.MANUAL_REVIEW,
refund_last_error=str(exc)[:255],
refund_next_attempt_at=None,
)
return "manual_review"
except PaymentReversalUncertain as exc:
with transaction.atomic():
current = Payment.objects.select_for_update().get(pk=payment_id)
current.refund_last_error = str(exc)[:255]
if current.refund_attempts >= max(1, getattr(settings, "PAYMENT_REFUND_MAX_ATTEMPTS", 3)):
current.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
current.refund_next_attempt_at = None
result = "manual_review"
else:
delay = min(60 * (2 ** (current.refund_attempts - 1)), 15 * 60)
current.refund_next_attempt_at = timezone.now() + timedelta(seconds=delay)
result = "retry_scheduled"
current.save(update_fields=[
"refund_status", "refund_last_error", "refund_next_attempt_at",
])
if result == "retry_scheduled":
try:
refund_payment.apply_async(args=(payment_id,), countdown=delay)
except Exception:
logger.exception("Unable to queue refund retry for payment %s", payment_id)
return result
Payment.objects.filter(pk=payment_id).update(
refund_status=Payment.RefundStatusChoices.SUCCEEDED,
refund_reference=str(data.get("ref_id") or data.get("reference") or "")[:64] or None,
refund_last_error="",
refund_next_attempt_at=None,
)
return "refunded"
@shared_task(name="apps.payments.sweep_pending_refunds")
def sweep_pending_refunds():
"""Recover refund work when an immediate Celery dispatch is lost."""
now = timezone.now()
due_ids = Payment.objects.filter(
status=Payment.OrderStatusChoices.PAID,
refund_status=Payment.RefundStatusChoices.PENDING,
).filter(
Q(refund_next_attempt_at__isnull=True) | Q(refund_next_attempt_at__lte=now)
).order_by("refund_last_attempt_at", "pk").values_list("pk", flat=True)[:100]
scheduled = 0
for payment_id in due_ids:
try:
refund_payment.apply_async(args=(payment_id,))
scheduled += 1
except Exception:
logger.exception("Unable to dispatch pending refund for payment %s", payment_id)
return scheduled

View File

@@ -86,7 +86,8 @@ def test_payment_callback_verification_failures_notify_once(client, user_factory
else: else:
gateway.return_value.json.return_value = gateway_result gateway.return_value.json.return_value = gateway_result
response = client.get("/api/payments/callback", {"Authority": "AUTH-FAIL", "Status": "OK"}) response = client.get("/api/payments/callback", {"Authority": "AUTH-FAIL", "Status": "OK"})
assert response.status_code == 302 and "status=pending" in response.url assert response.status_code == 302
assert f"/events/{event.slug}/registration?registration_id={registration.ticket_id}" in response.url
payment.refresh_from_db() payment.refresh_from_db()
registration.refresh_from_db() registration.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.PENDING assert payment.status == Payment.OrderStatusChoices.PENDING

View File

@@ -1,4 +1,7 @@
from datetime import timedelta
import pytest import pytest
from django.utils import timezone
from apps.events.models import Registration from apps.events.models import Registration
from apps.payments.models import Payment from apps.payments.models import Payment
@@ -39,13 +42,16 @@ def test_reconciliation_retry_confirms_payment_after_transient_timeout(
mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}}), mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}}),
] ]
notify = mocker.patch("apps.payments.api.views._notify_payment_status") notify = mocker.patch("apps.payments.api.views._notify_payment_status")
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
retry = mocker.patch.object(reconcile_payment, "retry", side_effect=RuntimeError("retry scheduled")) assert reconcile_payment.run(payment.id) == "retry_scheduled"
with pytest.raises(RuntimeError, match="retry scheduled"): payment.refresh_from_db()
reconcile_payment.run(payment.id) assert payment.verification_attempts == 1
assert payment.verification_next_attempt_at is not None
retry.assert_called_once() enqueue.assert_called_once_with(args=(payment.id,), countdown=60)
assert retry.call_args.kwargs["countdown"] == 60 Payment.objects.filter(pk=payment.pk).update(
verification_next_attempt_at=timezone.now() - timedelta(seconds=1)
)
gateway.side_effect = [ gateway.side_effect = [
mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}}) mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}})
] ]
@@ -57,6 +63,8 @@ def test_reconciliation_retry_confirms_payment_after_transient_timeout(
assert gateway.call_count == 2 assert gateway.call_count == 2
assert payment.status == Payment.OrderStatusChoices.PAID assert payment.status == Payment.OrderStatusChoices.PAID
assert payment.ref_id == "REF-RETRY" assert payment.ref_id == "REF-RETRY"
assert payment.verification_attempts == 2
assert payment.verification_manual_review_at is None
assert registration.status == Registration.StatusChoices.CONFIRMED assert registration.status == Registration.StatusChoices.CONFIRMED
notify.assert_called_once() notify.assert_called_once()
@@ -67,27 +75,27 @@ def test_reconciliation_exhaustion_leaves_payment_pending_for_review(
payment, registration = _pending_payment(user_factory, event_factory) payment, registration = _pending_payment(user_factory, event_factory)
settings.ZARINPAL_MERCHANT_ID = "test" settings.ZARINPAL_MERCHANT_ID = "test"
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify" settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS = 6
gateway = mocker.patch("apps.payments.services.requests.post", side_effect=RuntimeError("gateway offline")) gateway = mocker.patch("apps.payments.services.requests.post", side_effect=RuntimeError("gateway offline"))
notify = mocker.patch("apps.payments.api.views._notify_payment_status") notify = mocker.patch("apps.payments.api.views._notify_payment_status")
retry = mocker.patch.object(reconcile_payment, "retry", side_effect=RuntimeError("retry scheduled")) mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
for retry_number in range(reconcile_payment.max_retries + 1): for attempt_number in range(settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS):
reconcile_payment.push_request(retries=retry_number) Payment.objects.filter(pk=payment.pk).update(
try: verification_next_attempt_at=timezone.now() - timedelta(seconds=1)
if retry_number < reconcile_payment.max_retries: )
with pytest.raises(RuntimeError, match="retry scheduled"): result = reconcile_payment.run(payment.id)
reconcile_payment.run(payment.id) if attempt_number < settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS - 1:
else: assert result == "retry_scheduled"
result = reconcile_payment.run(payment.id)
finally:
reconcile_payment.pop_request()
payment.refresh_from_db() payment.refresh_from_db()
registration.refresh_from_db() registration.refresh_from_db()
assert result == "pending_manual_review" assert result == "pending_manual_review"
assert gateway.call_count == reconcile_payment.max_retries + 1 assert gateway.call_count == settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS
assert [call.kwargs["countdown"] for call in retry.call_args_list] == [60, 120, 240, 480, 900] assert payment.verification_attempts == settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS
assert payment.status == Payment.OrderStatusChoices.PENDING assert payment.status == Payment.OrderStatusChoices.PENDING
assert payment.verification_manual_review_at is not None
assert payment.verification_next_attempt_at is None
assert registration.status == Registration.StatusChoices.PENDING assert registration.status == Registration.StatusChoices.PENDING
notify.assert_not_called() notify.assert_not_called()
@@ -137,3 +145,162 @@ def test_failed_attempt_does_not_cancel_registration_used_by_another_active_paym
registration.refresh_from_db() registration.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.FAILED assert payment.status == Payment.OrderStatusChoices.FAILED
assert registration.status == Registration.StatusChoices.PENDING assert registration.status == Registration.StatusChoices.PENDING
def _expired_checkout_payment(user_factory, event_factory):
now = timezone.now()
user = user_factory()
event = event_factory(price=25000, capacity=2)
registration = Registration.objects.create(
event=event,
user=user,
status=Registration.StatusChoices.CANCELLED,
checkout_started_at=now - timedelta(minutes=20),
checkout_expires_at=now - timedelta(minutes=5),
cancellation_reason=Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
final_price=25000,
)
payment = Payment.objects.create(
user=user,
event=event,
registration=registration,
base_amount=25000,
amount=25000,
authority=f"AUTH-LATE-{user.pk}",
status=Payment.OrderStatusChoices.PENDING,
)
return payment, registration
def test_late_success_without_provider_payment_time_keeps_registration_cancelled(
user_factory, event_factory, mocker
):
from apps.payments.api.views import _mark_payment_paid
payment, registration = _expired_checkout_payment(user_factory, event_factory)
mocker.patch("apps.payments.tasks.refund_payment.apply_async")
payment, newly_verified = _mark_payment_paid(payment.id, {"ref_id": "LATE-REF"})
registration.refresh_from_db()
assert newly_verified is True
assert payment.status == Payment.OrderStatusChoices.PAID
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
assert registration.status == Registration.StatusChoices.CANCELLED
def test_late_success_with_proven_pre_expiry_payment_and_available_seat_is_honored(
user_factory, event_factory
):
from apps.payments.api.views import _mark_payment_paid
payment, registration = _expired_checkout_payment(user_factory, event_factory)
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "ONTIME-REF", "paid_at": paid_at})
registration.refresh_from_db()
assert registration.status == Registration.StatusChoices.CONFIRMED
assert payment.refund_status == Payment.RefundStatusChoices.NONE
def test_late_success_with_proven_pre_expiry_time_is_refunded_when_capacity_is_full(
user_factory, event_factory
):
from apps.payments.api.views import _mark_payment_paid
payment, registration = _expired_checkout_payment(user_factory, event_factory)
payment.event.capacity = 1
payment.event.save(update_fields=["capacity"])
other_user = user_factory()
Registration.objects.create(
event=payment.event,
user=other_user,
status=Registration.StatusChoices.CONFIRMED,
)
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "FULL-REF", "paid_at": paid_at})
registration.refresh_from_db()
assert registration.status == Registration.StatusChoices.CANCELLED
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
@pytest.mark.parametrize(
("reason", "cancel_event"),
[
(Registration.CancellationReasonChoices.USER_CANCELLED, False),
(Registration.CancellationReasonChoices.EVENT_CANCELLED, True),
],
)
def test_late_success_after_user_or_event_cancellation_is_refunded(
user_factory, event_factory, mocker, reason, cancel_event
):
from apps.payments.api.views import _mark_payment_paid
payment, registration = _expired_checkout_payment(user_factory, event_factory)
registration.cancellation_reason = reason
registration.save(update_fields=["cancellation_reason"])
if cancel_event:
payment.event.status = payment.event.StatusChoices.CANCELLED
payment.event.save(update_fields=["status"])
mocker.patch("apps.payments.tasks.refund_payment.apply_async")
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "CANCELLED-REF", "paid_at": paid_at})
registration.refresh_from_db()
assert registration.status == Registration.StatusChoices.CANCELLED
assert registration.cancellation_reason == reason
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
def test_late_success_refund_task_persists_provider_reversal(mocker, user_factory, event_factory):
from apps.payments.tasks import refund_payment
payment, registration = _expired_checkout_payment(user_factory, event_factory)
Payment.objects.filter(pk=payment.pk).update(
status=Payment.OrderStatusChoices.PAID,
refund_status=Payment.RefundStatusChoices.PENDING,
)
reverse = mocker.patch(
"apps.payments.tasks.request_payment_reversal",
return_value={"code": 100, "ref_id": "REVERSAL-1"},
)
result = refund_payment.run(payment.id)
payment.refresh_from_db()
assert result == "refunded"
assert payment.refund_status == Payment.RefundStatusChoices.SUCCEEDED
assert payment.refund_reference == "REVERSAL-1"
assert payment.refund_attempts == 1
reverse.assert_called_once()
def test_uncertain_reversal_retries_then_keeps_admin_review_state(mocker, user_factory, event_factory, settings):
from apps.payments.services import PaymentReversalUncertain
from apps.payments.tasks import refund_payment
payment, _ = _expired_checkout_payment(user_factory, event_factory)
Payment.objects.filter(pk=payment.pk).update(
status=Payment.OrderStatusChoices.PAID,
refund_status=Payment.RefundStatusChoices.PENDING,
refund_attempts=2,
)
settings.PAYMENT_REFUND_MAX_ATTEMPTS = 3
mocker.patch(
"apps.payments.tasks.request_payment_reversal",
side_effect=PaymentReversalUncertain("gateway timed out"),
)
enqueue = mocker.patch("apps.payments.tasks.refund_payment.apply_async")
result = refund_payment.run(payment.id)
payment.refresh_from_db()
assert result == "manual_review"
assert payment.refund_status == Payment.RefundStatusChoices.MANUAL_REVIEW
assert payment.refund_attempts == 3
assert payment.refund_next_attempt_at is None
enqueue.assert_not_called()

View File

@@ -0,0 +1,82 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from apps.payments.models import Payment
from apps.payments.tasks import sweep_stale_payment_verifications
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def test_sweep_schedules_only_old_unresolved_payments_and_deduplicates_dispatch(
user_factory, event_factory, mocker, settings
):
settings.PAYMENT_VERIFICATION_GRACE_SECONDS = 1800
user = user_factory()
event = event_factory(price=25000)
old_payment = Payment.objects.create(
user=user,
event=event,
base_amount=25000,
amount=25000,
authority="AUTH-RECOVERY-OLD",
status=Payment.OrderStatusChoices.PENDING,
)
recent_payment = Payment.objects.create(
user=user,
event=event,
base_amount=25000,
amount=25000,
authority="AUTH-RECOVERY-NEW",
status=Payment.OrderStatusChoices.PENDING,
)
Payment.objects.filter(pk=old_payment.pk).update(
created_at=timezone.now() - timedelta(seconds=1801)
)
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
scheduled = sweep_stale_payment_verifications.run()
old_payment.refresh_from_db()
recent_payment.refresh_from_db()
assert scheduled == 1
enqueue.assert_called_once_with(args=(old_payment.pk,))
assert old_payment.verification_dispatch_expires_at is not None
assert old_payment.verification_dispatch_expires_at > timezone.now()
assert recent_payment.verification_next_attempt_at is None
assert sweep_stale_payment_verifications.run() == 0
enqueue.assert_called_once()
def test_sweep_does_not_restart_exhausted_or_terminal_attempts(
user_factory, event_factory, mocker, settings
):
settings.PAYMENT_VERIFICATION_GRACE_SECONDS = 60
settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS = 3
user = user_factory()
event = event_factory(price=25000)
payments = [
Payment.objects.create(
user=user,
event=event,
base_amount=25000,
amount=25000,
authority=f"AUTH-RECOVERY-{status}-{index}",
status=status,
verification_attempts=3,
)
for index, status in enumerate(
[Payment.OrderStatusChoices.PENDING, Payment.OrderStatusChoices.FAILED]
)
]
Payment.objects.filter(pk__in=[payment.pk for payment in payments]).update(
created_at=timezone.now() - timedelta(minutes=5)
)
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
scheduled = sweep_stale_payment_verifications.run()
assert scheduled == 0
enqueue.assert_not_called()

View File

@@ -168,7 +168,8 @@ class TestPaymentsAPIIntegrationTests:
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH123', 'Status': 'OK'}) response = self.client.get('/api/payments/callback', {'Authority': 'AUTH123', 'Status': 'OK'})
payment.refresh_from_db() payment.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.PAID assert payment.status == Payment.OrderStatusChoices.PAID
assert 'status=success' in response.url assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
assert 'status=' not in response.url
def test_callback_failure_redirects_failed(self, mocker): def test_callback_failure_redirects_failed(self, mocker):
mock_post = mocker.patch('apps.payments.api.views.requests.post') mock_post = mocker.patch('apps.payments.api.views.requests.post')
@@ -179,11 +180,21 @@ class TestPaymentsAPIIntegrationTests:
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH456', 'Status': 'OK'}) response = self.client.get('/api/payments/callback', {'Authority': 'AUTH456', 'Status': 'OK'})
payment.refresh_from_db() payment.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.PAID assert payment.status == Payment.OrderStatusChoices.PAID
assert 'status=success' in response.url assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
def test_callback_missing_authority_returns_error(self): def test_callback_missing_authority_redirects_to_safe_unknown_result(self):
response = self.client.get('/api/payments/callback', {'Status': 'OK'}) response = self.client.get('/api/payments/callback', {'Status': 'OK'})
assert response.status_code == 400 assert response.status_code == 302
assert '/events?payment_callback=unmatched' in response.url
def test_callback_unknown_authority_redirects_to_safe_unknown_result(self):
response = self.client.get(
'/api/payments/callback',
{'Authority': 'AUTH-UNKNOWN', 'Status': 'OK'},
)
assert response.status_code == 302
assert '/events?payment_callback=unmatched' in response.url
assert 'AUTH-UNKNOWN' not in response.url
def test_callback_not_ok_is_verified_before_being_canceled(self, mocker): def test_callback_not_ok_is_verified_before_being_canceled(self, mocker):
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH789') payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH789')
@@ -191,8 +202,8 @@ class TestPaymentsAPIIntegrationTests:
gateway.return_value.json.return_value = {'data': {'code': -51}} gateway.return_value.json.return_value = {'data': {'code': -51}}
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'}) response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'})
payment.refresh_from_db() payment.refresh_from_db()
assert payment.status == Payment.OrderStatusChoices.FAILED assert payment.status == Payment.OrderStatusChoices.CANCELED
assert 'status=failed' in response.url assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
def test_late_canceled_callback_does_not_overwrite_verified_payment(self, mocker): def test_late_canceled_callback_does_not_overwrite_verified_payment(self, mocker):
registration = Registration.objects.create( registration = Registration.objects.create(
@@ -229,8 +240,9 @@ class TestPaymentsAPIIntegrationTests:
assert payment.status == Payment.OrderStatusChoices.PAID assert payment.status == Payment.OrderStatusChoices.PAID
assert payment.verified_at is not None assert payment.verified_at is not None
assert registration.status == Registration.StatusChoices.CONFIRMED assert registration.status == Registration.StatusChoices.CONFIRMED
assert 'status=success' in success_response.url expected_return = f'/events/{self.event.slug}/registration?registration_id={registration.ticket_id}'
assert 'status=success' in late_failure_response.url assert expected_return in success_response.url
assert expected_return in late_failure_response.url
def test_create_payment_gateway_timeout_stays_pending(self, mocker): def test_create_payment_gateway_timeout_stays_pending(self, mocker):
mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down')) mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down'))
@@ -249,8 +261,7 @@ class TestPaymentsAPIIntegrationTests:
payment.refresh_from_db() payment.refresh_from_db()
assert response.status_code == 302 assert response.status_code == 302
assert 'status=pending' in response.url assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
assert f'payment_id={payment.id}' in response.url
assert payment.status == Payment.OrderStatusChoices.PENDING assert payment.status == Payment.OrderStatusChoices.PENDING
reconcile.assert_called_once_with(args=(payment.id,), countdown=60) reconcile.assert_called_once_with(args=(payment.id,), countdown=60)
notify.assert_not_called() notify.assert_not_called()
@@ -261,12 +272,45 @@ class TestPaymentsAPIIntegrationTests:
assert own.status_code == 200 assert own.status_code == 200
assert own.json()['payment_id'] == payment.id assert own.json()['payment_id'] == payment.id
assert own.json()['status'] == 'pending' assert own.json()['status'] == 'pending'
assert own.json()['verification_state'] == 'awaiting_verification'
assert own.json()['verification_attempts'] == 0
assert own.json()['last_verification_at'] is None
assert own.json()['manual_review_at'] is None
other = User.objects.create_user(username='other_payment_owner', email='other-payment@example.com', mobile='09123456780', password=self.password) other = User.objects.create_user(username='other_payment_owner', email='other-payment@example.com', mobile='09123456780', password=self.password)
other_token = create_jwt_token(other) other_token = create_jwt_token(other)
forbidden = self.client.get(f'/api/payments/status/{payment.id}', HTTP_AUTHORIZATION=f'Bearer {other_token}') forbidden = self.client.get(f'/api/payments/status/{payment.id}', HTTP_AUTHORIZATION=f'Bearer {other_token}')
assert forbidden.status_code == 404 assert forbidden.status_code == 404
def test_payment_reference_lookup_requires_authentication_and_is_user_scoped(self):
payment = Payment.objects.create(
user=self.user,
event=self.event,
base_amount=self.event.price,
amount=self.event.price,
status=Payment.OrderStatusChoices.PAID,
ref_id="OWNER-ONLY-REF",
)
unauthenticated = self.client.get("/api/payments/by-ref/OWNER-ONLY-REF")
owned = self.client.get("/api/payments/by-ref/OWNER-ONLY-REF", **self._headers())
other = User.objects.create_user(
username="other_ref_owner",
email="other-ref-owner@example.com",
mobile="09123456781",
password=self.password,
)
other_token = create_jwt_token(other)
forbidden = self.client.get(
"/api/payments/by-ref/OWNER-ONLY-REF",
HTTP_AUTHORIZATION=f"Bearer {other_token}",
)
assert unauthenticated.status_code == 401
assert owned.status_code == 200
assert owned.json()["payment_id"] == payment.id
assert forbidden.status_code == 404
def test_create_payment_when_already_paid(self): def test_create_payment_when_already_paid(self):
Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PAID) Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PAID)
response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Duplicate'}), content_type='application/json', **self._headers()) response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Duplicate'}), content_type='application/json', **self._headers())
@@ -341,10 +385,12 @@ def test_my_payments_are_user_scoped_and_omit_gateway_identifiers(client, user_f
payload = response.json() payload = response.json()
assert len(payload) == 1 assert len(payload) == 1
assert payload[0]["event"]["title"] == own_event.title assert payload[0]["event"]["title"] == own_event.title
assert payload[0]["event"]["id"] == own_event.id
assert payload[0]["amount"] == 100000 assert payload[0]["amount"] == 100000
assert payload[0]["discount_amount"] == 20000 assert payload[0]["discount_amount"] == 20000
assert payload[0]["status"] == "paid" assert payload[0]["status"] == "paid"
assert set(payload[0]) == {"event", "base_amount", "discount_amount", "amount", "status", "created_at", "paid_at"} assert set(payload[0]) == {"payment_id", "event", "base_amount", "discount_amount", "amount", "status", "refund_status", "created_at", "paid_at"}
assert payload[0]["payment_id"] == Payment.objects.get(user=current_user, event=own_event).id
assert "internal-authority-owner" not in response.content.decode() assert "internal-authority-owner" not in response.content.decode()
assert "internal-reference-owner" not in response.content.decode() assert "internal-reference-owner" not in response.content.decode()
assert "internal-card-hash" not in response.content.decode() assert "internal-card-hash" not in response.content.decode()

View File

@@ -38,6 +38,21 @@ app.conf.beat_schedule = {
'schedule': crontab(hour=2, minute=0), 'schedule': crontab(hour=2, minute=0),
'description': 'Runs daily at 02:00 UTC to cleanup notification retention.', 'description': 'Runs daily at 02:00 UTC to cleanup notification retention.',
}, },
'sweep-stale-payment-verifications': {
'task': 'apps.payments.sweep_stale_payment_verifications',
'schedule': crontab(minute='*/5'),
'description': 'Recovers old unresolved payment attempts with bounded gateway verification.',
},
'expire-stale-registration-checkouts': {
'task': 'apps.events.expire_stale_registration_checkouts',
'schedule': crontab(minute='*'),
'description': 'Releases seats from paid checkouts after their 15-minute window.',
},
'sweep-pending-refunds': {
'task': 'apps.payments.sweep_pending_refunds',
'schedule': crontab(minute='*/5'),
'description': 'Recovers pending transaction reversals and exposes exhausted attempts for admin review.',
},
} }
CELERY_TASK_SOFT_TIME_LIMIT = 20 CELERY_TASK_SOFT_TIME_LIMIT = 20

View File

@@ -6,5 +6,6 @@ ZARINPAL_USE_SANDBOX = config('ZARINPAL_USE_SANDBOX', default=False, cast=bool)
ZARINPAL_API_BASE = "https://sandbox.zarinpal.com" if ZARINPAL_USE_SANDBOX else "https://payment.zarinpal.com" ZARINPAL_API_BASE = "https://sandbox.zarinpal.com" if ZARINPAL_USE_SANDBOX else "https://payment.zarinpal.com"
ZARINPAL_REQUEST_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/request.json" ZARINPAL_REQUEST_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/request.json"
ZARINPAL_VERIFY_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/verify.json" ZARINPAL_VERIFY_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/verify.json"
ZARINPAL_REVERSE_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/reverse.json"
ZARINPAL_STARTPAY = f"{ZARINPAL_API_BASE}/pg/StartPay/" ZARINPAL_STARTPAY = f"{ZARINPAL_API_BASE}/pg/StartPay/"
ZARINPAL_CALLBACK_URL = config('ZARINPAL_CALLBACK_URL', default='http://localhost:8000/api/payments/callback') ZARINPAL_CALLBACK_URL = config('ZARINPAL_CALLBACK_URL', default='http://localhost:8000/api/payments/callback')

View File

@@ -229,6 +229,9 @@ os.makedirs(BASE_DIR / 'logs', exist_ok=True)
BACKEND_ROOT = config('DJANGO_HOST', default='http://localhost:8000/') BACKEND_ROOT = config('DJANGO_HOST', default='http://localhost:8000/')
FRONTEND_ROOT = config('FRONTEND_ROOT', default='http://localhost:3000/') FRONTEND_ROOT = config('FRONTEND_ROOT', default='http://localhost:3000/')
FRONTEND_CALLBACK_URL = config('FRONTEND_CALLBACK_URL', default='http://localhost:3000/payments/result') FRONTEND_CALLBACK_URL = config('FRONTEND_CALLBACK_URL', default='http://localhost:3000/payments/result')
PAYMENT_VERIFICATION_GRACE_SECONDS = config('PAYMENT_VERIFICATION_GRACE_SECONDS', cast=int, default=1800)
PAYMENT_VERIFICATION_MAX_ATTEMPTS = config('PAYMENT_VERIFICATION_MAX_ATTEMPTS', cast=int, default=6)
PAYMENT_VERIFICATION_DISPATCH_LEASE_SECONDS = config('PAYMENT_VERIFICATION_DISPATCH_LEASE_SECONDS', cast=int, default=900)
GOOGLE_OAUTH_CLIENT_ID = config('GOOGLE_OAUTH_CLIENT_ID', default='') GOOGLE_OAUTH_CLIENT_ID = config('GOOGLE_OAUTH_CLIENT_ID', default='')
GOOGLE_OAUTH_CLIENT_SECRET = config('GOOGLE_OAUTH_CLIENT_SECRET', default='') GOOGLE_OAUTH_CLIENT_SECRET = config('GOOGLE_OAUTH_CLIENT_SECRET', default='')
GOOGLE_OAUTH_REDIRECT_URI = config('GOOGLE_OAUTH_REDIRECT_URI', default='') GOOGLE_OAUTH_REDIRECT_URI = config('GOOGLE_OAUTH_REDIRECT_URI', default='')