diff --git a/.env.development.example b/.env.development.example index 8518a0d..b1f3731 100644 --- a/.env.development.example +++ b/.env.development.example @@ -27,6 +27,9 @@ SMS_PAYMENT_STATUS_TEMPLATE_ID= FRONTEND_ROOT=http://localhost:8080 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_FRONTEND_CALLBACK_URL=http://localhost:8080/auth/google/callback diff --git a/.env.example b/.env.example index 6403caf..85e1ff1 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,9 @@ CSRF_COOKIE_SECURE=True SESSION_COOKIE_SECURE=True FRONTEND_ROOT=https://frontend.example.com 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 NOTIFICATIONS_ENABLED=True diff --git a/apps/content/management/commands/seed_mock_content.py b/apps/content/management/commands/seed_mock_content.py index c531302..29218fb 100644 --- a/apps/content/management/commands/seed_mock_content.py +++ b/apps/content/management/commands/seed_mock_content.py @@ -87,7 +87,7 @@ ABOUT_MOCK = { "members_intro": "با اعضایی آشنا شوید که برای ساختن تجربه‌های بهتر در انجمن همکاری می‌کنند.", "gallery_heading": "خاطرات انجمن", "gallery_intro": "تصاویری از گردهمایی‌ها، برنامه‌ها و لحظه‌هایی که کنار هم ساخته‌ایم.", - "past_events_heading": "رویدادهای برگزارشده", + "past_events_heading": "رویدادهای برگزار شده", "history_heading": "مسیر انجمن", "audience_heading": "برای چه کسانی؟", "year_events_heading": "لحظه‌های هر سال", diff --git a/apps/events/admin.py b/apps/events/admin.py index 5331857..41a8dee 100644 --- a/apps/events/admin.py +++ b/apps/events/admin.py @@ -7,6 +7,7 @@ from core.templatetags.jalali import jdate from apps.events.models import Event, Registration from apps.events.resources import EventResource, RegistrationResource from apps.events.services import verify_registration_attendance +from apps.events.checkout import cancel_event_checkouts @admin.register(Event) @@ -79,8 +80,14 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin): @admin.action(description='Mark selected events as cancelled') def make_cancelled(self, request, queryset): - queryset.update(status=Event.StatusChoices.CANCELLED) - self.message_user(request, f'Cancelled {queryset.count()} events.') + count = 0 + 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') def make_completed(self, request, queryset): @@ -93,15 +100,27 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin): event.restore() 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) class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin): resource_class = RegistrationResource 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', ) - list_filter = ('status', 'event', 'is_deleted', 'registered_at', SoftDeleteListFilter) + list_filter = ( + 'status', 'cancellation_reason', 'event', 'is_deleted', 'registered_at', + SoftDeleteListFilter, + ) search_fields = ( 'user__username', 'user__email', 'user__first_name', 'user__last_name', 'event__title', 'ticket_id', @@ -109,13 +128,15 @@ class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin): readonly_fields = ( 'ticket_id', 'registered_at', 'confirmation_notification_sent_at', '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', ) fieldsets = ( ('Registration Details', {'fields': ( 'user', 'event', 'status', 'registered_at', 'ticket_id', 'confirmation_notification_sent_at', 'cancellation_notification_sent_at', + 'checkout_started_at', 'checkout_expires_at', 'cancellation_reason', )}), ('Attendance verification', {'fields': ( 'attendance_verified_at', 'attendance_verified_by', 'attendance_verification_source', diff --git a/apps/events/api/schemas.py b/apps/events/api/schemas.py index 1238522..3014c4b 100644 --- a/apps/events/api/schemas.py +++ b/apps/events/api/schemas.py @@ -5,6 +5,8 @@ from ninja import ModelSchema, Schema from pydantic import field_validator from typing import Literal, Optional, List from datetime import datetime +from django.db.models import Q +from django.utils import timezone from apps.blog.api.schemas import AuthorSchema from apps.events.models import Event, Registration @@ -85,7 +87,11 @@ class EventSchema(ModelSchema): @staticmethod 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 def resolve_description_html(obj): @@ -102,6 +108,7 @@ class EventListSchema(Schema): absolute_featured_image_thumbnail_url: Optional[str] = None absolute_featured_image_preview_url: Optional[str] = None event_type: str + address: Optional[str] = None start_time: datetime end_time: datetime registration_start_date: Optional[datetime] = None @@ -133,7 +140,22 @@ class EventListSchema(Schema): @staticmethod 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): """Payload for creating events via the API.""" @@ -185,6 +207,9 @@ class RegistrationSchema(ModelSchema): 'id', 'status', 'registered_at', + 'checkout_started_at', + 'checkout_expires_at', + 'cancellation_reason', 'ticket_id', 'discount_amount', 'final_price', @@ -326,9 +351,16 @@ class EventBriefSchema(Schema): class MyEventRegistrationOut(Schema): """Registration information as returned to authenticated users.""" id: int + ticket_id: UUID created_at: datetime status: Literal["pending", "confirmed", "cancelled", "attended"] 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): @@ -340,6 +372,79 @@ class PaginatedMyEventRegistrationsSchema(Schema): class RegistrationStatusOut(Schema): 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): diff --git a/apps/events/api/views.py b/apps/events/api/views.py index 895ec09..62dfa96 100644 --- a/apps/events/api/views.py +++ b/apps/events/api/views.py @@ -1,7 +1,11 @@ from django.conf import settings from django.core.files.base import ContentFile 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.utils.text import slugify from django.utils import timezone @@ -17,6 +21,7 @@ from apps.events.api.schemas import ( EventCreateSchema, EventGallerySchema, EventListSchema, + PaginatedEventBrowseSchema, EventSchema, EventUpdateSchema, MyEventRegistrationOut, @@ -26,14 +31,22 @@ from apps.events.api.schemas import ( RegistrationCreateSchema, RegistrationSchema, RegistrationStatusOut, + RegistrationHubOut, RegistrationStatusUpdateSchema, ) from core.authentication import jwt_auth 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.tasks import process_uploaded_image 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 core.api.schemas import ErrorSchema, MessageSchema @@ -138,6 +151,143 @@ def list_events( events = queryset[offset:offset + limit] 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) def get_event(request, event_id: int): """Get event details by ID""" @@ -224,6 +374,7 @@ def update_event(request, event_id: int, payload: EventUpdateSchema): ) if cancelled_now: + cancel_event_checkouts(event.id) _notify_event_update( event, notification_type="event_cancelled", @@ -244,6 +395,45 @@ def update_event(request, event_id: int, payload: EventUpdateSchema): 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) def delete_event(request, event_id: int): """Soft delete an event""" @@ -407,79 +597,109 @@ def register_for_event( payload: RegistrationCreateSchema | None = None, ): """Register current user for an event""" - event = get_object_or_404(Event, id=event_id, is_deleted=False) 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(): - raise HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید.") + with transaction.atomic(): + 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(): - raise HttpError(400, "مهلت ثبت‌نام به پایان رسیده‌است") - - if event.registration_start_date and event.registration_start_date > timezone.now(): - raise HttpError(400, "زمان ثبت‌نام هنوز آغاز نشده است") + registration = ( + Registration.objects.select_for_update() + .filter(event=event, user=user, is_deleted=False) + .order_by("-registered_at", "-id") + .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: - raise HttpError(400, "ظرفیت شرکت‌کنندگان تکمیل است") + unresolved_attempt = Payment.objects.filter( + 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 - discount_code = None - if payload and payload.discount_code: - discount_code = payload.discount_code - elif request.GET.get("discount_code"): - discount_code = request.GET.get("discount_code") + if event.status != Event.StatusChoices.PUBLISHED: + raise HttpError(400, "ثبت‌نام این رویداد فعال نیست.") + if event.registration_end_date and event.registration_end_date < now: + raise HttpError(400, "مهلت ثبت‌نام به پایان رسیده‌است") + if event.registration_start_date and event.registration_start_date > now: + raise HttpError(400, "زمان ثبت‌نام هنوز آغاز نشده است") + if not event.has_available_slots: + raise HttpError(400, "ظرفیت شرکت‌کنندگان تکمیل است") - registration, created = Registration.objects.get_or_create( - event=event, - user=user, - status=Registration.StatusChoices.PENDING, - defaults={"final_price": event.price}, - ) + discount_code_value = ( + payload.discount_code if payload and payload.discount_code + else request.GET.get("discount_code") + ) + 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: - return HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید") - - if registration.status == Registration.StatusChoices.CANCELLED: + is_free = final_price == 0 registration = Registration.objects.create( event=event, user=user, - status=Registration.StatusChoices.PENDING, - final_price=event.price, + status=( + 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: - 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�O\"O� U+UOO3O�") - 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 + return registration @events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth) 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) requested_status = payload.dict(exclude_unset=True).get('status') - if requested_status == Registration.StatusChoices.ATTENDED: - raise HttpError(403, "حضور رویداد فقط توسط برگزارکننده تأیید می‌شود.") - registration.status = requested_status - registration.full_clean() - registration.save() + if requested_status != Registration.StatusChoices.CANCELLED: + raise HttpError(403, "وضعیت ثبت‌نام فقط پس از تأیید پرداخت توسط سامانه تغییر می‌کند.") + 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 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 @@ -501,8 +746,33 @@ def cancel_registration(request, registration_id: int): """Cancel a registration""" user = request.auth - registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False) - registration.delete() + registration = get_object_or_404( + 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": "ثبت‌نام شما لغو شد :("} @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) def my_registrations_paginated( request, @@ -542,11 +859,14 @@ def my_registrations_paginated( count = queryset.count() total_pages = max(1, (count + page_size - 1) // page_size) start = (page - 1) * page_size + now = timezone.now() results = [ MyEventRegistrationOut( id=registration.id, + ticket_id=registration.ticket_id, created_at=registration.created_at, status=registration.status, + **_my_registration_checkout_fields(registration, now), event=EventBriefSchema( id=registration.event.id, title=registration.event.title, @@ -582,12 +902,15 @@ def my_registrations(request): .order_by("-created_at") ) out: List[MyEventRegistrationOut] = [] + now = timezone.now() for r in qs: out.append( MyEventRegistrationOut( id=r.id, + ticket_id=r.ticket_id, created_at=r.created_at, status=r.status, + **_my_registration_checkout_fields(r, now), event=EventBriefSchema( id=r.event.id, title=r.event.title, @@ -604,12 +927,337 @@ def my_registrations(request): @events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth) def is_registered(request, event_id: int): - exists = Registration.objects.filter( - user=request.auth, - event_id=event_id, - status=Registration.StatusChoices.CONFIRMED - ).exists() - return {"is_registered": exists} + now = timezone.now() + registration = ( + Registration.objects.filter( + user=request.auth, + event_id=event_id, + 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) def event_admin_detail(request, event_id: int): user = request.auth diff --git a/apps/events/checkout.py b/apps/events/checkout.py new file mode 100644 index 0000000..f1d9859 --- /dev/null +++ b/apps/events/checkout.py @@ -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 diff --git a/apps/events/migrations/0016_registration_cancellation_reason_and_more.py b/apps/events/migrations/0016_registration_cancellation_reason_and_more.py new file mode 100644 index 0000000..44237b0 --- /dev/null +++ b/apps/events/migrations/0016_registration_cancellation_reason_and_more.py @@ -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), + ), + ] diff --git a/apps/events/models.py b/apps/events/models.py index 0d00bf1..2005143 100644 --- a/apps/events/models.py +++ b/apps/events/models.py @@ -108,8 +108,15 @@ class Event(BaseModel): @property def current_attendees_count(self): - """Count confirmed attendees""" - return self.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], is_deleted=False).count() + """Count confirmed attendees and currently held paid-checkout seats.""" + 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 def has_available_slots(self): @@ -132,9 +139,24 @@ class Registration(BaseModel): SYSTEM = 'system', 'System' 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') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations') 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, default=StatusChoices.PENDING) attendance_verified_at = models.DateTimeField(null=True, blank=True) diff --git a/apps/events/tasks.py b/apps/events/tasks.py index 668d2c5..bdabfa2 100644 --- a/apps/events/tasks.py +++ b/apps/events/tasks.py @@ -5,12 +5,19 @@ import logging from celery import shared_task from apps.events.models import Registration +from apps.events.checkout import expire_stale_registration_checkouts from apps.notifications.services import notify_user from apps.users.tasks import send_critical_sms 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) def send_registration_confirmation_notification(self, registration_pk: str): try: diff --git a/apps/events/tests/integration/test_events.py b/apps/events/tests/integration/test_events.py index 0a64171..80a7a4a 100644 --- a/apps/events/tests/integration/test_events.py +++ b/apps/events/tests/integration/test_events.py @@ -3,7 +3,7 @@ import io import json import tempfile import uuid -from datetime import timedelta +from datetime import datetime, time as datetime_time, timedelta from types import SimpleNamespace from PIL import Image from django.core.files.uploadedfile import SimpleUploadedFile @@ -110,6 +110,115 @@ class TestEventsAPIIntegrationTests: assert response.status_code == 200 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): 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) diff --git a/apps/events/tests/integration/test_registration_checkout.py b/apps/events/tests/integration/test_registration_checkout.py new file mode 100644 index 0000000..f9c6fc5 --- /dev/null +++ b/apps/events/tests/integration/test_registration_checkout.py @@ -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) diff --git a/apps/events/tests/unit/test_admin_actions.py b/apps/events/tests/unit/test_admin_actions.py index c742bcd..4be8fcf 100644 --- a/apps/events/tests/unit/test_admin_actions.py +++ b/apps/events/tests/unit/test_admin_actions.py @@ -22,13 +22,21 @@ def test_event_admin_formats_empty_price_and_capacity(): def test_event_admin_status_actions_update_selection(mocker): model_admin = EventAdmin(Event, admin.site) mocker.patch.object(model_admin, "message_user") + cancel_checkouts = mocker.patch("apps.events.admin.cancel_event_checkouts") queryset = mocker.Mock() queryset.count.return_value = 2 for action, status in ( (model_admin.make_published, Event.StatusChoices.PUBLISHED), (model_admin.make_draft, Event.StatusChoices.DRAFT), - (model_admin.make_cancelled, Event.StatusChoices.CANCELLED), (model_admin.make_completed, Event.StatusChoices.COMPLETED), ): action(SimpleNamespace(), queryset) 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) diff --git a/apps/payments/admin.py b/apps/payments/admin.py index a56e4a8..29342b5 100644 --- a/apps/payments/admin.py +++ b/apps/payments/admin.py @@ -52,18 +52,26 @@ class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin): list_display = ( '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 = ( - 'status', 'event', + 'status', 'refund_status', 'event', 'verification_manual_review_at', SoftDeleteListFilter, ) search_fields = ( 'user__email', 'authority', 'ref_id', 'discount_code__code' ) readonly_fields = ( - 'user', 'event', 'base_amount', 'discount_code', 'discount_code', 'discount_amount', 'amount', 'authority', - 'status', 'ref_id', 'card_pan', 'card_hash', 'created_at', 'updated_at', 'deleted_at' + 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount', 'authority', + '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 = ( @@ -74,7 +82,20 @@ class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin): 'fields': ('base_amount', 'discount_code', 'discount_amount', 'amount') }), ('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', { 'fields': ('is_deleted', 'deleted_at'), diff --git a/apps/payments/api/schemas.py b/apps/payments/api/schemas.py index 34c83aa..c7b0d5a 100644 --- a/apps/payments/api/schemas.py +++ b/apps/payments/api/schemas.py @@ -71,21 +71,44 @@ class PaymentDetailOut(Schema): amount: int status: str 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 class UserPaymentEventSchema(Schema): + id: int title: str slug: str image_url: Optional[str] = None class UserPaymentSchema(Schema): + payment_id: int event: UserPaymentEventSchema base_amount: int discount_amount: int amount: int status: Literal["initiated", "pending", "paid", "failed", "canceled"] + refund_status: Literal["none", "pending", "succeeded", "manual_review"] = "none" created_at: datetime paid_at: Optional[datetime] = None diff --git a/apps/payments/api/views.py b/apps/payments/api/views.py index b349620..efde831 100644 --- a/apps/payments/api/views.py +++ b/apps/payments/api/views.py @@ -1,5 +1,7 @@ import logging +from datetime import datetime from uuid import uuid4 +from urllib.parse import quote from django.conf import settings from django.shortcuts import redirect, get_object_or_404 @@ -14,8 +16,18 @@ import requests from apps.payments.models import Payment, DiscountCode from apps.events.models import Event, Registration +from apps.events.checkout import ( + CHECKOUT_TIMEOUT, + expire_stale_registration_checkouts, + queue_checkout_reconciliation, +) from apps.notifications.services import notify_user -from apps.payments.services import PaymentVerificationUncertain, 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.users.tasks import send_critical_sms from core.authentication import jwt_auth @@ -55,7 +67,9 @@ def my_payments(request): def _user_payment_payload(request, payment, status_names): return { + "payment_id": payment.id, "event": { + "id": payment.event_id, "title": payment.event.title, "slug": payment.event.slug, "image_url": request.build_absolute_uri(payment.event.featured_image.url) @@ -66,6 +80,7 @@ def _user_payment_payload(request, payment, status_names): "discount_amount": payment.discount_amount, "amount": payment.amount, "status": status_names[payment.status], + "refund_status": payment.refund_status, "created_at": payment.created_at, "paid_at": payment.verified_at, } @@ -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) def create_payment(request, payload: CreatePaymentIn): idempotency_key = payload.idempotency_key or uuid4() + now = timezone.now() + expire_stale_registration_checkouts(now=now, event_id=payload.event_id) # Serialize checkout creation for this event so concurrent taps cannot # both create a new active attempt for the same user/event pair. @@ -282,19 +326,48 @@ def create_payment(request, payload: CreatePaymentIn): pk=payload.event_id, is_deleted=False, ) + if event.status == Event.StatusChoices.CANCELLED: + raise HttpError(400, "این رویداد لغو شده است و امکان پرداخت ندارد.") keyed_payment = Payment.objects.filter( user=request.auth, event=event, idempotency_key=idempotency_key, - ).select_related("event").first() + ).select_related("event", "registration").first() if keyed_payment: + _ensure_checkout_attempt_can_be_reused( + keyed_payment, + now=now, + allow_review_replay=True, + ) return _payment_checkout_payload(keyed_payment) + unresolved_refund = Payment.objects.filter( + user=request.auth, + event=event, + registration__cancellation_reason__in=[ + Registration.CancellationReasonChoices.CHECKOUT_EXPIRED, + Registration.CancellationReasonChoices.USER_CANCELLED, + Registration.CancellationReasonChoices.EVENT_CANCELLED, + ], + status=Payment.OrderStatusChoices.PAID, + refund_status__in=[ + Payment.RefundStatusChoices.PENDING, + Payment.RefundStatusChoices.MANUAL_REVIEW, + ], + ).exists() + if unresolved_refund: + raise HttpError(409, "بازگشت وجه پرداخت قبلی هنوز نهایی نشده است. پس از بررسی دوباره تلاش کنید.") + if Payment.objects.filter( status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event, + refund_status__in=[ + Payment.RefundStatusChoices.NONE, + Payment.RefundStatusChoices.PENDING, + Payment.RefundStatusChoices.MANUAL_REVIEW, + ], ).exists(): raise HttpError(400, "You have already registered in this event") @@ -304,10 +377,12 @@ def create_payment(request, payload: CreatePaymentIn): event=event, status__in=[Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING], ) + .select_related("registration") .order_by("-created_at", "-id") .first() ) if active_payment: + _ensure_checkout_attempt_can_be_reused(active_payment, now=now) return _payment_checkout_payload(active_payment) registration = ( @@ -316,11 +391,29 @@ def create_payment(request, payload: CreatePaymentIn): .order_by("-registered_at") .first() ) + if registration and registration.status in ( + Registration.StatusChoices.CONFIRMED, + Registration.StatusChoices.ATTENDED, + ): + raise HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید.") + + needs_new_checkout = not registration or registration.status == Registration.StatusChoices.CANCELLED + if needs_new_checkout: + if event.status != Event.StatusChoices.PUBLISHED: + raise HttpError(400, "ثبت‌نام این رویداد فعال نیست.") + if event.registration_start_date and event.registration_start_date > now: + raise HttpError(400, "زمان ثبت‌نام هنوز آغاز نشده است") + if event.registration_end_date and event.registration_end_date < now: + raise HttpError(400, "مهلت ثبت‌نام به پایان رسیده‌است") + if not event.has_available_slots: + raise HttpError(400, "ظرفیت شرکت‌کنندگان تکمیل است") if not registration or registration.status == Registration.StatusChoices.CANCELLED: registration = Registration.objects.create( event=event, user=request.auth, status=Registration.StatusChoices.PENDING, + checkout_started_at=now, + checkout_expires_at=now + CHECKOUT_TIMEOUT, final_price=event.price, ) elif registration.final_price is None: @@ -331,7 +424,17 @@ def create_payment(request, payload: CreatePaymentIn): discount_amount = 0 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( code=payload.discount_code, applicable_events=event, @@ -421,7 +524,8 @@ def create_payment(request, payload: CreatePaymentIn): pay.id, ) 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) data = jd.get("data") if isinstance(jd, dict) else None @@ -441,31 +545,122 @@ def create_payment(request, payload: CreatePaymentIn): pay.id, ) 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) - pay.authority = authority - pay.status = Payment.OrderStatusChoices.PENDING - pay.save(update_fields=["authority","status"]) + with transaction.atomic(): + event = Event.objects.select_for_update().get(pk=pay.event_id) + registration = None + if pay.registration_id: + registration = Registration.objects.select_for_update().filter(pk=pay.registration_id).first() + pay = Payment.objects.select_for_update().get(pk=pay.id) + pay.authority = authority + if pay.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING): + pay.status = Payment.OrderStatusChoices.PENDING + pay.verification_manual_review_at = None + pay.save(update_fields=["authority", "status", "verification_manual_review_at"]) + if ( + registration is not None + and registration.status == Registration.StatusChoices.CANCELLED + ) or event.status == Event.StatusChoices.CANCELLED: + if registration is not None: + queue_checkout_reconciliation(registration) return _payment_checkout_payload(pay) -def _callback_redirect(payment: Payment, status: str): - url = ( - f"{settings.FRONTEND_CALLBACK_URL}?status={status}" - f"&event_id={payment.event_id}&payment_id={payment.id}" - ) - if status == "success" and payment.ref_id: - url += f"&ref_id={payment.ref_id}" +def _frontend_root_url(): + root = (getattr(settings, "FRONTEND_ROOT", "") or settings.FRONTEND_CALLBACK_URL).rstrip("/") + return root + + +def _callback_redirect(payment: Payment, _status: str): + """Return to the authenticated hub; its API read is the status authority.""" + event = payment.event + url = f"{_frontend_root_url()}/events/{quote(event.slug or str(event.id), safe='')}/registration" + if payment.registration_id: + url += f"?registration_id={payment.registration.ticket_id}" + else: + url += f"?payment_id={payment.id}" return redirect(url) +def _callback_unknown_redirect(): + """Send malformed or stale provider callbacks to the designed safe fallback.""" + return redirect(f"{_frontend_root_url()}/events?payment_callback=unmatched") + + +def _provider_paid_at(verification_data: dict | None): + """Read an explicit timezone-aware payment time if the gateway supplies one.""" + raw_value = verification_data.get("paid_at") if isinstance(verification_data, dict) else None + if isinstance(raw_value, datetime): + return raw_value if timezone.is_aware(raw_value) else None + if not isinstance(raw_value, str) or not raw_value.strip(): + return None + try: + value = datetime.fromisoformat(raw_value.strip().replace("Z", "+00:00")) + except ValueError: + return None + return value if timezone.is_aware(value) else None + + +def _checkout_may_be_confirmed(event, registration, paid_at, now): + if registration is None: + return False + if registration.status in ( + Registration.StatusChoices.CONFIRMED, + Registration.StatusChoices.ATTENDED, + ): + return True + if event.status == Event.StatusChoices.CANCELLED: + return False + + expiry = registration.checkout_expires_at + if registration.status == Registration.StatusChoices.PENDING: + if registration.cancellation_reason: + return False + if expiry is None or expiry > now: + return True + + if ( + registration.status == Registration.StatusChoices.CANCELLED + and registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED + and expiry is not None + and paid_at is not None + and paid_at <= expiry + and event.has_available_slots + ): + return True + return False + + +def _queue_payment_refund(payment_id): + from apps.payments.tasks import refund_payment + + def dispatch(): + try: + refund_payment.apply_async(args=(payment_id,), countdown=0) + except Exception: + logger.exception("Unable to dispatch refund reversal for payment %s", payment_id) + + transaction.on_commit( + dispatch + ) + + def _mark_payment_paid(payment_id: int, verification_data: dict | None = None): - """Persist 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(): + event = Event.objects.select_for_update().get(pk=payment_snapshot.event_id) + registration = None + if payment_snapshot.registration_id: + registration = Registration.objects.select_for_update().filter( + pk=payment_snapshot.registration_id + ).first() payment = ( Payment.objects.select_for_update() - .select_related("event", "user") .get(pk=payment_id) ) 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.verified_at = timezone.now() updates.extend(["ref_id", "card_pan", "card_hash", "verified_at"]) + if paid_at is not None and payment.gateway_paid_at is None: + payment.gateway_paid_at = paid_at + updates.append("gateway_paid_at") + for field in ( + "verification_next_attempt_at", + "verification_lease_until", + "verification_dispatch_expires_at", + "verification_manual_review_at", + ): + if getattr(payment, field) is not None: + setattr(payment, field, None) + updates.append(field) if updates: payment.save(update_fields=updates) - registration = None - if payment.registration_id: - registration = Registration.objects.select_for_update().filter( - pk=payment.registration_id - ).first() - if registration is None: - registration = Registration.objects.select_for_update().filter( - user_id=payment.user_id, - event_id=payment.event_id, - status=Registration.StatusChoices.PENDING, - ).first() - if registration and registration.status != Registration.StatusChoices.CONFIRMED: - registration.status = Registration.StatusChoices.CONFIRMED - registration_updates = ["status"] + now = timezone.now() + if registration and registration.status == Registration.StatusChoices.PENDING: + if event.status == Event.StatusChoices.CANCELLED: + registration.status = Registration.StatusChoices.CANCELLED + registration.cancellation_reason = Registration.CancellationReasonChoices.EVENT_CANCELLED + registration.save(update_fields=["status", "cancellation_reason", "updated_at"]) + elif ( + registration.checkout_expires_at is not None + and registration.checkout_expires_at <= now + and not registration.cancellation_reason + ): + registration.status = Registration.StatusChoices.CANCELLED + registration.cancellation_reason = Registration.CancellationReasonChoices.CHECKOUT_EXPIRED + registration.save(update_fields=["status", "cancellation_reason", "updated_at"]) + + if _checkout_may_be_confirmed(event, registration, paid_at, now): + if registration.status == Registration.StatusChoices.CANCELLED: + registration.cancellation_reason = "" + registration.status = Registration.StatusChoices.CONFIRMED + registration_updates = ["status", "cancellation_reason"] + else: + registration.status = Registration.StatusChoices.CONFIRMED + registration_updates = ["status"] if registration.final_price is None: registration.final_price = payment.amount registration_updates.append("final_price") registration.save(update_fields=registration_updates) + if payment.refund_status != Payment.RefundStatusChoices.NONE: + payment.refund_status = Payment.RefundStatusChoices.NONE + payment.refund_last_error = "" + payment.refund_next_attempt_at = None + payment.save(update_fields=[ + "refund_status", "refund_last_error", "refund_next_attempt_at", + ]) + elif not was_already_verified and payment.refund_status not in ( + Payment.RefundStatusChoices.SUCCEEDED, + Payment.RefundStatusChoices.MANUAL_REVIEW, + ): + payment.refund_status = Payment.RefundStatusChoices.PENDING + payment.refund_next_attempt_at = None + payment.save(update_fields=["refund_status", "refund_next_attempt_at"]) + _queue_payment_refund(payment.id) return payment, not was_already_verified @@ -516,7 +747,11 @@ def _mark_payment_pending(payment_id: int): if payment.status != Payment.OrderStatusChoices.PAID: payment.status = Payment.OrderStatusChoices.PAID payment.save(update_fields=["status"]) - elif payment.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.CANCELED): + elif payment.status in ( + Payment.OrderStatusChoices.INIT, + Payment.OrderStatusChoices.FAILED, + Payment.OrderStatusChoices.CANCELED, + ): payment.status = Payment.OrderStatusChoices.PENDING payment.save(update_fields=["status"]) return payment @@ -524,10 +759,16 @@ def _mark_payment_pending(payment_id: int): def _mark_payment_unsuccessful(payment_id: int, target_status: int): """Apply a failure only while the payment has not been verified successfully.""" + snapshot = Payment.objects.only("event_id", "registration_id", "user_id").get(pk=payment_id) with transaction.atomic(): + Event.objects.select_for_update().get(pk=snapshot.event_id) + registration = None + if snapshot.registration_id: + registration = Registration.objects.select_for_update().filter( + pk=snapshot.registration_id, + ).first() payment = ( Payment.objects.select_for_update() - .select_related("event", "user") .get(pk=payment_id) ) if ( @@ -551,7 +792,17 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int): transitioned = False else: 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 = ( "canceled" if target_status == Payment.OrderStatusChoices.CANCELED @@ -559,13 +810,7 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int): ) transitioned = True - registration = None - if payment.registration_id: - registration = Registration.objects.select_for_update().filter( - pk=payment.registration_id, - status=Registration.StatusChoices.PENDING, - ).first() - else: + if registration is None: registration = Registration.objects.select_for_update().filter( user_id=payment.user_id, event_id=payment.event_id, @@ -589,7 +834,10 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int): Registration.objects.filter( pk=registration.pk, status=Registration.StatusChoices.PENDING, - ).update(status=Registration.StatusChoices.CANCELLED) + ).update( + status=Registration.StatusChoices.CANCELLED, + cancellation_reason=Registration.CancellationReasonChoices.PAYMENT_FAILED, + ) return payment, result, transitioned @@ -597,49 +845,96 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int): @payments_router.get("callback") def callback(request, Authority: str | None = None, Status: str | None = None): 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() 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: pay, _ = _mark_payment_paid(pay.id) return _callback_redirect(pay, "success") + if pay.status in ( + Payment.OrderStatusChoices.FAILED, + Payment.OrderStatusChoices.CANCELED, + ) and not gateway_says_success: + return _callback_redirect(pay, _payment_status_name(pay)) + + pay, claimed = claim_payment_verification( + pay.id, + allow_terminal=gateway_says_success, + allow_manual_review=gateway_says_success, + ) + if not claimed: + return _callback_redirect(pay, _payment_status_name(pay)) try: vcode, data = request_payment_verification(pay) except PaymentVerificationUncertain: pay = _mark_payment_pending(pay.id) - 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: - reconcile_payment.apply_async(args=(pay.id,), countdown=60) + reconcile_payment.apply_async(args=(pay.id,), countdown=delay) except 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, ) + elif delay is None and pay.status == Payment.OrderStatusChoices.PENDING: + logger.error( + "Payment %s remains pending after exhausting verification attempts; manual review is required", + pay.id, + ) return _callback_redirect(pay, _payment_status_name(pay)) if vcode in (100, 101): pay, newly_verified = _mark_payment_paid(pay.id, data) if newly_verified: + refund_pending = pay.refund_status in ( + Payment.RefundStatusChoices.PENDING, + Payment.RefundStatusChoices.MANUAL_REVIEW, + ) _notify_payment_status( pay, - title=f"پرداخت رویداد «{pay.event.title}» تأیید شد", - message="پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است.", - level="success", + title=( + f"پرداخت رویداد «{pay.event.title}» تأیید شد؛ بازگشت وجه در حال پیگیری است" + if refund_pending + else f"پرداخت رویداد «{pay.event.title}» تأیید شد" + ), + message=( + "ثبت‌نام لغو شده است. بازگشت وجه به‌صورت خودکار پیگیری می‌شود؛ اگر نتیجه روشن نشود، پشتیبانی آن را بررسی می‌کند." + if refund_pending + else "پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است." + ), + level="warning" if refund_pending else "success", ) return _callback_redirect(pay, "success") - 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: + canceled = result == "canceled" _notify_payment_status( pay, - title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود", - message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.", - level="error", + title=( + f"پرداخت رویداد «{pay.event.title}» لغو شد" + if canceled + else f"پرداخت رویداد «{pay.event.title}» ناموفق بود" + ), + message=( + "پرداخت تکمیل نشد و ثبت‌نام نهایی نشد. برای شروع دوباره به صفحه رویداد برگردید." + if canceled + else "تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید." + ), + level="warning" if canceled else "error", ) return _callback_redirect(pay, result) @@ -666,6 +961,34 @@ def _payment_detail_payload(request, payment: Payment): "amount": payment.amount, "status": _payment_status_name(payment), "verified_at": payment.verified_at.isoformat() if payment.verified_at else None, + "gateway_paid_at": payment.gateway_paid_at.isoformat() if payment.gateway_paid_at else None, + "refund_status": payment.refund_status, + "refund_attempts": payment.refund_attempts, + "refund_last_attempt_at": ( + payment.refund_last_attempt_at.isoformat() if payment.refund_last_attempt_at else None + ), + "refund_next_attempt_at": ( + payment.refund_next_attempt_at.isoformat() if payment.refund_next_attempt_at else None + ), + "refund_reference": payment.refund_reference, + "refund_last_error": payment.refund_last_error or None, + "verification_state": payment.verification_state, + "verification_attempts": payment.verification_attempts, + "last_verification_at": ( + payment.verification_last_attempt_at.isoformat() + if payment.verification_last_attempt_at + else None + ), + "next_verification_at": ( + payment.verification_next_attempt_at.isoformat() + if payment.verification_next_attempt_at + else None + ), + "manual_review_at": ( + payment.verification_manual_review_at.isoformat() + if payment.verification_manual_review_at + else None + ), "event": { "id": event.id, "title": event.title, @@ -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): - 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) @payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth) diff --git a/apps/payments/migrations/0005_payment_verification_attempts_and_more.py b/apps/payments/migrations/0005_payment_verification_attempts_and_more.py new file mode 100644 index 0000000..f8ba7a2 --- /dev/null +++ b/apps/payments/migrations/0005_payment_verification_attempts_and_more.py @@ -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), + ), + ] diff --git a/apps/payments/migrations/0006_payment_refund_attempts_and_more.py b/apps/payments/migrations/0006_payment_refund_attempts_and_more.py new file mode 100644 index 0000000..9751961 --- /dev/null +++ b/apps/payments/migrations/0006_payment_refund_attempts_and_more.py @@ -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), + ), + ] diff --git a/apps/payments/migrations/0007_payment_gateway_paid_at.py b/apps/payments/migrations/0007_payment_gateway_paid_at.py new file mode 100644 index 0000000..d285ca3 --- /dev/null +++ b/apps/payments/migrations/0007_payment_gateway_paid_at.py @@ -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), + ), + ] diff --git a/apps/payments/models.py b/apps/payments/models.py index 9180b89..ab9a876 100644 --- a/apps/payments/models.py +++ b/apps/payments/models.py @@ -81,6 +81,12 @@ class Payment(BaseModel): FAILED = 3, "Failed" 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) 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_hash = models.CharField(max_length=128, 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: constraints = [ @@ -127,5 +151,24 @@ class Payment(BaseModel): """Human-readable label for the payment status.""" 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): return f"{self.user.email}:{self.event} - {self.get_status_display()}" diff --git a/apps/payments/services.py b/apps/payments/services.py index cad7dd4..cebed60 100644 --- a/apps/payments/services.py +++ b/apps/payments/services.py @@ -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.db import transaction +from django.utils import timezone import requests @@ -8,6 +12,14 @@ class PaymentVerificationUncertain(Exception): """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): try: response = requests.post( @@ -46,3 +58,184 @@ def request_payment_verification(payment): ) 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", + ]) diff --git a/apps/payments/tasks.py b/apps/payments/tasks.py index fec2f8a..cffd572 100644 --- a/apps/payments/tasks.py +++ b/apps/payments/tasks.py @@ -1,32 +1,63 @@ -"""Bounded retries for payment verification after an inconclusive callback.""" +"""Bounded payment verification and recovery for lost gateway callbacks.""" import logging +from datetime import timedelta 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.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__) -@shared_task(bind=True, max_retries=5, name="apps.payments.reconcile_payment") -def reconcile_payment(self, payment_id: int): - payment = get_object_or_404( - Payment.objects.select_related("event", "user"), - pk=payment_id, - ) - 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" +def _status_result(payment): + return { + Payment.OrderStatusChoices.PAID: "paid", + Payment.OrderStatusChoices.FAILED: "failed", + Payment.OrderStatusChoices.CANCELED: "canceled", + }.get(payment.status) - # 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 ( _mark_payment_paid, _mark_payment_pending, @@ -38,24 +69,37 @@ def reconcile_payment(self, payment_id: int): code, data = request_payment_verification(payment) except PaymentVerificationUncertain as exc: _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( - "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, exc_info=exc, ) return "pending_manual_review" - countdown = min(60 * (2 ** self.request.retries), 15 * 60) - raise self.retry(exc=exc, countdown=countdown) + _queue_retry(payment.id, delay) + return "retry_scheduled" if code in (100, 101): payment, newly_verified = _mark_payment_paid(payment.id, data) if newly_verified: + refund_pending = payment.refund_status in ( + Payment.RefundStatusChoices.PENDING, + Payment.RefundStatusChoices.MANUAL_REVIEW, + ) _notify_payment_status( payment, - title=f"پرداخت رویداد «{payment.event.title}» تأیید شد", - message="پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است.", - level="success", + title=( + f"پرداخت رویداد «{payment.event.title}» تأیید شد؛ بازگشت وجه در حال پیگیری است" + if refund_pending + else f"پرداخت رویداد «{payment.event.title}» تأیید شد" + ), + message=( + "ثبت‌نام لغو شده است. بازگشت وجه به‌صورت خودکار پیگیری می‌شود؛ اگر نتیجه روشن نشود، پشتیبانی آن را بررسی می‌کند." + if refund_pending + else "پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است." + ), + level="warning" if refund_pending else "success", ) return "paid" @@ -71,3 +115,204 @@ def reconcile_payment(self, payment_id: int): level="error", ) 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 diff --git a/apps/payments/tests/integration/test_payment_admin.py b/apps/payments/tests/integration/test_payment_admin.py index 5825cb7..a43a9df 100644 --- a/apps/payments/tests/integration/test_payment_admin.py +++ b/apps/payments/tests/integration/test_payment_admin.py @@ -86,7 +86,8 @@ def test_payment_callback_verification_failures_notify_once(client, user_factory else: gateway.return_value.json.return_value = gateway_result 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() registration.refresh_from_db() assert payment.status == Payment.OrderStatusChoices.PENDING diff --git a/apps/payments/tests/integration/test_payment_reconciliation.py b/apps/payments/tests/integration/test_payment_reconciliation.py index f2e3fee..5c77541 100644 --- a/apps/payments/tests/integration/test_payment_reconciliation.py +++ b/apps/payments/tests/integration/test_payment_reconciliation.py @@ -1,4 +1,7 @@ +from datetime import timedelta + import pytest +from django.utils import timezone from apps.events.models import Registration 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"}}}), ] 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")) - with pytest.raises(RuntimeError, match="retry scheduled"): - reconcile_payment.run(payment.id) - - retry.assert_called_once() - assert retry.call_args.kwargs["countdown"] == 60 + assert reconcile_payment.run(payment.id) == "retry_scheduled" + payment.refresh_from_db() + assert payment.verification_attempts == 1 + assert payment.verification_next_attempt_at is not None + enqueue.assert_called_once_with(args=(payment.id,), countdown=60) + Payment.objects.filter(pk=payment.pk).update( + verification_next_attempt_at=timezone.now() - timedelta(seconds=1) + ) gateway.side_effect = [ 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 payment.status == Payment.OrderStatusChoices.PAID 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 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) settings.ZARINPAL_MERCHANT_ID = "test" 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")) 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): - reconcile_payment.push_request(retries=retry_number) - try: - if retry_number < reconcile_payment.max_retries: - with pytest.raises(RuntimeError, match="retry scheduled"): - reconcile_payment.run(payment.id) - else: - result = reconcile_payment.run(payment.id) - finally: - reconcile_payment.pop_request() + for attempt_number in range(settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS): + Payment.objects.filter(pk=payment.pk).update( + verification_next_attempt_at=timezone.now() - timedelta(seconds=1) + ) + result = reconcile_payment.run(payment.id) + if attempt_number < settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS - 1: + assert result == "retry_scheduled" payment.refresh_from_db() registration.refresh_from_db() assert result == "pending_manual_review" - assert gateway.call_count == reconcile_payment.max_retries + 1 - assert [call.kwargs["countdown"] for call in retry.call_args_list] == [60, 120, 240, 480, 900] + assert gateway.call_count == settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS + assert payment.verification_attempts == settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS 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 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() assert payment.status == Payment.OrderStatusChoices.FAILED 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() diff --git a/apps/payments/tests/integration/test_payment_recovery_sweep.py b/apps/payments/tests/integration/test_payment_recovery_sweep.py new file mode 100644 index 0000000..ea1ede2 --- /dev/null +++ b/apps/payments/tests/integration/test_payment_recovery_sweep.py @@ -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() diff --git a/apps/payments/tests/integration/test_payments.py b/apps/payments/tests/integration/test_payments.py index 03135ab..58c0300 100644 --- a/apps/payments/tests/integration/test_payments.py +++ b/apps/payments/tests/integration/test_payments.py @@ -168,7 +168,8 @@ class TestPaymentsAPIIntegrationTests: response = self.client.get('/api/payments/callback', {'Authority': 'AUTH123', 'Status': 'OK'}) payment.refresh_from_db() 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): 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'}) payment.refresh_from_db() 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'}) - 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): 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}} response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'}) payment.refresh_from_db() - assert payment.status == Payment.OrderStatusChoices.FAILED - assert 'status=failed' in response.url + assert payment.status == Payment.OrderStatusChoices.CANCELED + 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): registration = Registration.objects.create( @@ -229,8 +240,9 @@ class TestPaymentsAPIIntegrationTests: assert payment.status == Payment.OrderStatusChoices.PAID assert payment.verified_at is not None assert registration.status == Registration.StatusChoices.CONFIRMED - assert 'status=success' in success_response.url - assert 'status=success' in late_failure_response.url + expected_return = f'/events/{self.event.slug}/registration?registration_id={registration.ticket_id}' + 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): mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down')) @@ -249,8 +261,7 @@ class TestPaymentsAPIIntegrationTests: payment.refresh_from_db() assert response.status_code == 302 - assert 'status=pending' in response.url - assert f'payment_id={payment.id}' in response.url + assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url assert payment.status == Payment.OrderStatusChoices.PENDING reconcile.assert_called_once_with(args=(payment.id,), countdown=60) notify.assert_not_called() @@ -261,12 +272,45 @@ class TestPaymentsAPIIntegrationTests: assert own.status_code == 200 assert own.json()['payment_id'] == payment.id 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_token = create_jwt_token(other) forbidden = self.client.get(f'/api/payments/status/{payment.id}', HTTP_AUTHORIZATION=f'Bearer {other_token}') 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): 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()) @@ -341,10 +385,12 @@ def test_my_payments_are_user_scoped_and_omit_gateway_identifiers(client, user_f payload = response.json() assert len(payload) == 1 assert payload[0]["event"]["title"] == own_event.title + assert payload[0]["event"]["id"] == own_event.id assert payload[0]["amount"] == 100000 assert payload[0]["discount_amount"] == 20000 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-reference-owner" not in response.content.decode() assert "internal-card-hash" not in response.content.decode() diff --git a/config/services/celery.py b/config/services/celery.py index 1067abb..8a875f0 100644 --- a/config/services/celery.py +++ b/config/services/celery.py @@ -38,6 +38,21 @@ app.conf.beat_schedule = { 'schedule': crontab(hour=2, minute=0), '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 diff --git a/config/services/zarinpal.py b/config/services/zarinpal.py index 51ec1fa..e4843f8 100644 --- a/config/services/zarinpal.py +++ b/config/services/zarinpal.py @@ -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_REQUEST_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/request.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_CALLBACK_URL = config('ZARINPAL_CALLBACK_URL', default='http://localhost:8000/api/payments/callback') diff --git a/config/settings/base.py b/config/settings/base.py index ebe4849..07c593b 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -229,6 +229,9 @@ os.makedirs(BASE_DIR / 'logs', exist_ok=True) BACKEND_ROOT = config('DJANGO_HOST', default='http://localhost:8000/') FRONTEND_ROOT = config('FRONTEND_ROOT', default='http://localhost:3000/') 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_SECRET = config('GOOGLE_OAUTH_CLIENT_SECRET', default='') GOOGLE_OAUTH_REDIRECT_URI = config('GOOGLE_OAUTH_REDIRECT_URI', default='')