feat(events): harden registration checkout lifecycle
This commit is contained in:
@@ -27,6 +27,9 @@ SMS_PAYMENT_STATUS_TEMPLATE_ID=
|
|||||||
|
|
||||||
FRONTEND_ROOT=http://localhost:8080
|
FRONTEND_ROOT=http://localhost:8080
|
||||||
FRONTEND_CALLBACK_URL=http://localhost:8080/payments/result
|
FRONTEND_CALLBACK_URL=http://localhost:8080/payments/result
|
||||||
|
PAYMENT_VERIFICATION_GRACE_SECONDS=1800
|
||||||
|
PAYMENT_VERIFICATION_MAX_ATTEMPTS=6
|
||||||
|
PAYMENT_VERIFICATION_DISPATCH_LEASE_SECONDS=900
|
||||||
GOOGLE_OAUTH_REDIRECT_URI=http://127.0.0.1:8000/api/auth/oauth/google/callback
|
GOOGLE_OAUTH_REDIRECT_URI=http://127.0.0.1:8000/api/auth/oauth/google/callback
|
||||||
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=http://localhost:8080/auth/google/callback
|
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=http://localhost:8080/auth/google/callback
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ CSRF_COOKIE_SECURE=True
|
|||||||
SESSION_COOKIE_SECURE=True
|
SESSION_COOKIE_SECURE=True
|
||||||
FRONTEND_ROOT=https://frontend.example.com
|
FRONTEND_ROOT=https://frontend.example.com
|
||||||
FRONTEND_CALLBACK_URL=https://frontend.example.com/payments/result
|
FRONTEND_CALLBACK_URL=https://frontend.example.com/payments/result
|
||||||
|
PAYMENT_VERIFICATION_GRACE_SECONDS=1800
|
||||||
|
PAYMENT_VERIFICATION_MAX_ATTEMPTS=6
|
||||||
|
PAYMENT_VERIFICATION_DISPATCH_LEASE_SECONDS=900
|
||||||
|
|
||||||
# SSE Notifications
|
# SSE Notifications
|
||||||
NOTIFICATIONS_ENABLED=True
|
NOTIFICATIONS_ENABLED=True
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from core.templatetags.jalali import jdate
|
|||||||
from apps.events.models import Event, Registration
|
from apps.events.models import Event, Registration
|
||||||
from apps.events.resources import EventResource, RegistrationResource
|
from apps.events.resources import EventResource, RegistrationResource
|
||||||
from apps.events.services import verify_registration_attendance
|
from apps.events.services import verify_registration_attendance
|
||||||
|
from apps.events.checkout import cancel_event_checkouts
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Event)
|
@admin.register(Event)
|
||||||
@@ -79,8 +80,14 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
|||||||
|
|
||||||
@admin.action(description='Mark selected events as cancelled')
|
@admin.action(description='Mark selected events as cancelled')
|
||||||
def make_cancelled(self, request, queryset):
|
def make_cancelled(self, request, queryset):
|
||||||
queryset.update(status=Event.StatusChoices.CANCELLED)
|
count = 0
|
||||||
self.message_user(request, f'Cancelled {queryset.count()} events.')
|
for event in queryset:
|
||||||
|
if event.status != Event.StatusChoices.CANCELLED:
|
||||||
|
event.status = Event.StatusChoices.CANCELLED
|
||||||
|
event.save(update_fields=['status'])
|
||||||
|
cancel_event_checkouts(event.pk)
|
||||||
|
count += 1
|
||||||
|
self.message_user(request, f'Cancelled {count} events.')
|
||||||
|
|
||||||
@admin.action(description='Mark selected events as completed')
|
@admin.action(description='Mark selected events as completed')
|
||||||
def make_completed(self, request, queryset):
|
def make_completed(self, request, queryset):
|
||||||
@@ -93,15 +100,27 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
|||||||
event.restore()
|
event.restore()
|
||||||
self.message_user(request, f'Restored {queryset.count()} events.')
|
self.message_user(request, f'Restored {queryset.count()} events.')
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
previous_status = None
|
||||||
|
if change and obj.pk:
|
||||||
|
previous_status = Event.all_objects.filter(pk=obj.pk).values_list("status", flat=True).first()
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
if previous_status != Event.StatusChoices.CANCELLED and obj.status == Event.StatusChoices.CANCELLED:
|
||||||
|
cancel_event_checkouts(obj.pk)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Registration)
|
@admin.register(Registration)
|
||||||
class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
resource_class = RegistrationResource
|
resource_class = RegistrationResource
|
||||||
list_display = (
|
list_display = (
|
||||||
'user', 'event', 'status', 'registered_at', 'ticket_id',
|
'user', 'event', 'status', 'registered_at', 'checkout_expires_at',
|
||||||
|
'cancellation_reason', 'ticket_id',
|
||||||
'discount_code', 'discount_amount', 'final_price',
|
'discount_code', 'discount_amount', 'final_price',
|
||||||
)
|
)
|
||||||
list_filter = ('status', 'event', 'is_deleted', 'registered_at', SoftDeleteListFilter)
|
list_filter = (
|
||||||
|
'status', 'cancellation_reason', 'event', 'is_deleted', 'registered_at',
|
||||||
|
SoftDeleteListFilter,
|
||||||
|
)
|
||||||
search_fields = (
|
search_fields = (
|
||||||
'user__username', 'user__email', 'user__first_name', 'user__last_name',
|
'user__username', 'user__email', 'user__first_name', 'user__last_name',
|
||||||
'event__title', 'ticket_id',
|
'event__title', 'ticket_id',
|
||||||
@@ -109,13 +128,15 @@ class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
|||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
'ticket_id', 'registered_at', 'confirmation_notification_sent_at',
|
'ticket_id', 'registered_at', 'confirmation_notification_sent_at',
|
||||||
'cancellation_notification_sent_at', 'discount_code', 'discount_amount',
|
'cancellation_notification_sent_at', 'discount_code', 'discount_amount',
|
||||||
'final_price', 'deleted_at', 'attendance_verified_at',
|
'final_price', 'checkout_started_at', 'checkout_expires_at',
|
||||||
|
'cancellation_reason', 'deleted_at', 'attendance_verified_at',
|
||||||
'attendance_verified_by', 'attendance_verification_source',
|
'attendance_verified_by', 'attendance_verification_source',
|
||||||
)
|
)
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Registration Details', {'fields': (
|
('Registration Details', {'fields': (
|
||||||
'user', 'event', 'status', 'registered_at', 'ticket_id',
|
'user', 'event', 'status', 'registered_at', 'ticket_id',
|
||||||
'confirmation_notification_sent_at', 'cancellation_notification_sent_at',
|
'confirmation_notification_sent_at', 'cancellation_notification_sent_at',
|
||||||
|
'checkout_started_at', 'checkout_expires_at', 'cancellation_reason',
|
||||||
)}),
|
)}),
|
||||||
('Attendance verification', {'fields': (
|
('Attendance verification', {'fields': (
|
||||||
'attendance_verified_at', 'attendance_verified_by', 'attendance_verification_source',
|
'attendance_verified_at', 'attendance_verified_by', 'attendance_verification_source',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from ninja import ModelSchema, Schema
|
|||||||
from pydantic import field_validator
|
from pydantic import field_validator
|
||||||
from typing import Literal, Optional, List
|
from typing import Literal, Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.blog.api.schemas import AuthorSchema
|
from apps.blog.api.schemas import AuthorSchema
|
||||||
from apps.events.models import Event, Registration
|
from apps.events.models import Event, Registration
|
||||||
@@ -85,7 +87,11 @@ class EventSchema(ModelSchema):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_registration_count(obj):
|
def resolve_registration_count(obj):
|
||||||
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
now = timezone.now()
|
||||||
|
return obj.registrations.filter(is_deleted=False).filter(
|
||||||
|
Q(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
|
||||||
|
| Q(status=Registration.StatusChoices.PENDING, checkout_expires_at__gt=now)
|
||||||
|
).count()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_description_html(obj):
|
def resolve_description_html(obj):
|
||||||
@@ -102,6 +108,7 @@ class EventListSchema(Schema):
|
|||||||
absolute_featured_image_thumbnail_url: Optional[str] = None
|
absolute_featured_image_thumbnail_url: Optional[str] = None
|
||||||
absolute_featured_image_preview_url: Optional[str] = None
|
absolute_featured_image_preview_url: Optional[str] = None
|
||||||
event_type: str
|
event_type: str
|
||||||
|
address: Optional[str] = None
|
||||||
start_time: datetime
|
start_time: datetime
|
||||||
end_time: datetime
|
end_time: datetime
|
||||||
registration_start_date: Optional[datetime] = None
|
registration_start_date: Optional[datetime] = None
|
||||||
@@ -133,7 +140,22 @@ class EventListSchema(Schema):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_registration_count(obj):
|
def resolve_registration_count(obj):
|
||||||
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
if hasattr(obj, "browse_registration_count"):
|
||||||
|
return obj.browse_registration_count
|
||||||
|
now = timezone.now()
|
||||||
|
return obj.registrations.filter(is_deleted=False).filter(
|
||||||
|
Q(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
|
||||||
|
| Q(status=Registration.StatusChoices.PENDING, checkout_expires_at__gt=now)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedEventBrowseSchema(Schema):
|
||||||
|
count: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total_pages: int
|
||||||
|
price_ceiling: int
|
||||||
|
results: List[EventListSchema]
|
||||||
|
|
||||||
class EventCreateSchema(Schema):
|
class EventCreateSchema(Schema):
|
||||||
"""Payload for creating events via the API."""
|
"""Payload for creating events via the API."""
|
||||||
@@ -185,6 +207,9 @@ class RegistrationSchema(ModelSchema):
|
|||||||
'id',
|
'id',
|
||||||
'status',
|
'status',
|
||||||
'registered_at',
|
'registered_at',
|
||||||
|
'checkout_started_at',
|
||||||
|
'checkout_expires_at',
|
||||||
|
'cancellation_reason',
|
||||||
'ticket_id',
|
'ticket_id',
|
||||||
'discount_amount',
|
'discount_amount',
|
||||||
'final_price',
|
'final_price',
|
||||||
@@ -326,9 +351,16 @@ class EventBriefSchema(Schema):
|
|||||||
class MyEventRegistrationOut(Schema):
|
class MyEventRegistrationOut(Schema):
|
||||||
"""Registration information as returned to authenticated users."""
|
"""Registration information as returned to authenticated users."""
|
||||||
id: int
|
id: int
|
||||||
|
ticket_id: UUID
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
status: Literal["pending", "confirmed", "cancelled", "attended"]
|
status: Literal["pending", "confirmed", "cancelled", "attended"]
|
||||||
event: EventBriefSchema
|
event: EventBriefSchema
|
||||||
|
checkout_state: Literal["none", "active", "legacy_pending", "expired", "unresolved", "cancelled"] = "none"
|
||||||
|
checkout_expires_at: Optional[datetime] = None
|
||||||
|
payment_id: Optional[int] = None
|
||||||
|
payment_status: Optional[Literal["initiated", "pending", "paid", "failed", "canceled"]] = None
|
||||||
|
refund_status: Optional[Literal["none", "pending", "succeeded", "manual_review"]] = None
|
||||||
|
can_resume: bool = False
|
||||||
|
|
||||||
|
|
||||||
class PaginatedMyEventRegistrationsSchema(Schema):
|
class PaginatedMyEventRegistrationsSchema(Schema):
|
||||||
@@ -340,6 +372,79 @@ class PaginatedMyEventRegistrationsSchema(Schema):
|
|||||||
|
|
||||||
class RegistrationStatusOut(Schema):
|
class RegistrationStatusOut(Schema):
|
||||||
is_registered: bool
|
is_registered: bool
|
||||||
|
checkout_state: Literal[
|
||||||
|
"none", "confirmed", "active", "legacy_pending", "expired", "unresolved", "cancelled"
|
||||||
|
] = "none"
|
||||||
|
registration_id: Optional[int] = None
|
||||||
|
registration_status: Optional[str] = None
|
||||||
|
ticket_id: Optional[UUID] = None
|
||||||
|
checkout_expires_at: Optional[datetime] = None
|
||||||
|
payment_id: Optional[int] = None
|
||||||
|
payment_status: Optional[Literal["initiated", "pending", "paid", "failed", "canceled"]] = None
|
||||||
|
refund_status: Optional[Literal["none", "pending", "succeeded", "manual_review"]] = None
|
||||||
|
can_resume: bool = False
|
||||||
|
can_cancel: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationHubEventOut(Schema):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
slug: str
|
||||||
|
description: str
|
||||||
|
event_type: str
|
||||||
|
address: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
registration_start_date: Optional[datetime] = None
|
||||||
|
registration_end_date: Optional[datetime] = None
|
||||||
|
capacity: Optional[int] = None
|
||||||
|
price: int
|
||||||
|
status: str
|
||||||
|
registration_count: int
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
registration_success_markdown: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationHubRegistrationOut(Schema):
|
||||||
|
ticket_id: UUID
|
||||||
|
status: str
|
||||||
|
registered_at: datetime
|
||||||
|
checkout_started_at: Optional[datetime] = None
|
||||||
|
checkout_expires_at: Optional[datetime] = None
|
||||||
|
cancellation_reason: str = ""
|
||||||
|
final_price: Optional[int] = None
|
||||||
|
discount_amount: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationHubPaymentOut(Schema):
|
||||||
|
id: int
|
||||||
|
status: Literal["initiated", "pending", "paid", "failed", "canceled"]
|
||||||
|
verification_state: Literal[
|
||||||
|
"awaiting_verification", "checking", "retry_scheduled", "manual_review",
|
||||||
|
"verified", "failed", "canceled",
|
||||||
|
]
|
||||||
|
refund_status: Literal["none", "pending", "succeeded", "manual_review"]
|
||||||
|
base_amount: int
|
||||||
|
discount_amount: int
|
||||||
|
amount: int
|
||||||
|
ref_id: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
gateway_paid_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationHubOut(Schema):
|
||||||
|
state: Literal[
|
||||||
|
"ready", "active_checkout", "legacy_pending", "verification_pending",
|
||||||
|
"manual_review", "paid_success", "free_success", "failed", "cancelled",
|
||||||
|
"expired", "refund_pending", "refund_manual_review", "refund_succeeded",
|
||||||
|
]
|
||||||
|
event: RegistrationHubEventOut
|
||||||
|
registration: Optional[RegistrationHubRegistrationOut] = None
|
||||||
|
payment: Optional[RegistrationHubPaymentOut] = None
|
||||||
|
can_register: bool = False
|
||||||
|
can_resume: bool = False
|
||||||
|
can_cancel: bool = False
|
||||||
|
|
||||||
|
|
||||||
class RegistrationCreateSchema(Schema):
|
class RegistrationCreateSchema(Schema):
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.db.models import Q, Case, When, IntegerField, F
|
from django.db import transaction
|
||||||
|
from datetime import date, datetime, time as datetime_time, timedelta
|
||||||
|
from math import ceil
|
||||||
|
|
||||||
|
from django.db.models import Q, Case, When, IntegerField, F, Count, Max
|
||||||
from django.db.models.functions import Lower
|
from django.db.models.functions import Lower
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -17,6 +21,7 @@ from apps.events.api.schemas import (
|
|||||||
EventCreateSchema,
|
EventCreateSchema,
|
||||||
EventGallerySchema,
|
EventGallerySchema,
|
||||||
EventListSchema,
|
EventListSchema,
|
||||||
|
PaginatedEventBrowseSchema,
|
||||||
EventSchema,
|
EventSchema,
|
||||||
EventUpdateSchema,
|
EventUpdateSchema,
|
||||||
MyEventRegistrationOut,
|
MyEventRegistrationOut,
|
||||||
@@ -26,14 +31,22 @@ from apps.events.api.schemas import (
|
|||||||
RegistrationCreateSchema,
|
RegistrationCreateSchema,
|
||||||
RegistrationSchema,
|
RegistrationSchema,
|
||||||
RegistrationStatusOut,
|
RegistrationStatusOut,
|
||||||
|
RegistrationHubOut,
|
||||||
RegistrationStatusUpdateSchema,
|
RegistrationStatusUpdateSchema,
|
||||||
)
|
)
|
||||||
from core.authentication import jwt_auth
|
from core.authentication import jwt_auth
|
||||||
from apps.events.models import Event, Registration
|
from apps.events.models import Event, Registration
|
||||||
|
from apps.events.checkout import (
|
||||||
|
CHECKOUT_TIMEOUT,
|
||||||
|
cancel_event_checkouts,
|
||||||
|
expire_stale_registration_checkouts,
|
||||||
|
mark_checkout_cancelled,
|
||||||
|
queue_checkout_reconciliation,
|
||||||
|
)
|
||||||
from apps.gallery.models import Gallery
|
from apps.gallery.models import Gallery
|
||||||
from apps.gallery.tasks import process_uploaded_image
|
from apps.gallery.tasks import process_uploaded_image
|
||||||
from apps.notifications.services import notify_user
|
from apps.notifications.services import notify_user
|
||||||
from apps.payments.models import DiscountCode
|
from apps.payments.models import DiscountCode, Payment
|
||||||
from apps.users.tasks import send_critical_sms
|
from apps.users.tasks import send_critical_sms
|
||||||
from core.api.schemas import ErrorSchema, MessageSchema
|
from core.api.schemas import ErrorSchema, MessageSchema
|
||||||
|
|
||||||
@@ -138,6 +151,143 @@ def list_events(
|
|||||||
events = queryset[offset:offset + limit]
|
events = queryset[offset:offset + limit]
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def _event_registration_open_query(now):
|
||||||
|
return (
|
||||||
|
Q(status=Event.StatusChoices.PUBLISHED)
|
||||||
|
& (Q(registration_start_date__isnull=True) | Q(registration_start_date__lte=now))
|
||||||
|
& (Q(registration_end_date__isnull=True) | Q(registration_end_date__gte=now))
|
||||||
|
& (Q(capacity__isnull=True) | Q(capacity__gt=F("browse_registration_count")))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_browse_date(value: Optional[str], field_name: str):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = date.fromisoformat(value)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HttpError(400, f"{field_name} باید به شکل YYYY-MM-DD باشد.") from error
|
||||||
|
if parsed.isoformat() != value:
|
||||||
|
raise HttpError(400, f"{field_name} باید به شکل YYYY-MM-DD باشد.")
|
||||||
|
return timezone.make_aware(
|
||||||
|
datetime.combine(parsed, datetime_time.min),
|
||||||
|
timezone.get_current_timezone(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@events_router.get("/browse/", response=PaginatedEventBrowseSchema)
|
||||||
|
def browse_events(
|
||||||
|
request,
|
||||||
|
state: Literal["all", "completed", "upcoming", "open"] = "all",
|
||||||
|
event_type: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
date_to: Optional[str] = None,
|
||||||
|
price_min: Optional[int] = None,
|
||||||
|
price_max: Optional[int] = None,
|
||||||
|
free_only: bool = False,
|
||||||
|
page: int = 1,
|
||||||
|
):
|
||||||
|
"""Browse public events; price bounds and ceiling are in tomans, stored event prices are rials."""
|
||||||
|
page_size = 12
|
||||||
|
if page < 1:
|
||||||
|
raise HttpError(400, "شماره صفحه باید بزرگتر از صفر باشد.")
|
||||||
|
if event_type and event_type not in Event.TypeChoices.values:
|
||||||
|
raise HttpError(400, "نوع رویداد معتبر نیست.")
|
||||||
|
if price_min is not None and price_min < 0:
|
||||||
|
raise HttpError(400, "حداقل قیمت نمیتواند منفی باشد.")
|
||||||
|
if price_max is not None and price_max < 0:
|
||||||
|
raise HttpError(400, "حداکثر قیمت نمیتواند منفی باشد.")
|
||||||
|
if price_min is not None and price_max is not None and price_min > price_max:
|
||||||
|
raise HttpError(400, "حداقل قیمت نمیتواند از حداکثر قیمت بیشتر باشد.")
|
||||||
|
|
||||||
|
selected_from = _parse_browse_date(date_from, "date_from")
|
||||||
|
selected_to = _parse_browse_date(date_to, "date_to")
|
||||||
|
if selected_from and selected_to and selected_from > selected_to:
|
||||||
|
raise HttpError(400, "تاریخ شروع نمیتواند بعد از تاریخ پایان باشد.")
|
||||||
|
|
||||||
|
public_statuses = [Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED]
|
||||||
|
public_events = Event.objects.filter(is_deleted=False, status__in=public_statuses)
|
||||||
|
highest_price_rial = public_events.aggregate(value=Max("price"))["value"] or 0
|
||||||
|
price_ceiling = ceil(highest_price_rial / 10 / 1_000_000) * 1_000_000
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
queryset = (
|
||||||
|
public_events
|
||||||
|
.annotate(
|
||||||
|
browse_registration_count=Count(
|
||||||
|
"registrations",
|
||||||
|
filter=Q(
|
||||||
|
registrations__is_deleted=False,
|
||||||
|
) & (
|
||||||
|
Q(registrations__status__in=[
|
||||||
|
Registration.StatusChoices.CONFIRMED,
|
||||||
|
Registration.StatusChoices.ATTENDED,
|
||||||
|
])
|
||||||
|
| Q(
|
||||||
|
registrations__status=Registration.StatusChoices.PENDING,
|
||||||
|
registrations__checkout_expires_at__gt=now,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
distinct=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.prefetch_related("gallery_images")
|
||||||
|
)
|
||||||
|
|
||||||
|
if state == "completed":
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(status=Event.StatusChoices.COMPLETED) | Q(end_time__lte=now)
|
||||||
|
)
|
||||||
|
elif state == "upcoming":
|
||||||
|
queryset = queryset.filter(
|
||||||
|
status=Event.StatusChoices.PUBLISHED,
|
||||||
|
start_time__gt=now,
|
||||||
|
).exclude(_event_registration_open_query(now))
|
||||||
|
elif state == "open":
|
||||||
|
queryset = queryset.filter(_event_registration_open_query(now))
|
||||||
|
|
||||||
|
if event_type:
|
||||||
|
queryset = queryset.filter(event_type=event_type)
|
||||||
|
if search and search.strip():
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(title__icontains=search.strip()) | Q(description__icontains=search.strip())
|
||||||
|
)
|
||||||
|
if selected_from:
|
||||||
|
queryset = queryset.filter(start_time__gte=selected_from)
|
||||||
|
if selected_to:
|
||||||
|
queryset = queryset.filter(start_time__lt=selected_to + timedelta(days=1))
|
||||||
|
if free_only:
|
||||||
|
queryset = queryset.filter(Q(price__isnull=True) | Q(price__lte=0))
|
||||||
|
else:
|
||||||
|
if price_min is not None:
|
||||||
|
queryset = queryset.filter(price__gte=price_min * 10)
|
||||||
|
if price_max is not None:
|
||||||
|
queryset = queryset.filter(price__lte=price_max * 10)
|
||||||
|
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
published_first=Case(
|
||||||
|
When(status=Event.StatusChoices.PUBLISHED, then=0),
|
||||||
|
default=1,
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
).order_by("published_first", "-start_time", "-id")
|
||||||
|
|
||||||
|
count = queryset.count()
|
||||||
|
total_pages = ceil(count / page_size) if count else 0
|
||||||
|
current_page = min(page, total_pages) if total_pages else 1
|
||||||
|
results = queryset[(current_page - 1) * page_size:current_page * page_size]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"count": count,
|
||||||
|
"page": current_page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"price_ceiling": price_ceiling,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
@events_router.get("/{int:event_id}", response=EventSchema)
|
@events_router.get("/{int:event_id}", response=EventSchema)
|
||||||
def get_event(request, event_id: int):
|
def get_event(request, event_id: int):
|
||||||
"""Get event details by ID"""
|
"""Get event details by ID"""
|
||||||
@@ -224,6 +374,7 @@ def update_event(request, event_id: int, payload: EventUpdateSchema):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if cancelled_now:
|
if cancelled_now:
|
||||||
|
cancel_event_checkouts(event.id)
|
||||||
_notify_event_update(
|
_notify_event_update(
|
||||||
event,
|
event,
|
||||||
notification_type="event_cancelled",
|
notification_type="event_cancelled",
|
||||||
@@ -244,6 +395,45 @@ def update_event(request, event_id: int, payload: EventUpdateSchema):
|
|||||||
|
|
||||||
return 200, event
|
return 200, event
|
||||||
|
|
||||||
|
|
||||||
|
@events_router.post("/{int:event_id}/checkout/cancel", response=MessageSchema, auth=jwt_auth)
|
||||||
|
def cancel_registration_checkout(request, event_id: int):
|
||||||
|
"""Cancel the user's active paid checkout and release its seat."""
|
||||||
|
with transaction.atomic():
|
||||||
|
event = get_object_or_404(
|
||||||
|
Event.objects.select_for_update(),
|
||||||
|
id=event_id,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
registration = (
|
||||||
|
Registration.objects.select_for_update()
|
||||||
|
.filter(
|
||||||
|
event=event,
|
||||||
|
user=request.auth,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
checkout_expires_at__isnull=False,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.order_by("-registered_at", "-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if registration is None:
|
||||||
|
raise HttpError(409, "ثبتنام فعالی برای لغو وجود ندارد.")
|
||||||
|
|
||||||
|
payment = Payment.objects.select_for_update().filter(
|
||||||
|
registration=registration,
|
||||||
|
).order_by("-created_at", "-id").first()
|
||||||
|
if payment and payment.status == Payment.OrderStatusChoices.PAID:
|
||||||
|
raise HttpError(409, "پرداخت تأیید شده است و امکان لغو پرداخت وجود ندارد.")
|
||||||
|
|
||||||
|
mark_checkout_cancelled(
|
||||||
|
registration,
|
||||||
|
Registration.CancellationReasonChoices.USER_CANCELLED,
|
||||||
|
)
|
||||||
|
queue_checkout_reconciliation(registration)
|
||||||
|
|
||||||
|
return {"message": "درخواست پرداخت لغو شد و ظرفیت آزاد شد."}
|
||||||
|
|
||||||
@events_router.delete("/{int:event_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
|
@events_router.delete("/{int:event_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
|
||||||
def delete_event(request, event_id: int):
|
def delete_event(request, event_id: int):
|
||||||
"""Soft delete an event"""
|
"""Soft delete an event"""
|
||||||
@@ -407,78 +597,108 @@ def register_for_event(
|
|||||||
payload: RegistrationCreateSchema | None = None,
|
payload: RegistrationCreateSchema | None = None,
|
||||||
):
|
):
|
||||||
"""Register current user for an event"""
|
"""Register current user for an event"""
|
||||||
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
|
||||||
user = request.auth
|
user = request.auth
|
||||||
|
now = timezone.now()
|
||||||
|
expire_stale_registration_checkouts(now=now, event_id=event_id)
|
||||||
|
|
||||||
if Registration.objects.filter(event=event, user=user, status=Registration.StatusChoices.CONFIRMED).exists():
|
with transaction.atomic():
|
||||||
|
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, "شما قبلا در این ایونت ثبتنام کردهاید.")
|
raise HttpError(400, "شما قبلا در این ایونت ثبتنام کردهاید.")
|
||||||
|
|
||||||
if event.registration_end_date and event.registration_end_date < timezone.now():
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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, "وضعیت پرداخت قبلی هنوز مشخص نشده است. پس از بررسی دوباره تلاش کنید.")
|
||||||
|
|
||||||
|
if event.status != Event.StatusChoices.PUBLISHED:
|
||||||
|
raise HttpError(400, "ثبتنام این رویداد فعال نیست.")
|
||||||
|
if event.registration_end_date and event.registration_end_date < now:
|
||||||
raise HttpError(400, "مهلت ثبتنام به پایان رسیدهاست")
|
raise HttpError(400, "مهلت ثبتنام به پایان رسیدهاست")
|
||||||
|
if event.registration_start_date and event.registration_start_date > now:
|
||||||
if event.registration_start_date and event.registration_start_date > timezone.now():
|
|
||||||
raise HttpError(400, "زمان ثبتنام هنوز آغاز نشده است")
|
raise HttpError(400, "زمان ثبتنام هنوز آغاز نشده است")
|
||||||
|
|
||||||
if not event.has_available_slots:
|
if not event.has_available_slots:
|
||||||
raise HttpError(400, "ظرفیت شرکتکنندگان تکمیل است")
|
raise HttpError(400, "ظرفیت شرکتکنندگان تکمیل است")
|
||||||
|
|
||||||
# Create or get existing registration
|
discount_code_value = (
|
||||||
discount_code = None
|
payload.discount_code if payload and payload.discount_code
|
||||||
if payload and payload.discount_code:
|
else request.GET.get("discount_code")
|
||||||
discount_code = payload.discount_code
|
|
||||||
elif request.GET.get("discount_code"):
|
|
||||||
discount_code = request.GET.get("discount_code")
|
|
||||||
|
|
||||||
registration, created = Registration.objects.get_or_create(
|
|
||||||
event=event,
|
|
||||||
user=user,
|
|
||||||
status=Registration.StatusChoices.PENDING,
|
|
||||||
defaults={"final_price": event.price},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if registration.status == Registration.StatusChoices.CONFIRMED:
|
|
||||||
return HttpError(400, "شما قبلا در این ایونت ثبتنام کردهاید")
|
|
||||||
|
|
||||||
if registration.status == Registration.StatusChoices.CANCELLED:
|
|
||||||
registration = Registration.objects.create(
|
|
||||||
event=event,
|
|
||||||
user=user,
|
|
||||||
status=Registration.StatusChoices.PENDING,
|
|
||||||
final_price=event.price,
|
|
||||||
)
|
|
||||||
elif not created and registration.final_price is None:
|
|
||||||
registration.final_price = event.price
|
|
||||||
registration.save(update_fields=["final_price"])
|
|
||||||
|
|
||||||
applied_code = None
|
applied_code = None
|
||||||
discount_amount = 0
|
discount_amount = 0
|
||||||
final_price = event.price
|
final_price = event.price or 0
|
||||||
fields_to_update = []
|
if discount_code_value:
|
||||||
|
|
||||||
if discount_code:
|
|
||||||
applied_code = DiscountCode.objects.filter(
|
applied_code = DiscountCode.objects.filter(
|
||||||
code=discount_code,
|
code=discount_code_value,
|
||||||
applicable_events=event,
|
applicable_events=event,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
).first()
|
).first()
|
||||||
if not applied_code:
|
if not applied_code:
|
||||||
raise HttpError(400, "UcO_ O<>OrU?UOU? U.O1O<31>O\"O<EFBFBD> U+UOO3O<33>")
|
raise HttpError(400, "کد تخفیف برای این رویداد معتبر نیست.")
|
||||||
final_price, discount_amount = applied_code.calculate_discount(event, user)
|
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)))
|
|
||||||
|
|
||||||
|
is_free = final_price == 0
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=event,
|
||||||
|
user=user,
|
||||||
|
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,
|
||||||
|
)
|
||||||
return registration
|
return registration
|
||||||
|
|
||||||
@events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth)
|
@events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth)
|
||||||
@@ -488,8 +708,33 @@ def update_registration_status(request, registration_id: int, payload: Registrat
|
|||||||
|
|
||||||
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
|
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
|
||||||
requested_status = payload.dict(exclude_unset=True).get('status')
|
requested_status = payload.dict(exclude_unset=True).get('status')
|
||||||
if requested_status == Registration.StatusChoices.ATTENDED:
|
if requested_status != Registration.StatusChoices.CANCELLED:
|
||||||
raise HttpError(403, "حضور رویداد فقط توسط برگزارکننده تأیید میشود.")
|
raise HttpError(403, "وضعیت ثبتنام فقط پس از تأیید پرداخت توسط سامانه تغییر میکند.")
|
||||||
|
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.status = requested_status
|
||||||
registration.full_clean()
|
registration.full_clean()
|
||||||
registration.save()
|
registration.save()
|
||||||
@@ -501,7 +746,32 @@ def cancel_registration(request, registration_id: int):
|
|||||||
"""Cancel a registration"""
|
"""Cancel a registration"""
|
||||||
user = request.auth
|
user = request.auth
|
||||||
|
|
||||||
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
|
registration = get_object_or_404(
|
||||||
|
Registration,
|
||||||
|
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()
|
registration.delete()
|
||||||
return {"message": "ثبتنام شما لغو شد :("}
|
return {"message": "ثبتنام شما لغو شد :("}
|
||||||
|
|
||||||
@@ -523,6 +793,53 @@ def verify_my_registration(request, ticket_id: UUID):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _my_registration_checkout_fields(registration, now):
|
||||||
|
payment = registration.payments.order_by("-created_at", "-id").first()
|
||||||
|
checkout_state = "none"
|
||||||
|
can_resume = False
|
||||||
|
if registration.status == Registration.StatusChoices.PENDING:
|
||||||
|
if payment and payment.status == Payment.OrderStatusChoices.PAID:
|
||||||
|
checkout_state = "unresolved"
|
||||||
|
elif registration.checkout_expires_at is None:
|
||||||
|
checkout_state = "legacy_pending"
|
||||||
|
elif registration.checkout_expires_at > now:
|
||||||
|
checkout_state = "active"
|
||||||
|
can_resume = not bool(payment and payment.verification_manual_review_at)
|
||||||
|
else:
|
||||||
|
checkout_state = "expired"
|
||||||
|
elif registration.status == Registration.StatusChoices.CANCELLED:
|
||||||
|
checkout_state = "cancelled"
|
||||||
|
if payment and (
|
||||||
|
payment.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING)
|
||||||
|
or (
|
||||||
|
payment.status == Payment.OrderStatusChoices.PAID
|
||||||
|
and payment.refund_status in (
|
||||||
|
Payment.RefundStatusChoices.PENDING,
|
||||||
|
Payment.RefundStatusChoices.MANUAL_REVIEW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
checkout_state = "unresolved"
|
||||||
|
|
||||||
|
payment_status = None
|
||||||
|
if payment:
|
||||||
|
payment_status = {
|
||||||
|
Payment.OrderStatusChoices.INIT: "initiated",
|
||||||
|
Payment.OrderStatusChoices.PENDING: "pending",
|
||||||
|
Payment.OrderStatusChoices.PAID: "paid",
|
||||||
|
Payment.OrderStatusChoices.FAILED: "failed",
|
||||||
|
Payment.OrderStatusChoices.CANCELED: "canceled",
|
||||||
|
}[payment.status]
|
||||||
|
return {
|
||||||
|
"checkout_state": checkout_state,
|
||||||
|
"checkout_expires_at": registration.checkout_expires_at,
|
||||||
|
"payment_id": payment.id if payment else None,
|
||||||
|
"payment_status": payment_status,
|
||||||
|
"refund_status": payment.refund_status if payment else None,
|
||||||
|
"can_resume": can_resume,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@events_router.get("/my-registrations/paginated", response=PaginatedMyEventRegistrationsSchema, auth=jwt_auth)
|
@events_router.get("/my-registrations/paginated", response=PaginatedMyEventRegistrationsSchema, auth=jwt_auth)
|
||||||
def my_registrations_paginated(
|
def my_registrations_paginated(
|
||||||
request,
|
request,
|
||||||
@@ -542,11 +859,14 @@ def my_registrations_paginated(
|
|||||||
count = queryset.count()
|
count = queryset.count()
|
||||||
total_pages = max(1, (count + page_size - 1) // page_size)
|
total_pages = max(1, (count + page_size - 1) // page_size)
|
||||||
start = (page - 1) * page_size
|
start = (page - 1) * page_size
|
||||||
|
now = timezone.now()
|
||||||
results = [
|
results = [
|
||||||
MyEventRegistrationOut(
|
MyEventRegistrationOut(
|
||||||
id=registration.id,
|
id=registration.id,
|
||||||
|
ticket_id=registration.ticket_id,
|
||||||
created_at=registration.created_at,
|
created_at=registration.created_at,
|
||||||
status=registration.status,
|
status=registration.status,
|
||||||
|
**_my_registration_checkout_fields(registration, now),
|
||||||
event=EventBriefSchema(
|
event=EventBriefSchema(
|
||||||
id=registration.event.id,
|
id=registration.event.id,
|
||||||
title=registration.event.title,
|
title=registration.event.title,
|
||||||
@@ -582,12 +902,15 @@ def my_registrations(request):
|
|||||||
.order_by("-created_at")
|
.order_by("-created_at")
|
||||||
)
|
)
|
||||||
out: List[MyEventRegistrationOut] = []
|
out: List[MyEventRegistrationOut] = []
|
||||||
|
now = timezone.now()
|
||||||
for r in qs:
|
for r in qs:
|
||||||
out.append(
|
out.append(
|
||||||
MyEventRegistrationOut(
|
MyEventRegistrationOut(
|
||||||
id=r.id,
|
id=r.id,
|
||||||
|
ticket_id=r.ticket_id,
|
||||||
created_at=r.created_at,
|
created_at=r.created_at,
|
||||||
status=r.status,
|
status=r.status,
|
||||||
|
**_my_registration_checkout_fields(r, now),
|
||||||
event=EventBriefSchema(
|
event=EventBriefSchema(
|
||||||
id=r.event.id,
|
id=r.event.id,
|
||||||
title=r.event.title,
|
title=r.event.title,
|
||||||
@@ -604,12 +927,337 @@ def my_registrations(request):
|
|||||||
|
|
||||||
@events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth)
|
@events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth)
|
||||||
def is_registered(request, event_id: int):
|
def is_registered(request, event_id: int):
|
||||||
exists = Registration.objects.filter(
|
now = timezone.now()
|
||||||
|
registration = (
|
||||||
|
Registration.objects.filter(
|
||||||
user=request.auth,
|
user=request.auth,
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
status=Registration.StatusChoices.CONFIRMED
|
is_deleted=False,
|
||||||
).exists()
|
)
|
||||||
return {"is_registered": exists}
|
.order_by("-registered_at", "-id")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if registration and registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at and registration.checkout_expires_at <= now:
|
||||||
|
expire_stale_registration_checkouts(now=now, event_id=event_id)
|
||||||
|
registration.refresh_from_db()
|
||||||
|
|
||||||
|
payment = None
|
||||||
|
if registration:
|
||||||
|
payment = registration.payments.order_by("-created_at", "-id").first()
|
||||||
|
|
||||||
|
is_confirmed = bool(
|
||||||
|
registration
|
||||||
|
and registration.status in [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]
|
||||||
|
)
|
||||||
|
checkout_state = "none"
|
||||||
|
can_resume = False
|
||||||
|
can_cancel = False
|
||||||
|
if is_confirmed:
|
||||||
|
checkout_state = "confirmed"
|
||||||
|
elif registration and registration.status == Registration.StatusChoices.PENDING:
|
||||||
|
if payment and payment.status == Payment.OrderStatusChoices.PAID:
|
||||||
|
checkout_state = "unresolved"
|
||||||
|
elif registration.checkout_expires_at is None:
|
||||||
|
checkout_state = "legacy_pending"
|
||||||
|
elif registration.checkout_expires_at > now:
|
||||||
|
checkout_state = "active"
|
||||||
|
can_resume = not bool(payment and payment.verification_manual_review_at)
|
||||||
|
can_cancel = True
|
||||||
|
else:
|
||||||
|
checkout_state = "expired"
|
||||||
|
elif registration and registration.status == Registration.StatusChoices.CANCELLED:
|
||||||
|
checkout_state = "cancelled"
|
||||||
|
if payment and (
|
||||||
|
payment.status in [Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING]
|
||||||
|
or (
|
||||||
|
payment.status == Payment.OrderStatusChoices.PAID
|
||||||
|
and payment.refund_status in [
|
||||||
|
Payment.RefundStatusChoices.PENDING,
|
||||||
|
Payment.RefundStatusChoices.MANUAL_REVIEW,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
):
|
||||||
|
checkout_state = "unresolved"
|
||||||
|
|
||||||
|
payment_status = None
|
||||||
|
if payment:
|
||||||
|
payment_status = {
|
||||||
|
Payment.OrderStatusChoices.INIT: "initiated",
|
||||||
|
Payment.OrderStatusChoices.PENDING: "pending",
|
||||||
|
Payment.OrderStatusChoices.PAID: "paid",
|
||||||
|
Payment.OrderStatusChoices.FAILED: "failed",
|
||||||
|
Payment.OrderStatusChoices.CANCELED: "canceled",
|
||||||
|
}[payment.status]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"is_registered": is_confirmed,
|
||||||
|
"checkout_state": checkout_state,
|
||||||
|
"registration_id": registration.id if registration else None,
|
||||||
|
"registration_status": registration.status if registration else None,
|
||||||
|
"ticket_id": str(registration.ticket_id) if registration else None,
|
||||||
|
"checkout_expires_at": registration.checkout_expires_at if registration else None,
|
||||||
|
"payment_id": payment.id if payment else None,
|
||||||
|
"payment_status": payment_status,
|
||||||
|
"refund_status": payment.refund_status if payment else None,
|
||||||
|
"can_resume": can_resume,
|
||||||
|
"can_cancel": can_cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _registration_hub_state(registration, payment, now):
|
||||||
|
if payment:
|
||||||
|
if payment.refund_status == Payment.RefundStatusChoices.PENDING:
|
||||||
|
return "refund_pending"
|
||||||
|
if payment.refund_status == Payment.RefundStatusChoices.MANUAL_REVIEW:
|
||||||
|
return "refund_manual_review"
|
||||||
|
if payment.refund_status == Payment.RefundStatusChoices.SUCCEEDED:
|
||||||
|
return "refund_succeeded"
|
||||||
|
|
||||||
|
if payment.status == Payment.OrderStatusChoices.FAILED:
|
||||||
|
return "failed"
|
||||||
|
if payment.status == Payment.OrderStatusChoices.CANCELED:
|
||||||
|
return "cancelled"
|
||||||
|
if payment.verification_manual_review_at:
|
||||||
|
return "manual_review"
|
||||||
|
|
||||||
|
if payment.status == Payment.OrderStatusChoices.PAID:
|
||||||
|
if registration and registration.status in (
|
||||||
|
Registration.StatusChoices.CONFIRMED,
|
||||||
|
Registration.StatusChoices.ATTENDED,
|
||||||
|
):
|
||||||
|
return "paid_success"
|
||||||
|
# A charge without a confirmed registration must not be presented as
|
||||||
|
# a successful registration. Keep the saved transaction visible for review.
|
||||||
|
return "manual_review"
|
||||||
|
|
||||||
|
if payment.status in (
|
||||||
|
Payment.OrderStatusChoices.INIT,
|
||||||
|
Payment.OrderStatusChoices.PENDING,
|
||||||
|
):
|
||||||
|
if registration and registration.status == Registration.StatusChoices.PENDING:
|
||||||
|
if registration.checkout_expires_at is None:
|
||||||
|
return "legacy_pending"
|
||||||
|
if registration.checkout_expires_at > now:
|
||||||
|
return "active_checkout"
|
||||||
|
return "verification_pending"
|
||||||
|
|
||||||
|
if registration:
|
||||||
|
if registration.status in (
|
||||||
|
Registration.StatusChoices.CONFIRMED,
|
||||||
|
Registration.StatusChoices.ATTENDED,
|
||||||
|
):
|
||||||
|
return "free_success" if (registration.final_price or 0) <= 0 else "manual_review"
|
||||||
|
if registration.status == Registration.StatusChoices.PENDING:
|
||||||
|
if registration.checkout_expires_at is None:
|
||||||
|
return "legacy_pending"
|
||||||
|
if registration.checkout_expires_at > now:
|
||||||
|
return "active_checkout"
|
||||||
|
return "expired"
|
||||||
|
if registration.status == Registration.StatusChoices.CANCELLED:
|
||||||
|
if registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED:
|
||||||
|
return "expired"
|
||||||
|
if registration.cancellation_reason == Registration.CancellationReasonChoices.PAYMENT_FAILED:
|
||||||
|
return "failed"
|
||||||
|
return "cancelled"
|
||||||
|
|
||||||
|
return "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def _registration_hub_records(event, user, registration_id, payment_id, now):
|
||||||
|
if registration_id:
|
||||||
|
registration = Registration.objects.filter(
|
||||||
|
ticket_id=registration_id,
|
||||||
|
event=event,
|
||||||
|
user=user,
|
||||||
|
is_deleted=False,
|
||||||
|
).first()
|
||||||
|
if registration is None:
|
||||||
|
return None, None
|
||||||
|
payment = registration.payments.order_by("-created_at", "-id").first()
|
||||||
|
return registration, payment
|
||||||
|
|
||||||
|
if payment_id:
|
||||||
|
payment = Payment.objects.select_related("registration").filter(
|
||||||
|
pk=payment_id,
|
||||||
|
event=event,
|
||||||
|
user=user,
|
||||||
|
).first()
|
||||||
|
if payment is None:
|
||||||
|
return None, None
|
||||||
|
registration = payment.registration
|
||||||
|
if registration is not None and (
|
||||||
|
registration.event_id != event.id or registration.user_id != user.id
|
||||||
|
):
|
||||||
|
registration = None
|
||||||
|
return registration, payment
|
||||||
|
|
||||||
|
registrations = list(
|
||||||
|
Registration.objects.filter(event=event, user=user, is_deleted=False)
|
||||||
|
.prefetch_related("payments")
|
||||||
|
.order_by("-registered_at", "-id")
|
||||||
|
)
|
||||||
|
registration_payments = {
|
||||||
|
registration.id: registration.payments.order_by("-created_at", "-id").first()
|
||||||
|
for registration in registrations
|
||||||
|
}
|
||||||
|
payments = list(
|
||||||
|
Payment.objects.filter(event=event, user=user)
|
||||||
|
.select_related("registration")
|
||||||
|
.order_by("-created_at", "-id")
|
||||||
|
)
|
||||||
|
|
||||||
|
unresolved = []
|
||||||
|
for registration in registrations:
|
||||||
|
payment = registration_payments[registration.id]
|
||||||
|
state = _registration_hub_state(registration, payment, now)
|
||||||
|
if state in {
|
||||||
|
"active_checkout", "legacy_pending", "verification_pending", "manual_review",
|
||||||
|
"refund_pending", "refund_manual_review",
|
||||||
|
}:
|
||||||
|
unresolved.append((registration.registered_at, registration, payment))
|
||||||
|
|
||||||
|
linked_registration_ids = set(registration_payments)
|
||||||
|
for payment in payments:
|
||||||
|
if payment.registration_id in linked_registration_ids:
|
||||||
|
continue
|
||||||
|
state = _registration_hub_state(payment.registration, payment, now)
|
||||||
|
if state in {
|
||||||
|
"active_checkout", "legacy_pending", "verification_pending", "manual_review",
|
||||||
|
"refund_pending", "refund_manual_review",
|
||||||
|
}:
|
||||||
|
unresolved.append((payment.created_at, payment.registration, payment))
|
||||||
|
|
||||||
|
if unresolved:
|
||||||
|
_, registration, payment = max(unresolved, key=lambda item: item[0])
|
||||||
|
return registration, payment
|
||||||
|
if registrations:
|
||||||
|
registration = registrations[0]
|
||||||
|
return registration, registration_payments[registration.id]
|
||||||
|
if payments:
|
||||||
|
return payments[0].registration, payments[0]
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
@events_router.get("/{str:slug}/registration/status", response=RegistrationHubOut, auth=jwt_auth)
|
||||||
|
def registration_hub_status(
|
||||||
|
request,
|
||||||
|
slug: str,
|
||||||
|
registration_id: Optional[UUID] = None,
|
||||||
|
payment_id: Optional[int] = None,
|
||||||
|
):
|
||||||
|
"""Return the authenticated user's authoritative registration/payment view for an event."""
|
||||||
|
if registration_id and payment_id:
|
||||||
|
raise HttpError(400, "فقط یکی از شناسههای ثبتنام یا پرداخت را ارسال کنید.")
|
||||||
|
|
||||||
|
event = Event.objects.filter(slug=slug, is_deleted=False).first()
|
||||||
|
if event is None:
|
||||||
|
lookup = {"pk": int(slug)} if slug.isdecimal() else {"slug": slug}
|
||||||
|
event = get_object_or_404(Event, is_deleted=False, **lookup)
|
||||||
|
now = timezone.now()
|
||||||
|
expire_stale_registration_checkouts(now=now, event_id=event.id)
|
||||||
|
registration, payment = _registration_hub_records(
|
||||||
|
event,
|
||||||
|
request.auth,
|
||||||
|
registration_id,
|
||||||
|
payment_id,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
if (registration_id or payment_id) and registration is None and payment is None:
|
||||||
|
raise HttpError(404, "وضعیت ثبتنام یا پرداخت پیدا نشد.")
|
||||||
|
|
||||||
|
state = _registration_hub_state(registration, payment, now)
|
||||||
|
unresolved = state in {
|
||||||
|
"active_checkout", "legacy_pending", "verification_pending", "manual_review",
|
||||||
|
"refund_pending", "refund_manual_review",
|
||||||
|
}
|
||||||
|
can_resume = bool(
|
||||||
|
state == "active_checkout"
|
||||||
|
and registration
|
||||||
|
and registration.status == Registration.StatusChoices.PENDING
|
||||||
|
and (payment is None or not payment.verification_manual_review_at)
|
||||||
|
)
|
||||||
|
can_cancel = bool(
|
||||||
|
state == "active_checkout"
|
||||||
|
and registration
|
||||||
|
and registration.status == Registration.StatusChoices.PENDING
|
||||||
|
and registration.checkout_expires_at
|
||||||
|
and registration.checkout_expires_at > now
|
||||||
|
)
|
||||||
|
registration_is_settled = bool(
|
||||||
|
registration
|
||||||
|
and registration.status in (
|
||||||
|
Registration.StatusChoices.CONFIRMED,
|
||||||
|
Registration.StatusChoices.ATTENDED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registration_window_open = bool(
|
||||||
|
event.status == Event.StatusChoices.PUBLISHED
|
||||||
|
and event.is_registration_open
|
||||||
|
and event.has_available_slots
|
||||||
|
)
|
||||||
|
can_register = bool(registration_window_open and not unresolved and not registration_is_settled)
|
||||||
|
|
||||||
|
image_url = None
|
||||||
|
if event.featured_image and hasattr(event.featured_image, "url"):
|
||||||
|
image_url = request.build_absolute_uri(event.featured_image.url)
|
||||||
|
|
||||||
|
payment_status = None
|
||||||
|
if payment:
|
||||||
|
payment_status = {
|
||||||
|
Payment.OrderStatusChoices.INIT: "initiated",
|
||||||
|
Payment.OrderStatusChoices.PENDING: "pending",
|
||||||
|
Payment.OrderStatusChoices.PAID: "paid",
|
||||||
|
Payment.OrderStatusChoices.FAILED: "failed",
|
||||||
|
Payment.OrderStatusChoices.CANCELED: "canceled",
|
||||||
|
}[payment.status]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"state": state,
|
||||||
|
"event": {
|
||||||
|
"id": event.id,
|
||||||
|
"title": event.title,
|
||||||
|
"slug": event.slug,
|
||||||
|
"description": event.description,
|
||||||
|
"event_type": event.event_type,
|
||||||
|
"address": event.address,
|
||||||
|
"location": event.location,
|
||||||
|
"start_time": event.start_time,
|
||||||
|
"end_time": event.end_time,
|
||||||
|
"registration_start_date": event.registration_start_date,
|
||||||
|
"registration_end_date": event.registration_end_date,
|
||||||
|
"capacity": event.capacity,
|
||||||
|
"price": event.price,
|
||||||
|
"status": event.status,
|
||||||
|
"registration_count": event.current_attendees_count,
|
||||||
|
"image_url": image_url,
|
||||||
|
"registration_success_markdown": event.registration_success_markdown,
|
||||||
|
},
|
||||||
|
"registration": {
|
||||||
|
"ticket_id": registration.ticket_id,
|
||||||
|
"status": registration.status,
|
||||||
|
"registered_at": registration.registered_at,
|
||||||
|
"checkout_started_at": registration.checkout_started_at,
|
||||||
|
"checkout_expires_at": registration.checkout_expires_at,
|
||||||
|
"cancellation_reason": registration.cancellation_reason,
|
||||||
|
"final_price": registration.final_price,
|
||||||
|
"discount_amount": registration.discount_amount,
|
||||||
|
} if registration else None,
|
||||||
|
"payment": {
|
||||||
|
"id": payment.id,
|
||||||
|
"status": payment_status,
|
||||||
|
"verification_state": payment.verification_state,
|
||||||
|
"refund_status": payment.refund_status,
|
||||||
|
"base_amount": payment.base_amount,
|
||||||
|
"discount_amount": payment.discount_amount,
|
||||||
|
"amount": payment.amount,
|
||||||
|
"ref_id": payment.ref_id,
|
||||||
|
"created_at": payment.created_at,
|
||||||
|
"gateway_paid_at": payment.gateway_paid_at,
|
||||||
|
} if payment else None,
|
||||||
|
"can_register": can_register,
|
||||||
|
"can_resume": can_resume,
|
||||||
|
"can_cancel": can_cancel,
|
||||||
|
}
|
||||||
@events_router.get("/{int:event_id}/admin-detail", response=EventAdminDetailSchema, auth=jwt_auth)
|
@events_router.get("/{int:event_id}/admin-detail", response=EventAdminDetailSchema, auth=jwt_auth)
|
||||||
def event_admin_detail(request, event_id: int):
|
def event_admin_detail(request, event_id: int):
|
||||||
user = request.auth
|
user = request.auth
|
||||||
|
|||||||
136
apps/events/checkout.py
Normal file
136
apps/events/checkout.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""Registration checkout lifecycle rules shared by API views and workers."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.events.models import Event, Registration
|
||||||
|
|
||||||
|
|
||||||
|
CHECKOUT_TIMEOUT = timedelta(minutes=15)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_reconciliation(payment_id):
|
||||||
|
from apps.payments.tasks import reconcile_payment
|
||||||
|
|
||||||
|
try:
|
||||||
|
reconcile_payment.apply_async(args=(payment_id,), countdown=0)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unable to dispatch checkout payment %s for reconciliation", payment_id)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_checkout_cancelled(registration, reason):
|
||||||
|
"""Cancel a pending checkout without deleting its historical registration."""
|
||||||
|
if registration.status != Registration.StatusChoices.PENDING:
|
||||||
|
return False
|
||||||
|
|
||||||
|
registration.status = Registration.StatusChoices.CANCELLED
|
||||||
|
registration.cancellation_reason = reason
|
||||||
|
registration.save(update_fields=[
|
||||||
|
"status",
|
||||||
|
"cancellation_reason",
|
||||||
|
"updated_at",
|
||||||
|
])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def queue_checkout_reconciliation(registration):
|
||||||
|
"""Reconcile each unresolved payment after a checkout is cancelled."""
|
||||||
|
from apps.payments.models import Payment
|
||||||
|
|
||||||
|
payments = list(
|
||||||
|
Payment.objects.filter(
|
||||||
|
registration=registration,
|
||||||
|
status__in=[
|
||||||
|
Payment.OrderStatusChoices.INIT,
|
||||||
|
Payment.OrderStatusChoices.PENDING,
|
||||||
|
],
|
||||||
|
).only("id", "authority")
|
||||||
|
)
|
||||||
|
for payment in payments:
|
||||||
|
if payment.authority:
|
||||||
|
transaction.on_commit(
|
||||||
|
lambda payment_id=payment.id: _dispatch_reconciliation(payment_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# The payment request may have reached the gateway before its
|
||||||
|
# authority was persisted. Keep it visible for review until its
|
||||||
|
# request response arrives or an administrator resolves it.
|
||||||
|
Payment.objects.filter(
|
||||||
|
pk=payment.id,
|
||||||
|
status__in=[
|
||||||
|
Payment.OrderStatusChoices.INIT,
|
||||||
|
Payment.OrderStatusChoices.PENDING,
|
||||||
|
],
|
||||||
|
authority__isnull=True,
|
||||||
|
).update(verification_manual_review_at=timezone.now())
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def expire_stale_registration_checkouts(*, now=None, event_id=None):
|
||||||
|
"""Release seats for new-format pending checkouts whose deadline passed.
|
||||||
|
|
||||||
|
Legacy registrations have a null checkout deadline and are intentionally
|
||||||
|
excluded from this timeout sweep.
|
||||||
|
"""
|
||||||
|
now = now or timezone.now()
|
||||||
|
candidates = Registration.objects.filter(
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
is_deleted=False,
|
||||||
|
checkout_expires_at__lte=now,
|
||||||
|
)
|
||||||
|
if event_id is not None:
|
||||||
|
candidates = candidates.filter(event_id=event_id)
|
||||||
|
|
||||||
|
expired_count = 0
|
||||||
|
for registration_id in candidates.values_list("id", flat=True).iterator():
|
||||||
|
registration = (
|
||||||
|
Registration.objects.select_for_update()
|
||||||
|
.filter(pk=registration_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if registration is None or registration.status != Registration.StatusChoices.PENDING:
|
||||||
|
continue
|
||||||
|
|
||||||
|
reason = (
|
||||||
|
Registration.CancellationReasonChoices.EVENT_CANCELLED
|
||||||
|
if registration.event.status == Event.StatusChoices.CANCELLED
|
||||||
|
else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
|
||||||
|
)
|
||||||
|
if mark_checkout_cancelled(registration, reason):
|
||||||
|
expired_count += 1
|
||||||
|
queue_checkout_reconciliation(registration)
|
||||||
|
|
||||||
|
return expired_count
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def cancel_event_checkouts(event_id):
|
||||||
|
"""Cancel active new-format checkouts when an organizer cancels an event."""
|
||||||
|
event = Event.objects.select_for_update().filter(pk=event_id).first()
|
||||||
|
if event is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
registrations = (
|
||||||
|
Registration.objects.select_for_update()
|
||||||
|
.filter(
|
||||||
|
event=event,
|
||||||
|
is_deleted=False,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
checkout_expires_at__isnull=False,
|
||||||
|
)
|
||||||
|
.order_by("pk")
|
||||||
|
)
|
||||||
|
cancelled_count = 0
|
||||||
|
for registration in registrations:
|
||||||
|
if mark_checkout_cancelled(
|
||||||
|
registration,
|
||||||
|
Registration.CancellationReasonChoices.EVENT_CANCELLED,
|
||||||
|
):
|
||||||
|
cancelled_count += 1
|
||||||
|
queue_checkout_reconciliation(registration)
|
||||||
|
return cancelled_count
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -108,8 +108,15 @@ class Event(BaseModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def current_attendees_count(self):
|
def current_attendees_count(self):
|
||||||
"""Count confirmed attendees"""
|
"""Count confirmed attendees and currently held paid-checkout seats."""
|
||||||
return self.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], is_deleted=False).count()
|
now = timezone.now()
|
||||||
|
return self.registrations.filter(is_deleted=False).filter(
|
||||||
|
models.Q(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
|
||||||
|
| models.Q(
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
checkout_expires_at__gt=now,
|
||||||
|
)
|
||||||
|
).count()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_available_slots(self):
|
def has_available_slots(self):
|
||||||
@@ -132,9 +139,24 @@ class Registration(BaseModel):
|
|||||||
SYSTEM = 'system', 'System'
|
SYSTEM = 'system', 'System'
|
||||||
HISTORICAL = 'historical', 'Historical backfill'
|
HISTORICAL = 'historical', 'Historical backfill'
|
||||||
|
|
||||||
|
class CancellationReasonChoices(models.TextChoices):
|
||||||
|
CHECKOUT_EXPIRED = 'checkout_expired', 'Checkout expired'
|
||||||
|
USER_CANCELLED = 'user_cancelled', 'User cancelled checkout'
|
||||||
|
EVENT_CANCELLED = 'event_cancelled', 'Event cancelled'
|
||||||
|
PAYMENT_FAILED = 'payment_failed', 'Payment failed'
|
||||||
|
|
||||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations')
|
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations')
|
||||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations')
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations')
|
||||||
registered_at = models.DateTimeField(auto_now_add=True)
|
registered_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
checkout_started_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
checkout_expires_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
cancellation_reason = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=CancellationReasonChoices.choices,
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
status = models.CharField(max_length=10, choices=StatusChoices.choices,
|
status = models.CharField(max_length=10, choices=StatusChoices.choices,
|
||||||
default=StatusChoices.PENDING)
|
default=StatusChoices.PENDING)
|
||||||
attendance_verified_at = models.DateTimeField(null=True, blank=True)
|
attendance_verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ import logging
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
|
||||||
from apps.events.models import Registration
|
from apps.events.models import Registration
|
||||||
|
from apps.events.checkout import expire_stale_registration_checkouts
|
||||||
from apps.notifications.services import notify_user
|
from apps.notifications.services import notify_user
|
||||||
from apps.users.tasks import send_critical_sms
|
from apps.users.tasks import send_critical_sms
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="apps.events.expire_stale_registration_checkouts")
|
||||||
|
def expire_stale_registration_checkouts_task():
|
||||||
|
"""Release seats held by new checkout attempts that reached their deadline."""
|
||||||
|
return expire_stale_registration_checkouts()
|
||||||
|
|
||||||
|
|
||||||
@shared_task(bind=True, max_retries=3)
|
@shared_task(bind=True, max_retries=3)
|
||||||
def send_registration_confirmation_notification(self, registration_pk: str):
|
def send_registration_confirmation_notification(self, registration_pk: str):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import io
|
|||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import timedelta
|
from datetime import datetime, time as datetime_time, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
@@ -110,6 +110,115 @@ class TestEventsAPIIntegrationTests:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert any((item['id'] == event.id for item in response.json()))
|
assert any((item['id'] == event.id for item in response.json()))
|
||||||
|
|
||||||
|
def test_browse_events_returns_numbered_pages_and_global_price_ceiling(self):
|
||||||
|
self._create_event(title='Price ceiling 650k toman', price=6500000)
|
||||||
|
self._create_event(title='Price ceiling 4.3m toman', price=43000000)
|
||||||
|
for index in range(13):
|
||||||
|
self._create_event(title=f'Browse page event {index}', price=100000)
|
||||||
|
|
||||||
|
first_page = self.client.get('/api/events/browse/', {'page': 1})
|
||||||
|
second_page = self.client.get('/api/events/browse/', {'page': 2})
|
||||||
|
|
||||||
|
assert first_page.status_code == 200
|
||||||
|
assert first_page.json()['count'] == 17
|
||||||
|
assert first_page.json()['page'] == 1
|
||||||
|
assert first_page.json()['page_size'] == 12
|
||||||
|
assert first_page.json()['total_pages'] == 2
|
||||||
|
assert len(first_page.json()['results']) == 12
|
||||||
|
assert second_page.json()['page'] == 2
|
||||||
|
assert len(second_page.json()['results']) == 5
|
||||||
|
assert first_page.json()['price_ceiling'] == 5000000
|
||||||
|
assert 'address' in first_page.json()['results'][0]
|
||||||
|
|
||||||
|
def test_browse_events_upcoming_includes_future_events_that_cannot_register(self):
|
||||||
|
now = timezone.now()
|
||||||
|
open_event = self._create_event(
|
||||||
|
title='Browse open event',
|
||||||
|
start_time=now + timedelta(days=4),
|
||||||
|
end_time=now + timedelta(days=4, hours=2),
|
||||||
|
registration_start_date=now - timedelta(days=1),
|
||||||
|
registration_end_date=now + timedelta(days=2),
|
||||||
|
)
|
||||||
|
not_started = self._create_event(
|
||||||
|
title='Browse not started event',
|
||||||
|
start_time=now + timedelta(days=5),
|
||||||
|
end_time=now + timedelta(days=5, hours=2),
|
||||||
|
registration_start_date=now + timedelta(days=1),
|
||||||
|
)
|
||||||
|
closed = self._create_event(
|
||||||
|
title='Browse closed event',
|
||||||
|
start_time=now + timedelta(days=6),
|
||||||
|
end_time=now + timedelta(days=6, hours=2),
|
||||||
|
registration_end_date=now - timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
full = self._create_event(
|
||||||
|
title='Browse full event',
|
||||||
|
start_time=now + timedelta(days=7),
|
||||||
|
end_time=now + timedelta(days=7, hours=2),
|
||||||
|
capacity=1,
|
||||||
|
)
|
||||||
|
self._create_registration(full, self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||||
|
|
||||||
|
upcoming = self.client.get('/api/events/browse/', {'state': 'upcoming', 'search': 'Browse'}).json()
|
||||||
|
open_results = self.client.get('/api/events/browse/', {'state': 'open', 'search': 'Browse'}).json()
|
||||||
|
upcoming_ids = {item['id'] for item in upcoming['results']}
|
||||||
|
open_ids = {item['id'] for item in open_results['results']}
|
||||||
|
|
||||||
|
assert {not_started.id, closed.id, full.id}.issubset(upcoming_ids)
|
||||||
|
assert open_event.id not in upcoming_ids
|
||||||
|
assert open_ids == {open_event.id}
|
||||||
|
|
||||||
|
def test_browse_events_filters_start_dates_inclusive_and_combines_type_price_and_free(self):
|
||||||
|
selected_day = timezone.localdate()
|
||||||
|
local_timezone = timezone.get_current_timezone()
|
||||||
|
|
||||||
|
def at(day, hour, minute):
|
||||||
|
return timezone.make_aware(datetime.combine(day, datetime_time(hour, minute)), local_timezone)
|
||||||
|
|
||||||
|
start_day = self._create_event(
|
||||||
|
title='Date range probe free',
|
||||||
|
event_type=Event.TypeChoices.ONLINE,
|
||||||
|
price=0,
|
||||||
|
start_time=at(selected_day, 0, 0),
|
||||||
|
end_time=at(selected_day, 2, 0),
|
||||||
|
)
|
||||||
|
end_day = self._create_event(
|
||||||
|
title='Date range probe paid online',
|
||||||
|
event_type=Event.TypeChoices.ONLINE,
|
||||||
|
price=300000,
|
||||||
|
start_time=at(selected_day, 23, 59),
|
||||||
|
end_time=at(selected_day + timedelta(days=1), 1, 0),
|
||||||
|
)
|
||||||
|
self._create_event(
|
||||||
|
title='Date range probe paid onsite',
|
||||||
|
event_type=Event.TypeChoices.ON_SITE,
|
||||||
|
price=300000,
|
||||||
|
start_time=at(selected_day, 12, 0),
|
||||||
|
end_time=at(selected_day, 14, 0),
|
||||||
|
)
|
||||||
|
outside_day = self._create_event(
|
||||||
|
title='Date range probe outside',
|
||||||
|
event_type=Event.TypeChoices.ONLINE,
|
||||||
|
price=300000,
|
||||||
|
start_time=at(selected_day + timedelta(days=1), 0, 0),
|
||||||
|
end_time=at(selected_day + timedelta(days=1), 2, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'search': 'Date range probe',
|
||||||
|
'date_from': selected_day.isoformat(),
|
||||||
|
'date_to': selected_day.isoformat(),
|
||||||
|
'event_type': Event.TypeChoices.ONLINE,
|
||||||
|
'price_min': 10000,
|
||||||
|
'price_max': 40000,
|
||||||
|
}
|
||||||
|
paid_results = self.client.get('/api/events/browse/', params).json()
|
||||||
|
free_results = self.client.get('/api/events/browse/', {**params, 'free_only': 'true'}).json()
|
||||||
|
|
||||||
|
assert {item['id'] for item in paid_results['results']} == {end_day.id}
|
||||||
|
assert {item['id'] for item in free_results['results']} == {start_day.id}
|
||||||
|
assert outside_day.id not in {item['id'] for item in paid_results['results']}
|
||||||
|
|
||||||
def test_list_events_handles_comma_status_parameter(self):
|
def test_list_events_handles_comma_status_parameter(self):
|
||||||
event = self._create_event(title='Comma Event', status=Event.StatusChoices.PUBLISHED)
|
event = self._create_event(title='Comma Event', status=Event.StatusChoices.PUBLISHED)
|
||||||
results = list_events(None, status=f'{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}', event_type=None, search=None, limit=10, offset=0)
|
results = list_events(None, status=f'{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}', event_type=None, search=None, limit=10, offset=0)
|
||||||
|
|||||||
480
apps/events/tests/integration/test_registration_checkout.py
Normal file
480
apps/events/tests/integration/test_registration_checkout.py
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.test import Client
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.events.models import Event, Registration
|
||||||
|
from apps.events.checkout import cancel_event_checkouts, expire_stale_registration_checkouts
|
||||||
|
from apps.payments.models import Payment
|
||||||
|
from apps.users.models import User
|
||||||
|
from core.authentication import create_jwt_token
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestRegistrationCheckout:
|
||||||
|
def setup_method(self):
|
||||||
|
self.client = Client()
|
||||||
|
suffix = uuid.uuid4().hex[:10]
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username=f"checkout-{suffix}",
|
||||||
|
email=f"checkout-{suffix}@example.com",
|
||||||
|
mobile=f"0912{suffix[:7]}",
|
||||||
|
password="CheckoutPass!123",
|
||||||
|
)
|
||||||
|
self.token = create_jwt_token(self.user)
|
||||||
|
now = timezone.now()
|
||||||
|
self.event = Event.objects.create(
|
||||||
|
title="Checkout event",
|
||||||
|
description="Paid checkout behavior test",
|
||||||
|
start_time=now + timedelta(days=2),
|
||||||
|
end_time=now + timedelta(days=2, hours=1),
|
||||||
|
registration_start_date=now - timedelta(days=1),
|
||||||
|
registration_end_date=now + timedelta(days=1),
|
||||||
|
slug=f"checkout-{suffix}",
|
||||||
|
price=1250000,
|
||||||
|
capacity=1,
|
||||||
|
status=Event.StatusChoices.PUBLISHED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def auth_headers(self):
|
||||||
|
return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
|
||||||
|
|
||||||
|
def create_pending_checkout(self, *, expires_at=None):
|
||||||
|
now = timezone.now()
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
final_price=self.event.price,
|
||||||
|
checkout_started_at=now,
|
||||||
|
checkout_expires_at=expires_at or now + timedelta(minutes=15),
|
||||||
|
)
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
registration=registration,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PENDING,
|
||||||
|
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||||
|
idempotency_key=uuid.uuid4(),
|
||||||
|
)
|
||||||
|
return registration, payment
|
||||||
|
|
||||||
|
def test_active_checkout_reserves_capacity_but_can_be_resumed_by_its_owner(self):
|
||||||
|
registration, _ = self.create_pending_checkout()
|
||||||
|
|
||||||
|
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 1
|
||||||
|
resume_response = self.client.post(
|
||||||
|
f"/api/events/{self.event.id}/register",
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
other = User.objects.create_user(
|
||||||
|
username=f"other-{uuid.uuid4().hex[:8]}",
|
||||||
|
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
|
||||||
|
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
|
||||||
|
password="CheckoutPass!123",
|
||||||
|
)
|
||||||
|
other_token = create_jwt_token(other)
|
||||||
|
other_response = self.client.post(
|
||||||
|
f"/api/events/{self.event.id}/register",
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type="application/json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {other_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resume_response.status_code == 200
|
||||||
|
assert resume_response.json()["id"] == registration.id
|
||||||
|
assert other_response.status_code == 400
|
||||||
|
assert "ظرفیت" in other_response.json().get("detail", "")
|
||||||
|
|
||||||
|
def test_active_checkout_remains_resumable_after_registration_closes(self):
|
||||||
|
registration, _ = self.create_pending_checkout()
|
||||||
|
self.event.registration_end_date = timezone.now() - timedelta(minutes=1)
|
||||||
|
self.event.save(update_fields=["registration_end_date"])
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/events/{self.event.id}/register",
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["id"] == registration.id
|
||||||
|
|
||||||
|
def test_registration_status_returns_resumable_payment_without_changing_legacy_flag(self):
|
||||||
|
registration, payment = self.create_pending_checkout()
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{self.event.id}/is-registered",
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["is_registered"] is False
|
||||||
|
assert payload["checkout_state"] == "active"
|
||||||
|
assert payload["can_resume"] is True
|
||||||
|
assert payload["registration_id"] == registration.id
|
||||||
|
assert payload["payment_id"] == payment.id
|
||||||
|
expected_expiry = registration.checkout_expires_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
assert payload["checkout_expires_at"] == expected_expiry
|
||||||
|
|
||||||
|
def test_registration_hub_returns_ready_state_for_user_without_registration(self):
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{self.event.slug}/registration/status",
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["state"] == "ready"
|
||||||
|
assert payload["event"]["slug"] == self.event.slug
|
||||||
|
assert payload["registration"] is None
|
||||||
|
assert payload["payment"] is None
|
||||||
|
assert payload["can_register"] is True
|
||||||
|
|
||||||
|
def test_registration_hub_requires_authentication(self):
|
||||||
|
response = self.client.get(f"/api/events/{self.event.slug}/registration/status")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_registration_hub_does_not_trust_a_status_query_parameter(self):
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.CONFIRMED,
|
||||||
|
final_price=self.event.price,
|
||||||
|
)
|
||||||
|
Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
registration=registration,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
|
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{self.event.slug}/registration/status?status=failed",
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["state"] == "paid_success"
|
||||||
|
|
||||||
|
def test_registration_hub_shows_confirmed_free_registration(self):
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.CONFIRMED,
|
||||||
|
final_price=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{self.event.slug}/registration/status",
|
||||||
|
{"registration_id": str(registration.ticket_id)},
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["state"] == "free_success"
|
||||||
|
|
||||||
|
def test_registration_hub_returns_404_for_foreign_payment(self):
|
||||||
|
other = User.objects.create_user(
|
||||||
|
username=f"other-{uuid.uuid4().hex[:8]}",
|
||||||
|
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
|
||||||
|
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
|
||||||
|
password="CheckoutPass!123",
|
||||||
|
)
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
user=other,
|
||||||
|
event=self.event,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PENDING,
|
||||||
|
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{self.event.slug}/registration/status",
|
||||||
|
{"payment_id": payment.id},
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_registration_hub_uses_registration_ticket_uuid_and_exposes_active_payment(self):
|
||||||
|
registration, payment = self.create_pending_checkout()
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{self.event.slug}/registration/status",
|
||||||
|
{"registration_id": str(registration.ticket_id)},
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["state"] == "active_checkout"
|
||||||
|
assert payload["registration"]["ticket_id"] == str(registration.ticket_id)
|
||||||
|
assert payload["payment"]["id"] == payment.id
|
||||||
|
assert payload["can_resume"] is True
|
||||||
|
assert payload["can_cancel"] is True
|
||||||
|
|
||||||
|
def test_registration_hub_hides_another_users_registration(self):
|
||||||
|
other = User.objects.create_user(
|
||||||
|
username=f"other-{uuid.uuid4().hex[:8]}",
|
||||||
|
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
|
||||||
|
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
|
||||||
|
password="CheckoutPass!123",
|
||||||
|
)
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=other,
|
||||||
|
status=Registration.StatusChoices.CONFIRMED,
|
||||||
|
final_price=self.event.price,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{self.event.slug}/registration/status",
|
||||||
|
{"registration_id": str(registration.ticket_id)},
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
def test_registration_hub_accepts_legacy_payment_without_linked_registration(self):
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PENDING,
|
||||||
|
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{self.event.slug}/registration/status",
|
||||||
|
{"payment_id": payment.id},
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["state"] == "verification_pending"
|
||||||
|
assert payload["registration"] is None
|
||||||
|
assert payload["payment"]["id"] == payment.id
|
||||||
|
assert payload["can_register"] is False
|
||||||
|
|
||||||
|
def test_registration_hub_uses_event_id_for_legacy_events_without_a_slug(self):
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.CONFIRMED,
|
||||||
|
final_price=self.event.price,
|
||||||
|
confirmation_notification_sent_at=timezone.now(),
|
||||||
|
)
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
registration=registration,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
|
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||||
|
)
|
||||||
|
Event.objects.filter(pk=self.event.pk).update(slug="")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{self.event.pk}/registration/status",
|
||||||
|
{"payment_id": payment.id},
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["state"] == "paid_success"
|
||||||
|
assert payload["event"]["slug"] == ""
|
||||||
|
assert payload["payment"]["id"] == payment.id
|
||||||
|
|
||||||
|
def test_registration_hub_prefers_active_attempt_over_newer_settled_attempt(self):
|
||||||
|
confirmed = Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.CONFIRMED,
|
||||||
|
final_price=self.event.price,
|
||||||
|
)
|
||||||
|
Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
registration=confirmed,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
|
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||||
|
)
|
||||||
|
active, active_payment = self.create_pending_checkout()
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{self.event.slug}/registration/status",
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["state"] == "active_checkout"
|
||||||
|
assert payload["registration"]["ticket_id"] == str(active.ticket_id)
|
||||||
|
assert payload["payment"]["id"] == active_payment.id
|
||||||
|
|
||||||
|
def test_registration_hub_returns_manual_review_and_refund_state_from_saved_records(self):
|
||||||
|
registration, payment = self.create_pending_checkout()
|
||||||
|
payment.verification_manual_review_at = timezone.now()
|
||||||
|
payment.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
|
||||||
|
payment.save(update_fields=["verification_manual_review_at", "refund_status"])
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{self.event.slug}/registration/status",
|
||||||
|
{"payment_id": payment.id},
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["state"] == "refund_manual_review"
|
||||||
|
assert payload["registration"]["ticket_id"] == str(registration.ticket_id)
|
||||||
|
assert payload["payment"]["verification_state"] == "manual_review"
|
||||||
|
assert payload["payment"]["refund_status"] == "manual_review"
|
||||||
|
|
||||||
|
def test_expired_unresolved_checkout_releases_registration_and_blocks_duplicate_payment(self):
|
||||||
|
expired_at = timezone.now() - timedelta(seconds=1)
|
||||||
|
registration, payment = self.create_pending_checkout(expires_at=expired_at)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/payments/create",
|
||||||
|
data=json.dumps({
|
||||||
|
"event_id": self.event.id,
|
||||||
|
"description": "Retry after abandoned checkout",
|
||||||
|
"idempotency_key": str(uuid.uuid4()),
|
||||||
|
}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
registration.refresh_from_db()
|
||||||
|
payment.refresh_from_db()
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert registration.status == Registration.StatusChoices.CANCELLED
|
||||||
|
assert registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
|
||||||
|
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||||
|
assert Payment.objects.filter(event=self.event, user=self.user).count() == 1
|
||||||
|
|
||||||
|
def test_expiry_sweep_cancels_only_new_expired_checkouts_and_releases_the_seat(self):
|
||||||
|
expired_at = timezone.now() - timedelta(seconds=1)
|
||||||
|
registration, payment = self.create_pending_checkout(expires_at=expired_at)
|
||||||
|
legacy = Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=User.objects.create_user(
|
||||||
|
username=f"legacy-{uuid.uuid4().hex[:8]}",
|
||||||
|
email=f"legacy-{uuid.uuid4().hex[:8]}@example.com",
|
||||||
|
mobile=f"0914{uuid.uuid4().int % 10**7:07d}",
|
||||||
|
password="CheckoutPass!123",
|
||||||
|
),
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
final_price=self.event.price,
|
||||||
|
)
|
||||||
|
|
||||||
|
count = expire_stale_registration_checkouts()
|
||||||
|
|
||||||
|
registration.refresh_from_db()
|
||||||
|
payment.refresh_from_db()
|
||||||
|
legacy.refresh_from_db()
|
||||||
|
assert count == 1
|
||||||
|
assert registration.status == Registration.StatusChoices.CANCELLED
|
||||||
|
assert registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
|
||||||
|
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||||
|
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
|
||||||
|
assert legacy.status == Registration.StatusChoices.PENDING
|
||||||
|
|
||||||
|
def test_user_can_cancel_active_checkout_without_deleting_registration(self, mocker):
|
||||||
|
registration, payment = self.create_pending_checkout()
|
||||||
|
mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/events/{self.event.id}/checkout/cancel",
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
registration.refresh_from_db()
|
||||||
|
payment.refresh_from_db()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert registration.status == Registration.StatusChoices.CANCELLED
|
||||||
|
assert registration.cancellation_reason == Registration.CancellationReasonChoices.USER_CANCELLED
|
||||||
|
assert Registration.objects.filter(pk=registration.pk).exists()
|
||||||
|
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||||
|
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
|
||||||
|
|
||||||
|
def test_new_checkout_is_blocked_while_late_payment_refund_is_unresolved(self):
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
user=self.user,
|
||||||
|
status=Registration.StatusChoices.CANCELLED,
|
||||||
|
cancellation_reason=Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
|
||||||
|
checkout_started_at=timezone.now() - timedelta(minutes=20),
|
||||||
|
checkout_expires_at=timezone.now() - timedelta(minutes=5),
|
||||||
|
final_price=self.event.price,
|
||||||
|
)
|
||||||
|
Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
registration=registration,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
|
refund_status=Payment.RefundStatusChoices.PENDING,
|
||||||
|
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||||
|
)
|
||||||
|
|
||||||
|
register_response = self.client.post(
|
||||||
|
f"/api/events/{self.event.id}/register",
|
||||||
|
data=json.dumps({}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
payment_response = self.client.post(
|
||||||
|
"/api/payments/create",
|
||||||
|
data=json.dumps({
|
||||||
|
"event_id": self.event.id,
|
||||||
|
"description": "Do not charge twice while refund is unresolved",
|
||||||
|
"idempotency_key": str(uuid.uuid4()),
|
||||||
|
}),
|
||||||
|
content_type="application/json",
|
||||||
|
**self.auth_headers(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert register_response.status_code == 409
|
||||||
|
assert payment_response.status_code == 409
|
||||||
|
assert Registration.objects.filter(event=self.event, user=self.user).count() == 1
|
||||||
|
|
||||||
|
def test_event_cancellation_ends_checkouts_and_queues_payment_reconciliation(self, mocker):
|
||||||
|
registration, payment = self.create_pending_checkout()
|
||||||
|
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
|
||||||
|
mocker.patch("apps.events.checkout.transaction.on_commit", side_effect=lambda callback: callback())
|
||||||
|
self.event.status = Event.StatusChoices.CANCELLED
|
||||||
|
self.event.save(update_fields=["status"])
|
||||||
|
|
||||||
|
cancelled = cancel_event_checkouts(self.event.id)
|
||||||
|
|
||||||
|
registration.refresh_from_db()
|
||||||
|
assert cancelled == 1
|
||||||
|
assert registration.status == Registration.StatusChoices.CANCELLED
|
||||||
|
assert registration.cancellation_reason == Registration.CancellationReasonChoices.EVENT_CANCELLED
|
||||||
|
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
|
||||||
|
enqueue.assert_called_once_with(args=(payment.id,), countdown=0)
|
||||||
@@ -22,13 +22,21 @@ def test_event_admin_formats_empty_price_and_capacity():
|
|||||||
def test_event_admin_status_actions_update_selection(mocker):
|
def test_event_admin_status_actions_update_selection(mocker):
|
||||||
model_admin = EventAdmin(Event, admin.site)
|
model_admin = EventAdmin(Event, admin.site)
|
||||||
mocker.patch.object(model_admin, "message_user")
|
mocker.patch.object(model_admin, "message_user")
|
||||||
|
cancel_checkouts = mocker.patch("apps.events.admin.cancel_event_checkouts")
|
||||||
queryset = mocker.Mock()
|
queryset = mocker.Mock()
|
||||||
queryset.count.return_value = 2
|
queryset.count.return_value = 2
|
||||||
for action, status in (
|
for action, status in (
|
||||||
(model_admin.make_published, Event.StatusChoices.PUBLISHED),
|
(model_admin.make_published, Event.StatusChoices.PUBLISHED),
|
||||||
(model_admin.make_draft, Event.StatusChoices.DRAFT),
|
(model_admin.make_draft, Event.StatusChoices.DRAFT),
|
||||||
(model_admin.make_cancelled, Event.StatusChoices.CANCELLED),
|
|
||||||
(model_admin.make_completed, Event.StatusChoices.COMPLETED),
|
(model_admin.make_completed, Event.StatusChoices.COMPLETED),
|
||||||
):
|
):
|
||||||
action(SimpleNamespace(), queryset)
|
action(SimpleNamespace(), queryset)
|
||||||
queryset.update.assert_called_with(status=status)
|
queryset.update.assert_called_with(status=status)
|
||||||
|
|
||||||
|
published = SimpleNamespace(status=Event.StatusChoices.PUBLISHED, pk=10, save=mocker.Mock())
|
||||||
|
already_cancelled = SimpleNamespace(status=Event.StatusChoices.CANCELLED, pk=11, save=mocker.Mock())
|
||||||
|
model_admin.make_cancelled(SimpleNamespace(), [published, already_cancelled])
|
||||||
|
|
||||||
|
published.save.assert_called_once_with(update_fields=["status"])
|
||||||
|
already_cancelled.save.assert_not_called()
|
||||||
|
cancel_checkouts.assert_called_once_with(published.pk)
|
||||||
|
|||||||
@@ -52,18 +52,26 @@ class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
|||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
'id', 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount',
|
'id', 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount',
|
||||||
'status', 'created_at', 'verified_at', 'is_deleted'
|
'status', 'refund_status', 'refund_attempts', 'verification_attempts',
|
||||||
|
'verification_manual_review_at',
|
||||||
|
'created_at', 'verified_at', 'is_deleted'
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
'status', 'event',
|
'status', 'refund_status', 'event', 'verification_manual_review_at',
|
||||||
SoftDeleteListFilter,
|
SoftDeleteListFilter,
|
||||||
)
|
)
|
||||||
search_fields = (
|
search_fields = (
|
||||||
'user__email', 'authority', 'ref_id', 'discount_code__code'
|
'user__email', 'authority', 'ref_id', 'discount_code__code'
|
||||||
)
|
)
|
||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
'user', 'event', 'base_amount', 'discount_code', 'discount_code', 'discount_amount', 'amount', 'authority',
|
'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount', 'authority',
|
||||||
'status', 'ref_id', 'card_pan', 'card_hash', 'created_at', 'updated_at', 'deleted_at'
|
'status', 'ref_id', 'card_pan', 'card_hash', 'verified_at', 'gateway_paid_at',
|
||||||
|
'verification_attempts',
|
||||||
|
'verification_last_attempt_at', 'verification_next_attempt_at', 'verification_lease_until',
|
||||||
|
'verification_dispatch_expires_at', 'verification_manual_review_at', 'idempotency_key',
|
||||||
|
'refund_status', 'refund_attempts', 'refund_last_attempt_at', 'refund_next_attempt_at',
|
||||||
|
'refund_reference', 'refund_last_error',
|
||||||
|
'created_at', 'updated_at', 'deleted_at'
|
||||||
)
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@@ -74,7 +82,20 @@ class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
|||||||
'fields': ('base_amount', 'discount_code', 'discount_amount', 'amount')
|
'fields': ('base_amount', 'discount_code', 'discount_amount', 'amount')
|
||||||
}),
|
}),
|
||||||
('Others', {
|
('Others', {
|
||||||
'fields': ('authority', 'ref_id', 'card_pan', 'card_hash')
|
'fields': ('authority', 'ref_id', 'card_pan', 'card_hash', 'verified_at', 'gateway_paid_at', 'idempotency_key')
|
||||||
|
}),
|
||||||
|
('Verification and Support Review', {
|
||||||
|
'fields': (
|
||||||
|
'verification_attempts', 'verification_last_attempt_at',
|
||||||
|
'verification_next_attempt_at', 'verification_lease_until',
|
||||||
|
'verification_dispatch_expires_at', 'verification_manual_review_at',
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Refund and Reversal Review', {
|
||||||
|
'fields': (
|
||||||
|
'refund_status', 'refund_attempts', 'refund_last_attempt_at',
|
||||||
|
'refund_next_attempt_at', 'refund_reference', 'refund_last_error',
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
('Soft Delete', {
|
('Soft Delete', {
|
||||||
'fields': ('is_deleted', 'deleted_at'),
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
|||||||
@@ -71,21 +71,44 @@ class PaymentDetailOut(Schema):
|
|||||||
amount: int
|
amount: int
|
||||||
status: str
|
status: str
|
||||||
verified_at: str | None = None
|
verified_at: str | None = None
|
||||||
|
gateway_paid_at: str | None = None
|
||||||
|
refund_status: Literal["none", "pending", "succeeded", "manual_review"] = "none"
|
||||||
|
refund_attempts: int = 0
|
||||||
|
refund_last_attempt_at: str | None = None
|
||||||
|
refund_next_attempt_at: str | None = None
|
||||||
|
refund_reference: str | None = None
|
||||||
|
refund_last_error: str | None = None
|
||||||
|
verification_state: Literal[
|
||||||
|
"awaiting_verification",
|
||||||
|
"checking",
|
||||||
|
"retry_scheduled",
|
||||||
|
"manual_review",
|
||||||
|
"verified",
|
||||||
|
"failed",
|
||||||
|
"canceled",
|
||||||
|
]
|
||||||
|
verification_attempts: int
|
||||||
|
last_verification_at: str | None = None
|
||||||
|
next_verification_at: str | None = None
|
||||||
|
manual_review_at: str | None = None
|
||||||
event: dict
|
event: dict
|
||||||
|
|
||||||
|
|
||||||
class UserPaymentEventSchema(Schema):
|
class UserPaymentEventSchema(Schema):
|
||||||
|
id: int
|
||||||
title: str
|
title: str
|
||||||
slug: str
|
slug: str
|
||||||
image_url: Optional[str] = None
|
image_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UserPaymentSchema(Schema):
|
class UserPaymentSchema(Schema):
|
||||||
|
payment_id: int
|
||||||
event: UserPaymentEventSchema
|
event: UserPaymentEventSchema
|
||||||
base_amount: int
|
base_amount: int
|
||||||
discount_amount: int
|
discount_amount: int
|
||||||
amount: int
|
amount: int
|
||||||
status: Literal["initiated", "pending", "paid", "failed", "canceled"]
|
status: Literal["initiated", "pending", "paid", "failed", "canceled"]
|
||||||
|
refund_status: Literal["none", "pending", "succeeded", "manual_review"] = "none"
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
paid_at: Optional[datetime] = None
|
paid_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.shortcuts import redirect, get_object_or_404
|
from django.shortcuts import redirect, get_object_or_404
|
||||||
@@ -14,8 +16,18 @@ import requests
|
|||||||
|
|
||||||
from apps.payments.models import Payment, DiscountCode
|
from apps.payments.models import Payment, DiscountCode
|
||||||
from apps.events.models import Event, Registration
|
from apps.events.models import Event, Registration
|
||||||
|
from apps.events.checkout import (
|
||||||
|
CHECKOUT_TIMEOUT,
|
||||||
|
expire_stale_registration_checkouts,
|
||||||
|
queue_checkout_reconciliation,
|
||||||
|
)
|
||||||
from apps.notifications.services import notify_user
|
from apps.notifications.services import notify_user
|
||||||
from apps.payments.services import PaymentVerificationUncertain, request_payment_verification
|
from apps.payments.services import (
|
||||||
|
PaymentVerificationUncertain,
|
||||||
|
claim_payment_verification,
|
||||||
|
request_payment_verification,
|
||||||
|
schedule_payment_verification_retry,
|
||||||
|
)
|
||||||
from apps.payments.tasks import reconcile_payment
|
from apps.payments.tasks import reconcile_payment
|
||||||
from apps.users.tasks import send_critical_sms
|
from apps.users.tasks import send_critical_sms
|
||||||
from core.authentication import jwt_auth
|
from core.authentication import jwt_auth
|
||||||
@@ -55,7 +67,9 @@ def my_payments(request):
|
|||||||
|
|
||||||
def _user_payment_payload(request, payment, status_names):
|
def _user_payment_payload(request, payment, status_names):
|
||||||
return {
|
return {
|
||||||
|
"payment_id": payment.id,
|
||||||
"event": {
|
"event": {
|
||||||
|
"id": payment.event_id,
|
||||||
"title": payment.event.title,
|
"title": payment.event.title,
|
||||||
"slug": payment.event.slug,
|
"slug": payment.event.slug,
|
||||||
"image_url": request.build_absolute_uri(payment.event.featured_image.url)
|
"image_url": request.build_absolute_uri(payment.event.featured_image.url)
|
||||||
@@ -66,6 +80,7 @@ def _user_payment_payload(request, payment, status_names):
|
|||||||
"discount_amount": payment.discount_amount,
|
"discount_amount": payment.discount_amount,
|
||||||
"amount": payment.amount,
|
"amount": payment.amount,
|
||||||
"status": status_names[payment.status],
|
"status": status_names[payment.status],
|
||||||
|
"refund_status": payment.refund_status,
|
||||||
"created_at": payment.created_at,
|
"created_at": payment.created_at,
|
||||||
"paid_at": payment.verified_at,
|
"paid_at": payment.verified_at,
|
||||||
}
|
}
|
||||||
@@ -270,9 +285,38 @@ def _payment_checkout_payload(payment: Payment):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_checkout_attempt_can_be_reused(payment: Payment, *, now, allow_review_replay=False):
|
||||||
|
registration = payment.registration
|
||||||
|
if registration is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if payment.verification_manual_review_at is not None and not allow_review_replay:
|
||||||
|
raise HttpError(409, "وضعیت پرداخت در حال بررسی پشتیبانی است و امکان شروع دوباره وجود ندارد.")
|
||||||
|
|
||||||
|
if (
|
||||||
|
registration.status == Registration.StatusChoices.PENDING
|
||||||
|
and registration.checkout_expires_at is not None
|
||||||
|
and registration.checkout_expires_at <= now
|
||||||
|
):
|
||||||
|
raise HttpError(409, "مهلت پرداخت این ثبتنام به پایان رسیده است.")
|
||||||
|
|
||||||
|
if (
|
||||||
|
registration.status == Registration.StatusChoices.CANCELLED
|
||||||
|
and registration.cancellation_reason in {
|
||||||
|
Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
|
||||||
|
Registration.CancellationReasonChoices.USER_CANCELLED,
|
||||||
|
Registration.CancellationReasonChoices.EVENT_CANCELLED,
|
||||||
|
}
|
||||||
|
and payment.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING)
|
||||||
|
):
|
||||||
|
raise HttpError(409, "وضعیت پرداخت قبلی هنوز مشخص نشده است. پس از بررسی دوباره تلاش کنید.")
|
||||||
|
|
||||||
|
|
||||||
@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
|
@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
|
||||||
def create_payment(request, payload: CreatePaymentIn):
|
def create_payment(request, payload: CreatePaymentIn):
|
||||||
idempotency_key = payload.idempotency_key or uuid4()
|
idempotency_key = payload.idempotency_key or uuid4()
|
||||||
|
now = timezone.now()
|
||||||
|
expire_stale_registration_checkouts(now=now, event_id=payload.event_id)
|
||||||
|
|
||||||
# Serialize checkout creation for this event so concurrent taps cannot
|
# Serialize checkout creation for this event so concurrent taps cannot
|
||||||
# both create a new active attempt for the same user/event pair.
|
# both create a new active attempt for the same user/event pair.
|
||||||
@@ -282,19 +326,48 @@ def create_payment(request, payload: CreatePaymentIn):
|
|||||||
pk=payload.event_id,
|
pk=payload.event_id,
|
||||||
is_deleted=False,
|
is_deleted=False,
|
||||||
)
|
)
|
||||||
|
if event.status == Event.StatusChoices.CANCELLED:
|
||||||
|
raise HttpError(400, "این رویداد لغو شده است و امکان پرداخت ندارد.")
|
||||||
|
|
||||||
keyed_payment = Payment.objects.filter(
|
keyed_payment = Payment.objects.filter(
|
||||||
user=request.auth,
|
user=request.auth,
|
||||||
event=event,
|
event=event,
|
||||||
idempotency_key=idempotency_key,
|
idempotency_key=idempotency_key,
|
||||||
).select_related("event").first()
|
).select_related("event", "registration").first()
|
||||||
if keyed_payment:
|
if keyed_payment:
|
||||||
|
_ensure_checkout_attempt_can_be_reused(
|
||||||
|
keyed_payment,
|
||||||
|
now=now,
|
||||||
|
allow_review_replay=True,
|
||||||
|
)
|
||||||
return _payment_checkout_payload(keyed_payment)
|
return _payment_checkout_payload(keyed_payment)
|
||||||
|
|
||||||
|
unresolved_refund = Payment.objects.filter(
|
||||||
|
user=request.auth,
|
||||||
|
event=event,
|
||||||
|
registration__cancellation_reason__in=[
|
||||||
|
Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
|
||||||
|
Registration.CancellationReasonChoices.USER_CANCELLED,
|
||||||
|
Registration.CancellationReasonChoices.EVENT_CANCELLED,
|
||||||
|
],
|
||||||
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
|
refund_status__in=[
|
||||||
|
Payment.RefundStatusChoices.PENDING,
|
||||||
|
Payment.RefundStatusChoices.MANUAL_REVIEW,
|
||||||
|
],
|
||||||
|
).exists()
|
||||||
|
if unresolved_refund:
|
||||||
|
raise HttpError(409, "بازگشت وجه پرداخت قبلی هنوز نهایی نشده است. پس از بررسی دوباره تلاش کنید.")
|
||||||
|
|
||||||
if Payment.objects.filter(
|
if Payment.objects.filter(
|
||||||
status=Payment.OrderStatusChoices.PAID,
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
user=request.auth,
|
user=request.auth,
|
||||||
event=event,
|
event=event,
|
||||||
|
refund_status__in=[
|
||||||
|
Payment.RefundStatusChoices.NONE,
|
||||||
|
Payment.RefundStatusChoices.PENDING,
|
||||||
|
Payment.RefundStatusChoices.MANUAL_REVIEW,
|
||||||
|
],
|
||||||
).exists():
|
).exists():
|
||||||
raise HttpError(400, "You have already registered in this event")
|
raise HttpError(400, "You have already registered in this event")
|
||||||
|
|
||||||
@@ -304,10 +377,12 @@ def create_payment(request, payload: CreatePaymentIn):
|
|||||||
event=event,
|
event=event,
|
||||||
status__in=[Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING],
|
status__in=[Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING],
|
||||||
)
|
)
|
||||||
|
.select_related("registration")
|
||||||
.order_by("-created_at", "-id")
|
.order_by("-created_at", "-id")
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if active_payment:
|
if active_payment:
|
||||||
|
_ensure_checkout_attempt_can_be_reused(active_payment, now=now)
|
||||||
return _payment_checkout_payload(active_payment)
|
return _payment_checkout_payload(active_payment)
|
||||||
|
|
||||||
registration = (
|
registration = (
|
||||||
@@ -316,11 +391,29 @@ def create_payment(request, payload: CreatePaymentIn):
|
|||||||
.order_by("-registered_at")
|
.order_by("-registered_at")
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
if registration and registration.status in (
|
||||||
|
Registration.StatusChoices.CONFIRMED,
|
||||||
|
Registration.StatusChoices.ATTENDED,
|
||||||
|
):
|
||||||
|
raise HttpError(400, "شما قبلا در این ایونت ثبتنام کردهاید.")
|
||||||
|
|
||||||
|
needs_new_checkout = not registration or registration.status == Registration.StatusChoices.CANCELLED
|
||||||
|
if needs_new_checkout:
|
||||||
|
if event.status != Event.StatusChoices.PUBLISHED:
|
||||||
|
raise HttpError(400, "ثبتنام این رویداد فعال نیست.")
|
||||||
|
if event.registration_start_date and event.registration_start_date > now:
|
||||||
|
raise HttpError(400, "زمان ثبتنام هنوز آغاز نشده است")
|
||||||
|
if event.registration_end_date and event.registration_end_date < now:
|
||||||
|
raise HttpError(400, "مهلت ثبتنام به پایان رسیدهاست")
|
||||||
|
if not event.has_available_slots:
|
||||||
|
raise HttpError(400, "ظرفیت شرکتکنندگان تکمیل است")
|
||||||
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
|
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
|
||||||
registration = Registration.objects.create(
|
registration = Registration.objects.create(
|
||||||
event=event,
|
event=event,
|
||||||
user=request.auth,
|
user=request.auth,
|
||||||
status=Registration.StatusChoices.PENDING,
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
checkout_started_at=now,
|
||||||
|
checkout_expires_at=now + CHECKOUT_TIMEOUT,
|
||||||
final_price=event.price,
|
final_price=event.price,
|
||||||
)
|
)
|
||||||
elif registration.final_price is None:
|
elif registration.final_price is None:
|
||||||
@@ -331,7 +424,17 @@ def create_payment(request, payload: CreatePaymentIn):
|
|||||||
discount_amount = 0
|
discount_amount = 0
|
||||||
final_amount = event.price
|
final_amount = event.price
|
||||||
|
|
||||||
if payload.discount_code:
|
if (
|
||||||
|
registration.status == Registration.StatusChoices.PENDING
|
||||||
|
and registration.final_price is not None
|
||||||
|
and (registration.discount_code_id or not payload.discount_code)
|
||||||
|
):
|
||||||
|
# Resume with the persisted quote; a browser reload must not lose a
|
||||||
|
# coupon or silently change the amount agreed when the seat was held.
|
||||||
|
discount_code = registration.discount_code
|
||||||
|
discount_amount = registration.discount_amount
|
||||||
|
final_amount = registration.final_price
|
||||||
|
elif payload.discount_code:
|
||||||
discount_code = DiscountCode.objects.filter(
|
discount_code = DiscountCode.objects.filter(
|
||||||
code=payload.discount_code,
|
code=payload.discount_code,
|
||||||
applicable_events=event,
|
applicable_events=event,
|
||||||
@@ -421,7 +524,8 @@ def create_payment(request, payload: CreatePaymentIn):
|
|||||||
pay.id,
|
pay.id,
|
||||||
)
|
)
|
||||||
pay.status = Payment.OrderStatusChoices.PENDING
|
pay.status = Payment.OrderStatusChoices.PENDING
|
||||||
pay.save(update_fields=["status"])
|
pay.verification_manual_review_at = timezone.now()
|
||||||
|
pay.save(update_fields=["status", "verification_manual_review_at"])
|
||||||
return _payment_checkout_payload(pay)
|
return _payment_checkout_payload(pay)
|
||||||
|
|
||||||
data = jd.get("data") if isinstance(jd, dict) else None
|
data = jd.get("data") if isinstance(jd, dict) else None
|
||||||
@@ -441,31 +545,122 @@ def create_payment(request, payload: CreatePaymentIn):
|
|||||||
pay.id,
|
pay.id,
|
||||||
)
|
)
|
||||||
pay.status = Payment.OrderStatusChoices.PENDING
|
pay.status = Payment.OrderStatusChoices.PENDING
|
||||||
pay.save(update_fields=["status"])
|
pay.verification_manual_review_at = timezone.now()
|
||||||
|
pay.save(update_fields=["status", "verification_manual_review_at"])
|
||||||
return _payment_checkout_payload(pay)
|
return _payment_checkout_payload(pay)
|
||||||
|
|
||||||
|
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
|
pay.authority = authority
|
||||||
|
if pay.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING):
|
||||||
pay.status = Payment.OrderStatusChoices.PENDING
|
pay.status = Payment.OrderStatusChoices.PENDING
|
||||||
pay.save(update_fields=["authority","status"])
|
pay.verification_manual_review_at = None
|
||||||
|
pay.save(update_fields=["authority", "status", "verification_manual_review_at"])
|
||||||
|
if (
|
||||||
|
registration is not None
|
||||||
|
and registration.status == Registration.StatusChoices.CANCELLED
|
||||||
|
) or event.status == Event.StatusChoices.CANCELLED:
|
||||||
|
if registration is not None:
|
||||||
|
queue_checkout_reconciliation(registration)
|
||||||
|
|
||||||
return _payment_checkout_payload(pay)
|
return _payment_checkout_payload(pay)
|
||||||
|
|
||||||
def _callback_redirect(payment: Payment, status: str):
|
def _frontend_root_url():
|
||||||
url = (
|
root = (getattr(settings, "FRONTEND_ROOT", "") or settings.FRONTEND_CALLBACK_URL).rstrip("/")
|
||||||
f"{settings.FRONTEND_CALLBACK_URL}?status={status}"
|
return root
|
||||||
f"&event_id={payment.event_id}&payment_id={payment.id}"
|
|
||||||
)
|
|
||||||
if status == "success" and payment.ref_id:
|
def _callback_redirect(payment: Payment, _status: str):
|
||||||
url += f"&ref_id={payment.ref_id}"
|
"""Return to the authenticated hub; its API read is the status authority."""
|
||||||
|
event = payment.event
|
||||||
|
url = f"{_frontend_root_url()}/events/{quote(event.slug or str(event.id), safe='')}/registration"
|
||||||
|
if payment.registration_id:
|
||||||
|
url += f"?registration_id={payment.registration.ticket_id}"
|
||||||
|
else:
|
||||||
|
url += f"?payment_id={payment.id}"
|
||||||
return redirect(url)
|
return redirect(url)
|
||||||
|
|
||||||
|
|
||||||
|
def _callback_unknown_redirect():
|
||||||
|
"""Send malformed or stale provider callbacks to the designed safe fallback."""
|
||||||
|
return redirect(f"{_frontend_root_url()}/events?payment_callback=unmatched")
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_paid_at(verification_data: dict | None):
|
||||||
|
"""Read an explicit timezone-aware payment time if the gateway supplies one."""
|
||||||
|
raw_value = verification_data.get("paid_at") if isinstance(verification_data, dict) else None
|
||||||
|
if isinstance(raw_value, datetime):
|
||||||
|
return raw_value if timezone.is_aware(raw_value) else None
|
||||||
|
if not isinstance(raw_value, str) or not raw_value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(raw_value.strip().replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return value if timezone.is_aware(value) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _checkout_may_be_confirmed(event, registration, paid_at, now):
|
||||||
|
if registration is None:
|
||||||
|
return False
|
||||||
|
if registration.status in (
|
||||||
|
Registration.StatusChoices.CONFIRMED,
|
||||||
|
Registration.StatusChoices.ATTENDED,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
if event.status == Event.StatusChoices.CANCELLED:
|
||||||
|
return False
|
||||||
|
|
||||||
|
expiry = registration.checkout_expires_at
|
||||||
|
if registration.status == Registration.StatusChoices.PENDING:
|
||||||
|
if registration.cancellation_reason:
|
||||||
|
return False
|
||||||
|
if expiry is None or expiry > now:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if (
|
||||||
|
registration.status == Registration.StatusChoices.CANCELLED
|
||||||
|
and registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
|
||||||
|
and expiry is not None
|
||||||
|
and paid_at is not None
|
||||||
|
and paid_at <= expiry
|
||||||
|
and event.has_available_slots
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_payment_refund(payment_id):
|
||||||
|
from apps.payments.tasks import refund_payment
|
||||||
|
|
||||||
|
def dispatch():
|
||||||
|
try:
|
||||||
|
refund_payment.apply_async(args=(payment_id,), countdown=0)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unable to dispatch refund reversal for payment %s", payment_id)
|
||||||
|
|
||||||
|
transaction.on_commit(
|
||||||
|
dispatch
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _mark_payment_paid(payment_id: int, verification_data: dict | None = None):
|
def _mark_payment_paid(payment_id: int, verification_data: dict | None = None):
|
||||||
"""Persist a verified payment and its registration as one idempotent transition."""
|
"""Persist provider success without reviving a cancelled checkout by accident."""
|
||||||
|
payment_snapshot = Payment.objects.only("event_id", "registration_id").get(pk=payment_id)
|
||||||
|
paid_at = _provider_paid_at(verification_data)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
event = Event.objects.select_for_update().get(pk=payment_snapshot.event_id)
|
||||||
|
registration = None
|
||||||
|
if payment_snapshot.registration_id:
|
||||||
|
registration = Registration.objects.select_for_update().filter(
|
||||||
|
pk=payment_snapshot.registration_id
|
||||||
|
).first()
|
||||||
payment = (
|
payment = (
|
||||||
Payment.objects.select_for_update()
|
Payment.objects.select_for_update()
|
||||||
.select_related("event", "user")
|
|
||||||
.get(pk=payment_id)
|
.get(pk=payment_id)
|
||||||
)
|
)
|
||||||
was_already_verified = (
|
was_already_verified = (
|
||||||
@@ -483,27 +678,63 @@ def _mark_payment_paid(payment_id: int, verification_data: dict | None = None):
|
|||||||
payment.card_hash = verification_data.get("card_hash")
|
payment.card_hash = verification_data.get("card_hash")
|
||||||
payment.verified_at = timezone.now()
|
payment.verified_at = timezone.now()
|
||||||
updates.extend(["ref_id", "card_pan", "card_hash", "verified_at"])
|
updates.extend(["ref_id", "card_pan", "card_hash", "verified_at"])
|
||||||
|
if paid_at is not None and payment.gateway_paid_at is None:
|
||||||
|
payment.gateway_paid_at = paid_at
|
||||||
|
updates.append("gateway_paid_at")
|
||||||
|
for field in (
|
||||||
|
"verification_next_attempt_at",
|
||||||
|
"verification_lease_until",
|
||||||
|
"verification_dispatch_expires_at",
|
||||||
|
"verification_manual_review_at",
|
||||||
|
):
|
||||||
|
if getattr(payment, field) is not None:
|
||||||
|
setattr(payment, field, None)
|
||||||
|
updates.append(field)
|
||||||
if updates:
|
if updates:
|
||||||
payment.save(update_fields=updates)
|
payment.save(update_fields=updates)
|
||||||
|
|
||||||
registration = None
|
now = timezone.now()
|
||||||
if payment.registration_id:
|
if registration and registration.status == Registration.StatusChoices.PENDING:
|
||||||
registration = Registration.objects.select_for_update().filter(
|
if event.status == Event.StatusChoices.CANCELLED:
|
||||||
pk=payment.registration_id
|
registration.status = Registration.StatusChoices.CANCELLED
|
||||||
).first()
|
registration.cancellation_reason = Registration.CancellationReasonChoices.EVENT_CANCELLED
|
||||||
if registration is None:
|
registration.save(update_fields=["status", "cancellation_reason", "updated_at"])
|
||||||
registration = Registration.objects.select_for_update().filter(
|
elif (
|
||||||
user_id=payment.user_id,
|
registration.checkout_expires_at is not None
|
||||||
event_id=payment.event_id,
|
and registration.checkout_expires_at <= now
|
||||||
status=Registration.StatusChoices.PENDING,
|
and not registration.cancellation_reason
|
||||||
).first()
|
):
|
||||||
if registration and registration.status != Registration.StatusChoices.CONFIRMED:
|
registration.status = Registration.StatusChoices.CANCELLED
|
||||||
|
registration.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.status = Registration.StatusChoices.CONFIRMED
|
||||||
registration_updates = ["status"]
|
registration_updates = ["status"]
|
||||||
if registration.final_price is None:
|
if registration.final_price is None:
|
||||||
registration.final_price = payment.amount
|
registration.final_price = payment.amount
|
||||||
registration_updates.append("final_price")
|
registration_updates.append("final_price")
|
||||||
registration.save(update_fields=registration_updates)
|
registration.save(update_fields=registration_updates)
|
||||||
|
if payment.refund_status != Payment.RefundStatusChoices.NONE:
|
||||||
|
payment.refund_status = Payment.RefundStatusChoices.NONE
|
||||||
|
payment.refund_last_error = ""
|
||||||
|
payment.refund_next_attempt_at = None
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"refund_status", "refund_last_error", "refund_next_attempt_at",
|
||||||
|
])
|
||||||
|
elif not was_already_verified and payment.refund_status not in (
|
||||||
|
Payment.RefundStatusChoices.SUCCEEDED,
|
||||||
|
Payment.RefundStatusChoices.MANUAL_REVIEW,
|
||||||
|
):
|
||||||
|
payment.refund_status = Payment.RefundStatusChoices.PENDING
|
||||||
|
payment.refund_next_attempt_at = None
|
||||||
|
payment.save(update_fields=["refund_status", "refund_next_attempt_at"])
|
||||||
|
_queue_payment_refund(payment.id)
|
||||||
|
|
||||||
return payment, not was_already_verified
|
return payment, not was_already_verified
|
||||||
|
|
||||||
@@ -516,7 +747,11 @@ def _mark_payment_pending(payment_id: int):
|
|||||||
if payment.status != Payment.OrderStatusChoices.PAID:
|
if payment.status != Payment.OrderStatusChoices.PAID:
|
||||||
payment.status = Payment.OrderStatusChoices.PAID
|
payment.status = Payment.OrderStatusChoices.PAID
|
||||||
payment.save(update_fields=["status"])
|
payment.save(update_fields=["status"])
|
||||||
elif payment.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.CANCELED):
|
elif payment.status in (
|
||||||
|
Payment.OrderStatusChoices.INIT,
|
||||||
|
Payment.OrderStatusChoices.FAILED,
|
||||||
|
Payment.OrderStatusChoices.CANCELED,
|
||||||
|
):
|
||||||
payment.status = Payment.OrderStatusChoices.PENDING
|
payment.status = Payment.OrderStatusChoices.PENDING
|
||||||
payment.save(update_fields=["status"])
|
payment.save(update_fields=["status"])
|
||||||
return payment
|
return payment
|
||||||
@@ -524,10 +759,16 @@ def _mark_payment_pending(payment_id: int):
|
|||||||
|
|
||||||
def _mark_payment_unsuccessful(payment_id: int, target_status: int):
|
def _mark_payment_unsuccessful(payment_id: int, target_status: int):
|
||||||
"""Apply a failure only while the payment has not been verified successfully."""
|
"""Apply a failure only while the payment has not been verified successfully."""
|
||||||
|
snapshot = Payment.objects.only("event_id", "registration_id", "user_id").get(pk=payment_id)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
Event.objects.select_for_update().get(pk=snapshot.event_id)
|
||||||
|
registration = None
|
||||||
|
if snapshot.registration_id:
|
||||||
|
registration = Registration.objects.select_for_update().filter(
|
||||||
|
pk=snapshot.registration_id,
|
||||||
|
).first()
|
||||||
payment = (
|
payment = (
|
||||||
Payment.objects.select_for_update()
|
Payment.objects.select_for_update()
|
||||||
.select_related("event", "user")
|
|
||||||
.get(pk=payment_id)
|
.get(pk=payment_id)
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
@@ -551,7 +792,17 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
|
|||||||
transitioned = False
|
transitioned = False
|
||||||
else:
|
else:
|
||||||
payment.status = target_status
|
payment.status = target_status
|
||||||
payment.save(update_fields=["status"])
|
payment.verification_next_attempt_at = None
|
||||||
|
payment.verification_lease_until = None
|
||||||
|
payment.verification_dispatch_expires_at = None
|
||||||
|
payment.verification_manual_review_at = None
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"status",
|
||||||
|
"verification_next_attempt_at",
|
||||||
|
"verification_lease_until",
|
||||||
|
"verification_dispatch_expires_at",
|
||||||
|
"verification_manual_review_at",
|
||||||
|
])
|
||||||
result = (
|
result = (
|
||||||
"canceled"
|
"canceled"
|
||||||
if target_status == Payment.OrderStatusChoices.CANCELED
|
if target_status == Payment.OrderStatusChoices.CANCELED
|
||||||
@@ -559,13 +810,7 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
|
|||||||
)
|
)
|
||||||
transitioned = True
|
transitioned = True
|
||||||
|
|
||||||
registration = None
|
if registration is None:
|
||||||
if payment.registration_id:
|
|
||||||
registration = Registration.objects.select_for_update().filter(
|
|
||||||
pk=payment.registration_id,
|
|
||||||
status=Registration.StatusChoices.PENDING,
|
|
||||||
).first()
|
|
||||||
else:
|
|
||||||
registration = Registration.objects.select_for_update().filter(
|
registration = Registration.objects.select_for_update().filter(
|
||||||
user_id=payment.user_id,
|
user_id=payment.user_id,
|
||||||
event_id=payment.event_id,
|
event_id=payment.event_id,
|
||||||
@@ -589,7 +834,10 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
|
|||||||
Registration.objects.filter(
|
Registration.objects.filter(
|
||||||
pk=registration.pk,
|
pk=registration.pk,
|
||||||
status=Registration.StatusChoices.PENDING,
|
status=Registration.StatusChoices.PENDING,
|
||||||
).update(status=Registration.StatusChoices.CANCELLED)
|
).update(
|
||||||
|
status=Registration.StatusChoices.CANCELLED,
|
||||||
|
cancellation_reason=Registration.CancellationReasonChoices.PAYMENT_FAILED,
|
||||||
|
)
|
||||||
|
|
||||||
return payment, result, transitioned
|
return payment, result, transitioned
|
||||||
|
|
||||||
@@ -597,25 +845,46 @@ def _mark_payment_unsuccessful(payment_id: int, target_status: int):
|
|||||||
@payments_router.get("callback")
|
@payments_router.get("callback")
|
||||||
def callback(request, Authority: str | None = None, Status: str | None = None):
|
def callback(request, Authority: str | None = None, Status: str | None = None):
|
||||||
if not Authority:
|
if not Authority:
|
||||||
raise HttpError(400, "Missing Authority")
|
return _callback_unknown_redirect()
|
||||||
|
|
||||||
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
|
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
|
||||||
if not pay:
|
if not pay:
|
||||||
raise HttpError(404, "Payment not found")
|
return _callback_unknown_redirect()
|
||||||
|
gateway_says_success = (Status or "").strip().upper() == "OK"
|
||||||
if pay.status == Payment.OrderStatusChoices.PAID or pay.verified_at is not None:
|
if pay.status == Payment.OrderStatusChoices.PAID or pay.verified_at is not None:
|
||||||
pay, _ = _mark_payment_paid(pay.id)
|
pay, _ = _mark_payment_paid(pay.id)
|
||||||
return _callback_redirect(pay, "success")
|
return _callback_redirect(pay, "success")
|
||||||
|
if pay.status in (
|
||||||
|
Payment.OrderStatusChoices.FAILED,
|
||||||
|
Payment.OrderStatusChoices.CANCELED,
|
||||||
|
) and not gateway_says_success:
|
||||||
|
return _callback_redirect(pay, _payment_status_name(pay))
|
||||||
|
|
||||||
|
pay, claimed = claim_payment_verification(
|
||||||
|
pay.id,
|
||||||
|
allow_terminal=gateway_says_success,
|
||||||
|
allow_manual_review=gateway_says_success,
|
||||||
|
)
|
||||||
|
if not claimed:
|
||||||
|
return _callback_redirect(pay, _payment_status_name(pay))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
vcode, data = request_payment_verification(pay)
|
vcode, data = request_payment_verification(pay)
|
||||||
except PaymentVerificationUncertain:
|
except PaymentVerificationUncertain:
|
||||||
pay = _mark_payment_pending(pay.id)
|
pay = _mark_payment_pending(pay.id)
|
||||||
if pay.status == Payment.OrderStatusChoices.PENDING and pay.authority:
|
delay = schedule_payment_verification_retry(pay.id)
|
||||||
|
pay.refresh_from_db()
|
||||||
|
if delay is not None and pay.status == Payment.OrderStatusChoices.PENDING:
|
||||||
try:
|
try:
|
||||||
reconcile_payment.apply_async(args=(pay.id,), countdown=60)
|
reconcile_payment.apply_async(args=(pay.id,), countdown=delay)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Unable to queue payment verification retry for payment %s; it remains pending for manual review",
|
"Unable to queue payment verification retry for payment %s; the recovery sweep will retry it",
|
||||||
|
pay.id,
|
||||||
|
)
|
||||||
|
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,
|
pay.id,
|
||||||
)
|
)
|
||||||
return _callback_redirect(pay, _payment_status_name(pay))
|
return _callback_redirect(pay, _payment_status_name(pay))
|
||||||
@@ -624,22 +893,48 @@ def callback(request, Authority: str | None = None, Status: str | None = None):
|
|||||||
pay, newly_verified = _mark_payment_paid(pay.id, data)
|
pay, newly_verified = _mark_payment_paid(pay.id, data)
|
||||||
|
|
||||||
if newly_verified:
|
if newly_verified:
|
||||||
|
refund_pending = pay.refund_status in (
|
||||||
|
Payment.RefundStatusChoices.PENDING,
|
||||||
|
Payment.RefundStatusChoices.MANUAL_REVIEW,
|
||||||
|
)
|
||||||
_notify_payment_status(
|
_notify_payment_status(
|
||||||
pay,
|
pay,
|
||||||
title=f"پرداخت رویداد «{pay.event.title}» تأیید شد",
|
title=(
|
||||||
message="پرداخت شما با موفقیت ثبت شد و ثبتنام رویداد تکمیل شده است.",
|
f"پرداخت رویداد «{pay.event.title}» تأیید شد؛ بازگشت وجه در حال پیگیری است"
|
||||||
level="success",
|
if refund_pending
|
||||||
|
else f"پرداخت رویداد «{pay.event.title}» تأیید شد"
|
||||||
|
),
|
||||||
|
message=(
|
||||||
|
"ثبتنام لغو شده است. بازگشت وجه بهصورت خودکار پیگیری میشود؛ اگر نتیجه روشن نشود، پشتیبانی آن را بررسی میکند."
|
||||||
|
if refund_pending
|
||||||
|
else "پرداخت شما با موفقیت ثبت شد و ثبتنام رویداد تکمیل شده است."
|
||||||
|
),
|
||||||
|
level="warning" if refund_pending else "success",
|
||||||
)
|
)
|
||||||
|
|
||||||
return _callback_redirect(pay, "success")
|
return _callback_redirect(pay, "success")
|
||||||
|
|
||||||
pay, result, transitioned = _mark_payment_unsuccessful(pay.id, Payment.OrderStatusChoices.FAILED)
|
target_status = (
|
||||||
|
Payment.OrderStatusChoices.CANCELED
|
||||||
|
if (Status or "").strip().upper() == "NOK"
|
||||||
|
else Payment.OrderStatusChoices.FAILED
|
||||||
|
)
|
||||||
|
pay, result, transitioned = _mark_payment_unsuccessful(pay.id, target_status)
|
||||||
if transitioned:
|
if transitioned:
|
||||||
|
canceled = result == "canceled"
|
||||||
_notify_payment_status(
|
_notify_payment_status(
|
||||||
pay,
|
pay,
|
||||||
title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود",
|
title=(
|
||||||
message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.",
|
f"پرداخت رویداد «{pay.event.title}» لغو شد"
|
||||||
level="error",
|
if canceled
|
||||||
|
else f"پرداخت رویداد «{pay.event.title}» ناموفق بود"
|
||||||
|
),
|
||||||
|
message=(
|
||||||
|
"پرداخت تکمیل نشد و ثبتنام نهایی نشد. برای شروع دوباره به صفحه رویداد برگردید."
|
||||||
|
if canceled
|
||||||
|
else "تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید."
|
||||||
|
),
|
||||||
|
level="warning" if canceled else "error",
|
||||||
)
|
)
|
||||||
return _callback_redirect(pay, result)
|
return _callback_redirect(pay, result)
|
||||||
|
|
||||||
@@ -666,6 +961,34 @@ def _payment_detail_payload(request, payment: Payment):
|
|||||||
"amount": payment.amount,
|
"amount": payment.amount,
|
||||||
"status": _payment_status_name(payment),
|
"status": _payment_status_name(payment),
|
||||||
"verified_at": payment.verified_at.isoformat() if payment.verified_at else None,
|
"verified_at": payment.verified_at.isoformat() if payment.verified_at else None,
|
||||||
|
"gateway_paid_at": payment.gateway_paid_at.isoformat() if payment.gateway_paid_at else None,
|
||||||
|
"refund_status": payment.refund_status,
|
||||||
|
"refund_attempts": payment.refund_attempts,
|
||||||
|
"refund_last_attempt_at": (
|
||||||
|
payment.refund_last_attempt_at.isoformat() if payment.refund_last_attempt_at else None
|
||||||
|
),
|
||||||
|
"refund_next_attempt_at": (
|
||||||
|
payment.refund_next_attempt_at.isoformat() if payment.refund_next_attempt_at else None
|
||||||
|
),
|
||||||
|
"refund_reference": payment.refund_reference,
|
||||||
|
"refund_last_error": payment.refund_last_error or None,
|
||||||
|
"verification_state": payment.verification_state,
|
||||||
|
"verification_attempts": payment.verification_attempts,
|
||||||
|
"last_verification_at": (
|
||||||
|
payment.verification_last_attempt_at.isoformat()
|
||||||
|
if payment.verification_last_attempt_at
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"next_verification_at": (
|
||||||
|
payment.verification_next_attempt_at.isoformat()
|
||||||
|
if payment.verification_next_attempt_at
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"manual_review_at": (
|
||||||
|
payment.verification_manual_review_at.isoformat()
|
||||||
|
if payment.verification_manual_review_at
|
||||||
|
else None
|
||||||
|
),
|
||||||
"event": {
|
"event": {
|
||||||
"id": event.id,
|
"id": event.id,
|
||||||
"title": event.title,
|
"title": event.title,
|
||||||
@@ -676,9 +999,13 @@ def _payment_detail_payload(request, payment: Payment):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)
|
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut, auth=jwt_auth)
|
||||||
def payment_by_ref(request, ref_id: str):
|
def payment_by_ref(request, ref_id: str):
|
||||||
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id)
|
pay = get_object_or_404(
|
||||||
|
Payment.objects.select_related("event"),
|
||||||
|
ref_id=ref_id,
|
||||||
|
user=request.auth,
|
||||||
|
)
|
||||||
return _payment_detail_payload(request, pay)
|
return _payment_detail_payload(request, pay)
|
||||||
|
|
||||||
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
|
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
apps/payments/migrations/0007_payment_gateway_paid_at.py
Normal file
18
apps/payments/migrations/0007_payment_gateway_paid_at.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2026-10-01 04:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('payments', '0006_payment_refund_attempts_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='gateway_paid_at',
|
||||||
|
field=models.DateTimeField(blank=True, editable=False, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -81,6 +81,12 @@ class Payment(BaseModel):
|
|||||||
FAILED = 3, "Failed"
|
FAILED = 3, "Failed"
|
||||||
CANCELED = 4, "Canceled"
|
CANCELED = 4, "Canceled"
|
||||||
|
|
||||||
|
class RefundStatusChoices(models.TextChoices):
|
||||||
|
NONE = "none", "No refund required"
|
||||||
|
PENDING = "pending", "Refund pending"
|
||||||
|
SUCCEEDED = "succeeded", "Refund completed"
|
||||||
|
MANUAL_REVIEW = "manual_review", "Refund needs review"
|
||||||
|
|
||||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payments', editable=False)
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payments', editable=False)
|
||||||
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='payments', editable=False)
|
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='payments', editable=False)
|
||||||
|
|
||||||
@@ -104,6 +110,24 @@ class Payment(BaseModel):
|
|||||||
card_pan = models.CharField(max_length=32, null=True, blank=True, editable=False)
|
card_pan = models.CharField(max_length=32, null=True, blank=True, editable=False)
|
||||||
card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False)
|
card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False)
|
||||||
verified_at = models.DateTimeField(null=True, blank=True, editable=False)
|
verified_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
gateway_paid_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
verification_attempts = models.PositiveSmallIntegerField(default=0, editable=False)
|
||||||
|
verification_last_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
verification_next_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
verification_lease_until = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
verification_dispatch_expires_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
verification_manual_review_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
refund_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=RefundStatusChoices.choices,
|
||||||
|
default=RefundStatusChoices.NONE,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
refund_attempts = models.PositiveSmallIntegerField(default=0, editable=False)
|
||||||
|
refund_last_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
refund_next_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||||
|
refund_reference = models.CharField(max_length=64, null=True, blank=True, editable=False)
|
||||||
|
refund_last_error = models.CharField(max_length=255, blank=True, default='', editable=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
constraints = [
|
constraints = [
|
||||||
@@ -127,5 +151,24 @@ class Payment(BaseModel):
|
|||||||
"""Human-readable label for the payment status."""
|
"""Human-readable label for the payment status."""
|
||||||
return self.get_status_display()
|
return self.get_status_display()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def verification_state(self):
|
||||||
|
"""Server-owned verification state for callback and support screens."""
|
||||||
|
if self.status == self.OrderStatusChoices.PAID or self.verified_at is not None:
|
||||||
|
return "verified"
|
||||||
|
if self.status == self.OrderStatusChoices.FAILED:
|
||||||
|
return "failed"
|
||||||
|
if self.status == self.OrderStatusChoices.CANCELED:
|
||||||
|
return "canceled"
|
||||||
|
if self.verification_manual_review_at is not None or not self.authority:
|
||||||
|
return "manual_review"
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
if self.verification_lease_until and self.verification_lease_until > now:
|
||||||
|
return "checking"
|
||||||
|
if self.verification_next_attempt_at:
|
||||||
|
return "retry_scheduled"
|
||||||
|
return "awaiting_verification"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user.email}:{self.event} - {self.get_status_display()}"
|
return f"{self.user.email}:{self.event} - {self.get_status_display()}"
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"""Gateway operations whose responses must be interpreted conservatively."""
|
"""Gateway operations and durable verification scheduling."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
@@ -8,6 +12,14 @@ class PaymentVerificationUncertain(Exception):
|
|||||||
"""The gateway response does not prove either payment success or failure."""
|
"""The gateway response does not prove either payment success or failure."""
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentReversalUncertain(Exception):
|
||||||
|
"""The gateway did not give a conclusive response to a reversal request."""
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentReversalRejected(Exception):
|
||||||
|
"""The gateway definitively rejected the reversal and needs human review."""
|
||||||
|
|
||||||
|
|
||||||
def request_payment_verification(payment):
|
def request_payment_verification(payment):
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
@@ -46,3 +58,184 @@ def request_payment_verification(payment):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return code, body["data"]
|
return code, body["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def request_payment_reversal(payment):
|
||||||
|
"""Reverse a paid transaction through ZarinPal's transaction reversal API."""
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
settings.ZARINPAL_REVERSE_URL,
|
||||||
|
json={
|
||||||
|
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
||||||
|
"authority": payment.authority,
|
||||||
|
},
|
||||||
|
headers={"accept": "application/json", "content-type": "application/json"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
status_code = getattr(response, "status_code", 200)
|
||||||
|
if isinstance(status_code, int) and status_code >= 500:
|
||||||
|
raise PaymentReversalUncertain(
|
||||||
|
f"ZarinPal reversal returned HTTP {status_code}"
|
||||||
|
)
|
||||||
|
if isinstance(status_code, int) and status_code >= 400:
|
||||||
|
raise PaymentReversalRejected(
|
||||||
|
f"ZarinPal reversal was rejected with HTTP {status_code}"
|
||||||
|
)
|
||||||
|
body = response.json()
|
||||||
|
except (PaymentReversalUncertain, PaymentReversalRejected):
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise PaymentReversalUncertain("ZarinPal reversal request failed") from exc
|
||||||
|
|
||||||
|
if not isinstance(body, dict) or not isinstance(body.get("data"), dict):
|
||||||
|
if isinstance(body, dict) and isinstance(body.get("errors"), dict):
|
||||||
|
raise PaymentReversalRejected("ZarinPal rejected the reversal request")
|
||||||
|
raise PaymentReversalUncertain("ZarinPal returned an invalid reversal response")
|
||||||
|
|
||||||
|
code = body["data"].get("code")
|
||||||
|
try:
|
||||||
|
code = int(code)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise PaymentReversalUncertain("ZarinPal returned no reversal status code") from exc
|
||||||
|
|
||||||
|
if code != 100:
|
||||||
|
raise PaymentReversalRejected(f"ZarinPal rejected the reversal with code {code}")
|
||||||
|
return body["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def claim_payment_verification(
|
||||||
|
payment_id: int,
|
||||||
|
*,
|
||||||
|
lease_seconds: int = 120,
|
||||||
|
allow_terminal: bool = False,
|
||||||
|
allow_manual_review: bool = False,
|
||||||
|
):
|
||||||
|
"""Claim one bounded verification attempt using the database as the lock."""
|
||||||
|
from apps.payments.models import Payment
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
try:
|
||||||
|
payment = Payment.objects.select_for_update().get(pk=payment_id)
|
||||||
|
except Payment.DoesNotExist:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
if payment.status == Payment.OrderStatusChoices.PAID or (
|
||||||
|
payment.status in (
|
||||||
|
Payment.OrderStatusChoices.FAILED,
|
||||||
|
Payment.OrderStatusChoices.CANCELED,
|
||||||
|
)
|
||||||
|
and not allow_terminal
|
||||||
|
):
|
||||||
|
return payment, False
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
if payment.verification_manual_review_at is not None and not allow_manual_review:
|
||||||
|
return payment, False
|
||||||
|
if not payment.authority:
|
||||||
|
_mark_payment_manual_review(payment, now)
|
||||||
|
return payment, False
|
||||||
|
if payment.verification_lease_until and payment.verification_lease_until > now:
|
||||||
|
return payment, False
|
||||||
|
|
||||||
|
was_dispatched = bool(
|
||||||
|
payment.verification_dispatch_expires_at
|
||||||
|
and payment.verification_dispatch_expires_at > now
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
payment.verification_next_attempt_at
|
||||||
|
and payment.verification_next_attempt_at > now
|
||||||
|
and not was_dispatched
|
||||||
|
):
|
||||||
|
return payment, False
|
||||||
|
|
||||||
|
max_attempts = getattr(settings, "PAYMENT_VERIFICATION_MAX_ATTEMPTS", 6)
|
||||||
|
if payment.verification_attempts >= max_attempts and not allow_manual_review:
|
||||||
|
_mark_payment_manual_review(payment, now)
|
||||||
|
return payment, False
|
||||||
|
|
||||||
|
payment.verification_attempts += 1
|
||||||
|
payment.verification_last_attempt_at = now
|
||||||
|
payment.verification_next_attempt_at = None
|
||||||
|
payment.verification_lease_until = now + timedelta(seconds=lease_seconds)
|
||||||
|
payment.verification_dispatch_expires_at = None
|
||||||
|
payment.verification_manual_review_at = None
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"verification_attempts",
|
||||||
|
"verification_last_attempt_at",
|
||||||
|
"verification_next_attempt_at",
|
||||||
|
"verification_lease_until",
|
||||||
|
"verification_dispatch_expires_at",
|
||||||
|
"verification_manual_review_at",
|
||||||
|
])
|
||||||
|
return payment, True
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_payment_verification_retry(payment_id: int):
|
||||||
|
"""Persist the next backoff step or stop at manual review after exhaustion."""
|
||||||
|
from apps.payments.models import Payment
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
try:
|
||||||
|
payment = Payment.objects.select_for_update().get(pk=payment_id)
|
||||||
|
except Payment.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
payment.verification_lease_until = None
|
||||||
|
payment.verification_dispatch_expires_at = None
|
||||||
|
if payment.status in (
|
||||||
|
Payment.OrderStatusChoices.PAID,
|
||||||
|
Payment.OrderStatusChoices.FAILED,
|
||||||
|
Payment.OrderStatusChoices.CANCELED,
|
||||||
|
):
|
||||||
|
payment.verification_next_attempt_at = None
|
||||||
|
payment.verification_manual_review_at = None
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"verification_lease_until",
|
||||||
|
"verification_dispatch_expires_at",
|
||||||
|
"verification_next_attempt_at",
|
||||||
|
"verification_manual_review_at",
|
||||||
|
])
|
||||||
|
return None
|
||||||
|
|
||||||
|
max_attempts = getattr(settings, "PAYMENT_VERIFICATION_MAX_ATTEMPTS", 6)
|
||||||
|
if payment.verification_attempts >= max_attempts:
|
||||||
|
_mark_payment_manual_review(payment, timezone.now())
|
||||||
|
return None
|
||||||
|
|
||||||
|
attempt_index = max(payment.verification_attempts - 1, 0)
|
||||||
|
delay = min(60 * (2 ** attempt_index), 15 * 60)
|
||||||
|
payment.verification_next_attempt_at = timezone.now() + timedelta(seconds=delay)
|
||||||
|
payment.verification_manual_review_at = None
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"verification_lease_until",
|
||||||
|
"verification_dispatch_expires_at",
|
||||||
|
"verification_next_attempt_at",
|
||||||
|
"verification_manual_review_at",
|
||||||
|
])
|
||||||
|
return delay
|
||||||
|
|
||||||
|
|
||||||
|
def mark_payment_manual_review(payment_id: int):
|
||||||
|
"""Keep an unresolved payment pending and make support review durable."""
|
||||||
|
from apps.payments.models import Payment
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
try:
|
||||||
|
payment = Payment.objects.select_for_update().get(pk=payment_id)
|
||||||
|
except Payment.DoesNotExist:
|
||||||
|
return None
|
||||||
|
_mark_payment_manual_review(payment, timezone.now())
|
||||||
|
return payment
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_payment_manual_review(payment, now):
|
||||||
|
payment.verification_manual_review_at = payment.verification_manual_review_at or now
|
||||||
|
payment.verification_next_attempt_at = None
|
||||||
|
payment.verification_lease_until = None
|
||||||
|
payment.verification_dispatch_expires_at = None
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"verification_manual_review_at",
|
||||||
|
"verification_next_attempt_at",
|
||||||
|
"verification_lease_until",
|
||||||
|
"verification_dispatch_expires_at",
|
||||||
|
])
|
||||||
|
|||||||
@@ -1,32 +1,63 @@
|
|||||||
"""Bounded retries for payment verification after an inconclusive callback."""
|
"""Bounded payment verification and recovery for lost gateway callbacks."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.shortcuts import get_object_or_404
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.events.models import Registration
|
||||||
from apps.payments.models import Payment
|
from apps.payments.models import Payment
|
||||||
from apps.payments.services import PaymentVerificationUncertain, request_payment_verification
|
from apps.payments.services import (
|
||||||
|
PaymentReversalRejected,
|
||||||
|
PaymentReversalUncertain,
|
||||||
|
PaymentVerificationUncertain,
|
||||||
|
claim_payment_verification,
|
||||||
|
request_payment_verification,
|
||||||
|
request_payment_reversal,
|
||||||
|
schedule_payment_verification_retry,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@shared_task(bind=True, max_retries=5, name="apps.payments.reconcile_payment")
|
def _status_result(payment):
|
||||||
def reconcile_payment(self, payment_id: int):
|
return {
|
||||||
payment = get_object_or_404(
|
Payment.OrderStatusChoices.PAID: "paid",
|
||||||
Payment.objects.select_related("event", "user"),
|
Payment.OrderStatusChoices.FAILED: "failed",
|
||||||
pk=payment_id,
|
Payment.OrderStatusChoices.CANCELED: "canceled",
|
||||||
)
|
}.get(payment.status)
|
||||||
if payment.status in (Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.FAILED):
|
|
||||||
return payment.get_status_display().lower()
|
|
||||||
if not payment.authority:
|
|
||||||
logger.error(
|
|
||||||
"Payment %s remains pending without a gateway authority; manual review is required",
|
|
||||||
payment.id,
|
|
||||||
)
|
|
||||||
return "pending_manual_review"
|
|
||||||
|
|
||||||
# Imported at task runtime to avoid a view/task import cycle.
|
|
||||||
|
def _queue_retry(payment_id: int, countdown: int):
|
||||||
|
try:
|
||||||
|
reconcile_payment.apply_async(args=(payment_id,), countdown=countdown)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Unable to queue payment verification for payment %s; the recovery sweep will retry it",
|
||||||
|
payment_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="apps.payments.reconcile_payment")
|
||||||
|
def reconcile_payment(payment_id: int):
|
||||||
|
payment, claimed = claim_payment_verification(payment_id)
|
||||||
|
if payment is None:
|
||||||
|
return "not_found"
|
||||||
|
if not claimed:
|
||||||
|
terminal_result = _status_result(payment)
|
||||||
|
if terminal_result:
|
||||||
|
return terminal_result
|
||||||
|
if payment.verification_manual_review_at is not None or not payment.authority:
|
||||||
|
return "pending_manual_review"
|
||||||
|
if payment.verification_lease_until and payment.verification_lease_until > timezone.now():
|
||||||
|
return "verification_in_progress"
|
||||||
|
return "not_due"
|
||||||
|
|
||||||
|
# Imported here to avoid a view/task import cycle.
|
||||||
from apps.payments.api.views import (
|
from apps.payments.api.views import (
|
||||||
_mark_payment_paid,
|
_mark_payment_paid,
|
||||||
_mark_payment_pending,
|
_mark_payment_pending,
|
||||||
@@ -38,24 +69,37 @@ def reconcile_payment(self, payment_id: int):
|
|||||||
code, data = request_payment_verification(payment)
|
code, data = request_payment_verification(payment)
|
||||||
except PaymentVerificationUncertain as exc:
|
except PaymentVerificationUncertain as exc:
|
||||||
_mark_payment_pending(payment.id)
|
_mark_payment_pending(payment.id)
|
||||||
if self.request.retries >= self.max_retries:
|
delay = schedule_payment_verification_retry(payment.id)
|
||||||
|
if delay is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Payment %s verification remains uncertain after bounded retries; manual review is required",
|
"Payment %s remains pending after exhausting verification attempts; manual review is required",
|
||||||
payment.id,
|
payment.id,
|
||||||
exc_info=exc,
|
exc_info=exc,
|
||||||
)
|
)
|
||||||
return "pending_manual_review"
|
return "pending_manual_review"
|
||||||
countdown = min(60 * (2 ** self.request.retries), 15 * 60)
|
_queue_retry(payment.id, delay)
|
||||||
raise self.retry(exc=exc, countdown=countdown)
|
return "retry_scheduled"
|
||||||
|
|
||||||
if code in (100, 101):
|
if code in (100, 101):
|
||||||
payment, newly_verified = _mark_payment_paid(payment.id, data)
|
payment, newly_verified = _mark_payment_paid(payment.id, data)
|
||||||
if newly_verified:
|
if newly_verified:
|
||||||
|
refund_pending = payment.refund_status in (
|
||||||
|
Payment.RefundStatusChoices.PENDING,
|
||||||
|
Payment.RefundStatusChoices.MANUAL_REVIEW,
|
||||||
|
)
|
||||||
_notify_payment_status(
|
_notify_payment_status(
|
||||||
payment,
|
payment,
|
||||||
title=f"پرداخت رویداد «{payment.event.title}» تأیید شد",
|
title=(
|
||||||
message="پرداخت شما با موفقیت ثبت شد و ثبتنام رویداد تکمیل شده است.",
|
f"پرداخت رویداد «{payment.event.title}» تأیید شد؛ بازگشت وجه در حال پیگیری است"
|
||||||
level="success",
|
if refund_pending
|
||||||
|
else f"پرداخت رویداد «{payment.event.title}» تأیید شد"
|
||||||
|
),
|
||||||
|
message=(
|
||||||
|
"ثبتنام لغو شده است. بازگشت وجه بهصورت خودکار پیگیری میشود؛ اگر نتیجه روشن نشود، پشتیبانی آن را بررسی میکند."
|
||||||
|
if refund_pending
|
||||||
|
else "پرداخت شما با موفقیت ثبت شد و ثبتنام رویداد تکمیل شده است."
|
||||||
|
),
|
||||||
|
level="warning" if refund_pending else "success",
|
||||||
)
|
)
|
||||||
return "paid"
|
return "paid"
|
||||||
|
|
||||||
@@ -71,3 +115,204 @@ def reconcile_payment(self, payment_id: int):
|
|||||||
level="error",
|
level="error",
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="apps.payments.sweep_stale_payment_verifications")
|
||||||
|
def sweep_stale_payment_verifications():
|
||||||
|
"""Queue due old attempts so lost callbacks and broker failures recover safely."""
|
||||||
|
now = timezone.now()
|
||||||
|
grace_seconds = max(
|
||||||
|
0,
|
||||||
|
getattr(settings, "PAYMENT_VERIFICATION_GRACE_SECONDS", 1800),
|
||||||
|
)
|
||||||
|
cutoff = now - timedelta(seconds=grace_seconds)
|
||||||
|
max_attempts = getattr(settings, "PAYMENT_VERIFICATION_MAX_ATTEMPTS", 6)
|
||||||
|
dispatch_lease_seconds = max(
|
||||||
|
60,
|
||||||
|
getattr(settings, "PAYMENT_VERIFICATION_DISPATCH_LEASE_SECONDS", 900),
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = (
|
||||||
|
Payment.objects.filter(
|
||||||
|
status__in=[
|
||||||
|
Payment.OrderStatusChoices.INIT,
|
||||||
|
Payment.OrderStatusChoices.PENDING,
|
||||||
|
],
|
||||||
|
authority__isnull=False,
|
||||||
|
verification_attempts__lt=max_attempts,
|
||||||
|
verification_manual_review_at__isnull=True,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
Q(created_at__lte=cutoff)
|
||||||
|
| Q(registration__cancellation_reason__in=[
|
||||||
|
"checkout_expired", "user_cancelled", "event_cancelled",
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.filter(Q(verification_next_attempt_at__isnull=True) | Q(verification_next_attempt_at__lte=now))
|
||||||
|
.filter(Q(verification_lease_until__isnull=True) | Q(verification_lease_until__lte=now))
|
||||||
|
.filter(
|
||||||
|
Q(verification_dispatch_expires_at__isnull=True)
|
||||||
|
| Q(verification_dispatch_expires_at__lte=now)
|
||||||
|
)
|
||||||
|
.order_by("created_at", "pk")
|
||||||
|
.values_list("pk", flat=True)[:100]
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled = 0
|
||||||
|
for payment_id in candidates:
|
||||||
|
with transaction.atomic():
|
||||||
|
payment = Payment.objects.select_for_update().filter(pk=payment_id).first()
|
||||||
|
if payment is None:
|
||||||
|
continue
|
||||||
|
canceled_checkout = bool(
|
||||||
|
payment.registration_id
|
||||||
|
and Registration.objects.filter(
|
||||||
|
pk=payment.registration_id,
|
||||||
|
cancellation_reason__in=[
|
||||||
|
Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
|
||||||
|
Registration.CancellationReasonChoices.USER_CANCELLED,
|
||||||
|
Registration.CancellationReasonChoices.EVENT_CANCELLED,
|
||||||
|
],
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
payment.status not in (
|
||||||
|
Payment.OrderStatusChoices.INIT,
|
||||||
|
Payment.OrderStatusChoices.PENDING,
|
||||||
|
)
|
||||||
|
or not payment.authority
|
||||||
|
or (payment.created_at > cutoff and not canceled_checkout)
|
||||||
|
or payment.verification_attempts >= max_attempts
|
||||||
|
or payment.verification_manual_review_at is not None
|
||||||
|
or (payment.verification_lease_until and payment.verification_lease_until > now)
|
||||||
|
or (
|
||||||
|
payment.verification_next_attempt_at
|
||||||
|
and payment.verification_next_attempt_at > now
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
payment.verification_dispatch_expires_at
|
||||||
|
and payment.verification_dispatch_expires_at > now
|
||||||
|
)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
dispatch_expires_at = now + timedelta(seconds=dispatch_lease_seconds)
|
||||||
|
payment.verification_dispatch_expires_at = dispatch_expires_at
|
||||||
|
payment.save(update_fields=["verification_dispatch_expires_at"])
|
||||||
|
try:
|
||||||
|
reconcile_payment.apply_async(args=(payment.id,))
|
||||||
|
except Exception:
|
||||||
|
Payment.objects.filter(
|
||||||
|
pk=payment.id,
|
||||||
|
verification_dispatch_expires_at=dispatch_expires_at,
|
||||||
|
).update(verification_dispatch_expires_at=None)
|
||||||
|
logger.exception(
|
||||||
|
"Unable to dispatch stale payment %s for verification",
|
||||||
|
payment.id,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
scheduled += 1
|
||||||
|
|
||||||
|
return scheduled
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="apps.payments.refund_payment")
|
||||||
|
def refund_payment(payment_id: int):
|
||||||
|
"""Attempt a bounded ZarinPal reversal; surface unresolved refunds to admins."""
|
||||||
|
now = timezone.now()
|
||||||
|
lease = timedelta(seconds=max(60, getattr(settings, "PAYMENT_REFUND_LEASE_SECONDS", 120)))
|
||||||
|
with transaction.atomic():
|
||||||
|
payment = Payment.objects.select_for_update().filter(pk=payment_id).first()
|
||||||
|
if payment is None:
|
||||||
|
return "not_found"
|
||||||
|
if (
|
||||||
|
payment.status != Payment.OrderStatusChoices.PAID
|
||||||
|
or payment.refund_status != Payment.RefundStatusChoices.PENDING
|
||||||
|
):
|
||||||
|
return payment.refund_status
|
||||||
|
if payment.refund_next_attempt_at and payment.refund_next_attempt_at > now:
|
||||||
|
return "not_due"
|
||||||
|
if not payment.authority:
|
||||||
|
payment.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
|
||||||
|
payment.refund_last_error = "No gateway authority is available for reversal."
|
||||||
|
payment.refund_next_attempt_at = None
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"refund_status", "refund_last_error", "refund_next_attempt_at",
|
||||||
|
])
|
||||||
|
return "manual_review"
|
||||||
|
|
||||||
|
max_attempts = max(1, getattr(settings, "PAYMENT_REFUND_MAX_ATTEMPTS", 3))
|
||||||
|
if payment.refund_attempts >= max_attempts:
|
||||||
|
payment.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
|
||||||
|
payment.refund_next_attempt_at = None
|
||||||
|
payment.save(update_fields=["refund_status", "refund_next_attempt_at"])
|
||||||
|
return "manual_review"
|
||||||
|
|
||||||
|
payment.refund_attempts += 1
|
||||||
|
payment.refund_last_attempt_at = now
|
||||||
|
# Duplicate task deliveries cannot issue concurrent reversals. If a
|
||||||
|
# worker dies after the external request, the sweep retries at lease end.
|
||||||
|
payment.refund_next_attempt_at = now + lease
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"refund_attempts", "refund_last_attempt_at", "refund_next_attempt_at",
|
||||||
|
])
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request_payment_reversal(payment)
|
||||||
|
except PaymentReversalRejected as exc:
|
||||||
|
Payment.objects.filter(pk=payment_id).update(
|
||||||
|
refund_status=Payment.RefundStatusChoices.MANUAL_REVIEW,
|
||||||
|
refund_last_error=str(exc)[:255],
|
||||||
|
refund_next_attempt_at=None,
|
||||||
|
)
|
||||||
|
return "manual_review"
|
||||||
|
except PaymentReversalUncertain as exc:
|
||||||
|
with transaction.atomic():
|
||||||
|
current = Payment.objects.select_for_update().get(pk=payment_id)
|
||||||
|
current.refund_last_error = str(exc)[:255]
|
||||||
|
if current.refund_attempts >= max(1, getattr(settings, "PAYMENT_REFUND_MAX_ATTEMPTS", 3)):
|
||||||
|
current.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
|
||||||
|
current.refund_next_attempt_at = None
|
||||||
|
result = "manual_review"
|
||||||
|
else:
|
||||||
|
delay = min(60 * (2 ** (current.refund_attempts - 1)), 15 * 60)
|
||||||
|
current.refund_next_attempt_at = timezone.now() + timedelta(seconds=delay)
|
||||||
|
result = "retry_scheduled"
|
||||||
|
current.save(update_fields=[
|
||||||
|
"refund_status", "refund_last_error", "refund_next_attempt_at",
|
||||||
|
])
|
||||||
|
if result == "retry_scheduled":
|
||||||
|
try:
|
||||||
|
refund_payment.apply_async(args=(payment_id,), countdown=delay)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unable to queue refund retry for payment %s", payment_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
Payment.objects.filter(pk=payment_id).update(
|
||||||
|
refund_status=Payment.RefundStatusChoices.SUCCEEDED,
|
||||||
|
refund_reference=str(data.get("ref_id") or data.get("reference") or "")[:64] or None,
|
||||||
|
refund_last_error="",
|
||||||
|
refund_next_attempt_at=None,
|
||||||
|
)
|
||||||
|
return "refunded"
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="apps.payments.sweep_pending_refunds")
|
||||||
|
def sweep_pending_refunds():
|
||||||
|
"""Recover refund work when an immediate Celery dispatch is lost."""
|
||||||
|
now = timezone.now()
|
||||||
|
due_ids = Payment.objects.filter(
|
||||||
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
|
refund_status=Payment.RefundStatusChoices.PENDING,
|
||||||
|
).filter(
|
||||||
|
Q(refund_next_attempt_at__isnull=True) | Q(refund_next_attempt_at__lte=now)
|
||||||
|
).order_by("refund_last_attempt_at", "pk").values_list("pk", flat=True)[:100]
|
||||||
|
|
||||||
|
scheduled = 0
|
||||||
|
for payment_id in due_ids:
|
||||||
|
try:
|
||||||
|
refund_payment.apply_async(args=(payment_id,))
|
||||||
|
scheduled += 1
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unable to dispatch pending refund for payment %s", payment_id)
|
||||||
|
return scheduled
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ def test_payment_callback_verification_failures_notify_once(client, user_factory
|
|||||||
else:
|
else:
|
||||||
gateway.return_value.json.return_value = gateway_result
|
gateway.return_value.json.return_value = gateway_result
|
||||||
response = client.get("/api/payments/callback", {"Authority": "AUTH-FAIL", "Status": "OK"})
|
response = client.get("/api/payments/callback", {"Authority": "AUTH-FAIL", "Status": "OK"})
|
||||||
assert response.status_code == 302 and "status=pending" in response.url
|
assert response.status_code == 302
|
||||||
|
assert f"/events/{event.slug}/registration?registration_id={registration.ticket_id}" in response.url
|
||||||
payment.refresh_from_db()
|
payment.refresh_from_db()
|
||||||
registration.refresh_from_db()
|
registration.refresh_from_db()
|
||||||
assert payment.status == Payment.OrderStatusChoices.PENDING
|
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.events.models import Registration
|
from apps.events.models import Registration
|
||||||
from apps.payments.models import Payment
|
from apps.payments.models import Payment
|
||||||
@@ -39,13 +42,16 @@ def test_reconciliation_retry_confirms_payment_after_transient_timeout(
|
|||||||
mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}}),
|
mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}}),
|
||||||
]
|
]
|
||||||
notify = mocker.patch("apps.payments.api.views._notify_payment_status")
|
notify = mocker.patch("apps.payments.api.views._notify_payment_status")
|
||||||
|
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
|
||||||
|
|
||||||
retry = mocker.patch.object(reconcile_payment, "retry", side_effect=RuntimeError("retry scheduled"))
|
assert reconcile_payment.run(payment.id) == "retry_scheduled"
|
||||||
with pytest.raises(RuntimeError, match="retry scheduled"):
|
payment.refresh_from_db()
|
||||||
reconcile_payment.run(payment.id)
|
assert payment.verification_attempts == 1
|
||||||
|
assert payment.verification_next_attempt_at is not None
|
||||||
retry.assert_called_once()
|
enqueue.assert_called_once_with(args=(payment.id,), countdown=60)
|
||||||
assert retry.call_args.kwargs["countdown"] == 60
|
Payment.objects.filter(pk=payment.pk).update(
|
||||||
|
verification_next_attempt_at=timezone.now() - timedelta(seconds=1)
|
||||||
|
)
|
||||||
gateway.side_effect = [
|
gateway.side_effect = [
|
||||||
mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}})
|
mocker.Mock(**{"json.return_value": {"data": {"code": 100, "ref_id": "REF-RETRY"}}})
|
||||||
]
|
]
|
||||||
@@ -57,6 +63,8 @@ def test_reconciliation_retry_confirms_payment_after_transient_timeout(
|
|||||||
assert gateway.call_count == 2
|
assert gateway.call_count == 2
|
||||||
assert payment.status == Payment.OrderStatusChoices.PAID
|
assert payment.status == Payment.OrderStatusChoices.PAID
|
||||||
assert payment.ref_id == "REF-RETRY"
|
assert payment.ref_id == "REF-RETRY"
|
||||||
|
assert payment.verification_attempts == 2
|
||||||
|
assert payment.verification_manual_review_at is None
|
||||||
assert registration.status == Registration.StatusChoices.CONFIRMED
|
assert registration.status == Registration.StatusChoices.CONFIRMED
|
||||||
notify.assert_called_once()
|
notify.assert_called_once()
|
||||||
|
|
||||||
@@ -67,27 +75,27 @@ def test_reconciliation_exhaustion_leaves_payment_pending_for_review(
|
|||||||
payment, registration = _pending_payment(user_factory, event_factory)
|
payment, registration = _pending_payment(user_factory, event_factory)
|
||||||
settings.ZARINPAL_MERCHANT_ID = "test"
|
settings.ZARINPAL_MERCHANT_ID = "test"
|
||||||
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
|
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
|
||||||
|
settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS = 6
|
||||||
gateway = mocker.patch("apps.payments.services.requests.post", side_effect=RuntimeError("gateway offline"))
|
gateway = mocker.patch("apps.payments.services.requests.post", side_effect=RuntimeError("gateway offline"))
|
||||||
notify = mocker.patch("apps.payments.api.views._notify_payment_status")
|
notify = mocker.patch("apps.payments.api.views._notify_payment_status")
|
||||||
retry = mocker.patch.object(reconcile_payment, "retry", side_effect=RuntimeError("retry scheduled"))
|
mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
|
||||||
|
|
||||||
for retry_number in range(reconcile_payment.max_retries + 1):
|
for attempt_number in range(settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS):
|
||||||
reconcile_payment.push_request(retries=retry_number)
|
Payment.objects.filter(pk=payment.pk).update(
|
||||||
try:
|
verification_next_attempt_at=timezone.now() - timedelta(seconds=1)
|
||||||
if retry_number < reconcile_payment.max_retries:
|
)
|
||||||
with pytest.raises(RuntimeError, match="retry scheduled"):
|
|
||||||
reconcile_payment.run(payment.id)
|
|
||||||
else:
|
|
||||||
result = reconcile_payment.run(payment.id)
|
result = reconcile_payment.run(payment.id)
|
||||||
finally:
|
if attempt_number < settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS - 1:
|
||||||
reconcile_payment.pop_request()
|
assert result == "retry_scheduled"
|
||||||
|
|
||||||
payment.refresh_from_db()
|
payment.refresh_from_db()
|
||||||
registration.refresh_from_db()
|
registration.refresh_from_db()
|
||||||
assert result == "pending_manual_review"
|
assert result == "pending_manual_review"
|
||||||
assert gateway.call_count == reconcile_payment.max_retries + 1
|
assert gateway.call_count == settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS
|
||||||
assert [call.kwargs["countdown"] for call in retry.call_args_list] == [60, 120, 240, 480, 900]
|
assert payment.verification_attempts == settings.PAYMENT_VERIFICATION_MAX_ATTEMPTS
|
||||||
assert payment.status == Payment.OrderStatusChoices.PENDING
|
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||||
|
assert payment.verification_manual_review_at is not None
|
||||||
|
assert payment.verification_next_attempt_at is None
|
||||||
assert registration.status == Registration.StatusChoices.PENDING
|
assert registration.status == Registration.StatusChoices.PENDING
|
||||||
notify.assert_not_called()
|
notify.assert_not_called()
|
||||||
|
|
||||||
@@ -137,3 +145,162 @@ def test_failed_attempt_does_not_cancel_registration_used_by_another_active_paym
|
|||||||
registration.refresh_from_db()
|
registration.refresh_from_db()
|
||||||
assert payment.status == Payment.OrderStatusChoices.FAILED
|
assert payment.status == Payment.OrderStatusChoices.FAILED
|
||||||
assert registration.status == Registration.StatusChoices.PENDING
|
assert registration.status == Registration.StatusChoices.PENDING
|
||||||
|
|
||||||
|
|
||||||
|
def _expired_checkout_payment(user_factory, event_factory):
|
||||||
|
now = timezone.now()
|
||||||
|
user = user_factory()
|
||||||
|
event = event_factory(price=25000, capacity=2)
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=event,
|
||||||
|
user=user,
|
||||||
|
status=Registration.StatusChoices.CANCELLED,
|
||||||
|
checkout_started_at=now - timedelta(minutes=20),
|
||||||
|
checkout_expires_at=now - timedelta(minutes=5),
|
||||||
|
cancellation_reason=Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
|
||||||
|
final_price=25000,
|
||||||
|
)
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
user=user,
|
||||||
|
event=event,
|
||||||
|
registration=registration,
|
||||||
|
base_amount=25000,
|
||||||
|
amount=25000,
|
||||||
|
authority=f"AUTH-LATE-{user.pk}",
|
||||||
|
status=Payment.OrderStatusChoices.PENDING,
|
||||||
|
)
|
||||||
|
return payment, registration
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_success_without_provider_payment_time_keeps_registration_cancelled(
|
||||||
|
user_factory, event_factory, mocker
|
||||||
|
):
|
||||||
|
from apps.payments.api.views import _mark_payment_paid
|
||||||
|
|
||||||
|
payment, registration = _expired_checkout_payment(user_factory, event_factory)
|
||||||
|
mocker.patch("apps.payments.tasks.refund_payment.apply_async")
|
||||||
|
|
||||||
|
payment, newly_verified = _mark_payment_paid(payment.id, {"ref_id": "LATE-REF"})
|
||||||
|
|
||||||
|
registration.refresh_from_db()
|
||||||
|
assert newly_verified is True
|
||||||
|
assert payment.status == Payment.OrderStatusChoices.PAID
|
||||||
|
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
|
||||||
|
assert registration.status == Registration.StatusChoices.CANCELLED
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_success_with_proven_pre_expiry_payment_and_available_seat_is_honored(
|
||||||
|
user_factory, event_factory
|
||||||
|
):
|
||||||
|
from apps.payments.api.views import _mark_payment_paid
|
||||||
|
|
||||||
|
payment, registration = _expired_checkout_payment(user_factory, event_factory)
|
||||||
|
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
|
||||||
|
|
||||||
|
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "ONTIME-REF", "paid_at": paid_at})
|
||||||
|
|
||||||
|
registration.refresh_from_db()
|
||||||
|
assert registration.status == Registration.StatusChoices.CONFIRMED
|
||||||
|
assert payment.refund_status == Payment.RefundStatusChoices.NONE
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_success_with_proven_pre_expiry_time_is_refunded_when_capacity_is_full(
|
||||||
|
user_factory, event_factory
|
||||||
|
):
|
||||||
|
from apps.payments.api.views import _mark_payment_paid
|
||||||
|
|
||||||
|
payment, registration = _expired_checkout_payment(user_factory, event_factory)
|
||||||
|
payment.event.capacity = 1
|
||||||
|
payment.event.save(update_fields=["capacity"])
|
||||||
|
other_user = user_factory()
|
||||||
|
Registration.objects.create(
|
||||||
|
event=payment.event,
|
||||||
|
user=other_user,
|
||||||
|
status=Registration.StatusChoices.CONFIRMED,
|
||||||
|
)
|
||||||
|
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
|
||||||
|
|
||||||
|
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "FULL-REF", "paid_at": paid_at})
|
||||||
|
|
||||||
|
registration.refresh_from_db()
|
||||||
|
assert registration.status == Registration.StatusChoices.CANCELLED
|
||||||
|
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("reason", "cancel_event"),
|
||||||
|
[
|
||||||
|
(Registration.CancellationReasonChoices.USER_CANCELLED, False),
|
||||||
|
(Registration.CancellationReasonChoices.EVENT_CANCELLED, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_late_success_after_user_or_event_cancellation_is_refunded(
|
||||||
|
user_factory, event_factory, mocker, reason, cancel_event
|
||||||
|
):
|
||||||
|
from apps.payments.api.views import _mark_payment_paid
|
||||||
|
|
||||||
|
payment, registration = _expired_checkout_payment(user_factory, event_factory)
|
||||||
|
registration.cancellation_reason = reason
|
||||||
|
registration.save(update_fields=["cancellation_reason"])
|
||||||
|
if cancel_event:
|
||||||
|
payment.event.status = payment.event.StatusChoices.CANCELLED
|
||||||
|
payment.event.save(update_fields=["status"])
|
||||||
|
mocker.patch("apps.payments.tasks.refund_payment.apply_async")
|
||||||
|
paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat()
|
||||||
|
|
||||||
|
payment, _ = _mark_payment_paid(payment.id, {"ref_id": "CANCELLED-REF", "paid_at": paid_at})
|
||||||
|
|
||||||
|
registration.refresh_from_db()
|
||||||
|
assert registration.status == Registration.StatusChoices.CANCELLED
|
||||||
|
assert registration.cancellation_reason == reason
|
||||||
|
assert payment.refund_status == Payment.RefundStatusChoices.PENDING
|
||||||
|
|
||||||
|
|
||||||
|
def test_late_success_refund_task_persists_provider_reversal(mocker, user_factory, event_factory):
|
||||||
|
from apps.payments.tasks import refund_payment
|
||||||
|
|
||||||
|
payment, registration = _expired_checkout_payment(user_factory, event_factory)
|
||||||
|
Payment.objects.filter(pk=payment.pk).update(
|
||||||
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
|
refund_status=Payment.RefundStatusChoices.PENDING,
|
||||||
|
)
|
||||||
|
reverse = mocker.patch(
|
||||||
|
"apps.payments.tasks.request_payment_reversal",
|
||||||
|
return_value={"code": 100, "ref_id": "REVERSAL-1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = refund_payment.run(payment.id)
|
||||||
|
|
||||||
|
payment.refresh_from_db()
|
||||||
|
assert result == "refunded"
|
||||||
|
assert payment.refund_status == Payment.RefundStatusChoices.SUCCEEDED
|
||||||
|
assert payment.refund_reference == "REVERSAL-1"
|
||||||
|
assert payment.refund_attempts == 1
|
||||||
|
reverse.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncertain_reversal_retries_then_keeps_admin_review_state(mocker, user_factory, event_factory, settings):
|
||||||
|
from apps.payments.services import PaymentReversalUncertain
|
||||||
|
from apps.payments.tasks import refund_payment
|
||||||
|
|
||||||
|
payment, _ = _expired_checkout_payment(user_factory, event_factory)
|
||||||
|
Payment.objects.filter(pk=payment.pk).update(
|
||||||
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
|
refund_status=Payment.RefundStatusChoices.PENDING,
|
||||||
|
refund_attempts=2,
|
||||||
|
)
|
||||||
|
settings.PAYMENT_REFUND_MAX_ATTEMPTS = 3
|
||||||
|
mocker.patch(
|
||||||
|
"apps.payments.tasks.request_payment_reversal",
|
||||||
|
side_effect=PaymentReversalUncertain("gateway timed out"),
|
||||||
|
)
|
||||||
|
enqueue = mocker.patch("apps.payments.tasks.refund_payment.apply_async")
|
||||||
|
|
||||||
|
result = refund_payment.run(payment.id)
|
||||||
|
|
||||||
|
payment.refresh_from_db()
|
||||||
|
assert result == "manual_review"
|
||||||
|
assert payment.refund_status == Payment.RefundStatusChoices.MANUAL_REVIEW
|
||||||
|
assert payment.refund_attempts == 3
|
||||||
|
assert payment.refund_next_attempt_at is None
|
||||||
|
enqueue.assert_not_called()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -168,7 +168,8 @@ class TestPaymentsAPIIntegrationTests:
|
|||||||
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH123', 'Status': 'OK'})
|
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH123', 'Status': 'OK'})
|
||||||
payment.refresh_from_db()
|
payment.refresh_from_db()
|
||||||
assert payment.status == Payment.OrderStatusChoices.PAID
|
assert payment.status == Payment.OrderStatusChoices.PAID
|
||||||
assert 'status=success' in response.url
|
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
|
||||||
|
assert 'status=' not in response.url
|
||||||
|
|
||||||
def test_callback_failure_redirects_failed(self, mocker):
|
def test_callback_failure_redirects_failed(self, mocker):
|
||||||
mock_post = mocker.patch('apps.payments.api.views.requests.post')
|
mock_post = mocker.patch('apps.payments.api.views.requests.post')
|
||||||
@@ -179,11 +180,21 @@ class TestPaymentsAPIIntegrationTests:
|
|||||||
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH456', 'Status': 'OK'})
|
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH456', 'Status': 'OK'})
|
||||||
payment.refresh_from_db()
|
payment.refresh_from_db()
|
||||||
assert payment.status == Payment.OrderStatusChoices.PAID
|
assert payment.status == Payment.OrderStatusChoices.PAID
|
||||||
assert 'status=success' in response.url
|
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
|
||||||
|
|
||||||
def test_callback_missing_authority_returns_error(self):
|
def test_callback_missing_authority_redirects_to_safe_unknown_result(self):
|
||||||
response = self.client.get('/api/payments/callback', {'Status': 'OK'})
|
response = self.client.get('/api/payments/callback', {'Status': 'OK'})
|
||||||
assert response.status_code == 400
|
assert response.status_code == 302
|
||||||
|
assert '/events?payment_callback=unmatched' in response.url
|
||||||
|
|
||||||
|
def test_callback_unknown_authority_redirects_to_safe_unknown_result(self):
|
||||||
|
response = self.client.get(
|
||||||
|
'/api/payments/callback',
|
||||||
|
{'Authority': 'AUTH-UNKNOWN', 'Status': 'OK'},
|
||||||
|
)
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert '/events?payment_callback=unmatched' in response.url
|
||||||
|
assert 'AUTH-UNKNOWN' not in response.url
|
||||||
|
|
||||||
def test_callback_not_ok_is_verified_before_being_canceled(self, mocker):
|
def test_callback_not_ok_is_verified_before_being_canceled(self, mocker):
|
||||||
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH789')
|
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH789')
|
||||||
@@ -191,8 +202,8 @@ class TestPaymentsAPIIntegrationTests:
|
|||||||
gateway.return_value.json.return_value = {'data': {'code': -51}}
|
gateway.return_value.json.return_value = {'data': {'code': -51}}
|
||||||
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'})
|
response = self.client.get('/api/payments/callback', {'Authority': 'AUTH789', 'Status': 'NOK'})
|
||||||
payment.refresh_from_db()
|
payment.refresh_from_db()
|
||||||
assert payment.status == Payment.OrderStatusChoices.FAILED
|
assert payment.status == Payment.OrderStatusChoices.CANCELED
|
||||||
assert 'status=failed' in response.url
|
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
|
||||||
|
|
||||||
def test_late_canceled_callback_does_not_overwrite_verified_payment(self, mocker):
|
def test_late_canceled_callback_does_not_overwrite_verified_payment(self, mocker):
|
||||||
registration = Registration.objects.create(
|
registration = Registration.objects.create(
|
||||||
@@ -229,8 +240,9 @@ class TestPaymentsAPIIntegrationTests:
|
|||||||
assert payment.status == Payment.OrderStatusChoices.PAID
|
assert payment.status == Payment.OrderStatusChoices.PAID
|
||||||
assert payment.verified_at is not None
|
assert payment.verified_at is not None
|
||||||
assert registration.status == Registration.StatusChoices.CONFIRMED
|
assert registration.status == Registration.StatusChoices.CONFIRMED
|
||||||
assert 'status=success' in success_response.url
|
expected_return = f'/events/{self.event.slug}/registration?registration_id={registration.ticket_id}'
|
||||||
assert 'status=success' in late_failure_response.url
|
assert expected_return in success_response.url
|
||||||
|
assert expected_return in late_failure_response.url
|
||||||
|
|
||||||
def test_create_payment_gateway_timeout_stays_pending(self, mocker):
|
def test_create_payment_gateway_timeout_stays_pending(self, mocker):
|
||||||
mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down'))
|
mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down'))
|
||||||
@@ -249,8 +261,7 @@ class TestPaymentsAPIIntegrationTests:
|
|||||||
|
|
||||||
payment.refresh_from_db()
|
payment.refresh_from_db()
|
||||||
assert response.status_code == 302
|
assert response.status_code == 302
|
||||||
assert 'status=pending' in response.url
|
assert f'/events/{self.event.slug}/registration?payment_id={payment.id}' in response.url
|
||||||
assert f'payment_id={payment.id}' in response.url
|
|
||||||
assert payment.status == Payment.OrderStatusChoices.PENDING
|
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||||
reconcile.assert_called_once_with(args=(payment.id,), countdown=60)
|
reconcile.assert_called_once_with(args=(payment.id,), countdown=60)
|
||||||
notify.assert_not_called()
|
notify.assert_not_called()
|
||||||
@@ -261,12 +272,45 @@ class TestPaymentsAPIIntegrationTests:
|
|||||||
assert own.status_code == 200
|
assert own.status_code == 200
|
||||||
assert own.json()['payment_id'] == payment.id
|
assert own.json()['payment_id'] == payment.id
|
||||||
assert own.json()['status'] == 'pending'
|
assert own.json()['status'] == 'pending'
|
||||||
|
assert own.json()['verification_state'] == 'awaiting_verification'
|
||||||
|
assert own.json()['verification_attempts'] == 0
|
||||||
|
assert own.json()['last_verification_at'] is None
|
||||||
|
assert own.json()['manual_review_at'] is None
|
||||||
|
|
||||||
other = User.objects.create_user(username='other_payment_owner', email='other-payment@example.com', mobile='09123456780', password=self.password)
|
other = User.objects.create_user(username='other_payment_owner', email='other-payment@example.com', mobile='09123456780', password=self.password)
|
||||||
other_token = create_jwt_token(other)
|
other_token = create_jwt_token(other)
|
||||||
forbidden = self.client.get(f'/api/payments/status/{payment.id}', HTTP_AUTHORIZATION=f'Bearer {other_token}')
|
forbidden = self.client.get(f'/api/payments/status/{payment.id}', HTTP_AUTHORIZATION=f'Bearer {other_token}')
|
||||||
assert forbidden.status_code == 404
|
assert forbidden.status_code == 404
|
||||||
|
|
||||||
|
def test_payment_reference_lookup_requires_authentication_and_is_user_scoped(self):
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
event=self.event,
|
||||||
|
base_amount=self.event.price,
|
||||||
|
amount=self.event.price,
|
||||||
|
status=Payment.OrderStatusChoices.PAID,
|
||||||
|
ref_id="OWNER-ONLY-REF",
|
||||||
|
)
|
||||||
|
|
||||||
|
unauthenticated = self.client.get("/api/payments/by-ref/OWNER-ONLY-REF")
|
||||||
|
owned = self.client.get("/api/payments/by-ref/OWNER-ONLY-REF", **self._headers())
|
||||||
|
other = User.objects.create_user(
|
||||||
|
username="other_ref_owner",
|
||||||
|
email="other-ref-owner@example.com",
|
||||||
|
mobile="09123456781",
|
||||||
|
password=self.password,
|
||||||
|
)
|
||||||
|
other_token = create_jwt_token(other)
|
||||||
|
forbidden = self.client.get(
|
||||||
|
"/api/payments/by-ref/OWNER-ONLY-REF",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {other_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert unauthenticated.status_code == 401
|
||||||
|
assert owned.status_code == 200
|
||||||
|
assert owned.json()["payment_id"] == payment.id
|
||||||
|
assert forbidden.status_code == 404
|
||||||
|
|
||||||
def test_create_payment_when_already_paid(self):
|
def test_create_payment_when_already_paid(self):
|
||||||
Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PAID)
|
Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PAID)
|
||||||
response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Duplicate'}), content_type='application/json', **self._headers())
|
response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Duplicate'}), content_type='application/json', **self._headers())
|
||||||
@@ -341,10 +385,12 @@ def test_my_payments_are_user_scoped_and_omit_gateway_identifiers(client, user_f
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert len(payload) == 1
|
assert len(payload) == 1
|
||||||
assert payload[0]["event"]["title"] == own_event.title
|
assert payload[0]["event"]["title"] == own_event.title
|
||||||
|
assert payload[0]["event"]["id"] == own_event.id
|
||||||
assert payload[0]["amount"] == 100000
|
assert payload[0]["amount"] == 100000
|
||||||
assert payload[0]["discount_amount"] == 20000
|
assert payload[0]["discount_amount"] == 20000
|
||||||
assert payload[0]["status"] == "paid"
|
assert payload[0]["status"] == "paid"
|
||||||
assert set(payload[0]) == {"event", "base_amount", "discount_amount", "amount", "status", "created_at", "paid_at"}
|
assert set(payload[0]) == {"payment_id", "event", "base_amount", "discount_amount", "amount", "status", "refund_status", "created_at", "paid_at"}
|
||||||
|
assert payload[0]["payment_id"] == Payment.objects.get(user=current_user, event=own_event).id
|
||||||
assert "internal-authority-owner" not in response.content.decode()
|
assert "internal-authority-owner" not in response.content.decode()
|
||||||
assert "internal-reference-owner" not in response.content.decode()
|
assert "internal-reference-owner" not in response.content.decode()
|
||||||
assert "internal-card-hash" not in response.content.decode()
|
assert "internal-card-hash" not in response.content.decode()
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ app.conf.beat_schedule = {
|
|||||||
'schedule': crontab(hour=2, minute=0),
|
'schedule': crontab(hour=2, minute=0),
|
||||||
'description': 'Runs daily at 02:00 UTC to cleanup notification retention.',
|
'description': 'Runs daily at 02:00 UTC to cleanup notification retention.',
|
||||||
},
|
},
|
||||||
|
'sweep-stale-payment-verifications': {
|
||||||
|
'task': 'apps.payments.sweep_stale_payment_verifications',
|
||||||
|
'schedule': crontab(minute='*/5'),
|
||||||
|
'description': 'Recovers old unresolved payment attempts with bounded gateway verification.',
|
||||||
|
},
|
||||||
|
'expire-stale-registration-checkouts': {
|
||||||
|
'task': 'apps.events.expire_stale_registration_checkouts',
|
||||||
|
'schedule': crontab(minute='*'),
|
||||||
|
'description': 'Releases seats from paid checkouts after their 15-minute window.',
|
||||||
|
},
|
||||||
|
'sweep-pending-refunds': {
|
||||||
|
'task': 'apps.payments.sweep_pending_refunds',
|
||||||
|
'schedule': crontab(minute='*/5'),
|
||||||
|
'description': 'Recovers pending transaction reversals and exposes exhausted attempts for admin review.',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
CELERY_TASK_SOFT_TIME_LIMIT = 20
|
CELERY_TASK_SOFT_TIME_LIMIT = 20
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ ZARINPAL_USE_SANDBOX = config('ZARINPAL_USE_SANDBOX', default=False, cast=bool)
|
|||||||
ZARINPAL_API_BASE = "https://sandbox.zarinpal.com" if ZARINPAL_USE_SANDBOX else "https://payment.zarinpal.com"
|
ZARINPAL_API_BASE = "https://sandbox.zarinpal.com" if ZARINPAL_USE_SANDBOX else "https://payment.zarinpal.com"
|
||||||
ZARINPAL_REQUEST_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/request.json"
|
ZARINPAL_REQUEST_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/request.json"
|
||||||
ZARINPAL_VERIFY_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/verify.json"
|
ZARINPAL_VERIFY_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/verify.json"
|
||||||
|
ZARINPAL_REVERSE_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/reverse.json"
|
||||||
ZARINPAL_STARTPAY = f"{ZARINPAL_API_BASE}/pg/StartPay/"
|
ZARINPAL_STARTPAY = f"{ZARINPAL_API_BASE}/pg/StartPay/"
|
||||||
ZARINPAL_CALLBACK_URL = config('ZARINPAL_CALLBACK_URL', default='http://localhost:8000/api/payments/callback')
|
ZARINPAL_CALLBACK_URL = config('ZARINPAL_CALLBACK_URL', default='http://localhost:8000/api/payments/callback')
|
||||||
|
|||||||
@@ -229,6 +229,9 @@ os.makedirs(BASE_DIR / 'logs', exist_ok=True)
|
|||||||
BACKEND_ROOT = config('DJANGO_HOST', default='http://localhost:8000/')
|
BACKEND_ROOT = config('DJANGO_HOST', default='http://localhost:8000/')
|
||||||
FRONTEND_ROOT = config('FRONTEND_ROOT', default='http://localhost:3000/')
|
FRONTEND_ROOT = config('FRONTEND_ROOT', default='http://localhost:3000/')
|
||||||
FRONTEND_CALLBACK_URL = config('FRONTEND_CALLBACK_URL', default='http://localhost:3000/payments/result')
|
FRONTEND_CALLBACK_URL = config('FRONTEND_CALLBACK_URL', default='http://localhost:3000/payments/result')
|
||||||
|
PAYMENT_VERIFICATION_GRACE_SECONDS = config('PAYMENT_VERIFICATION_GRACE_SECONDS', cast=int, default=1800)
|
||||||
|
PAYMENT_VERIFICATION_MAX_ATTEMPTS = config('PAYMENT_VERIFICATION_MAX_ATTEMPTS', cast=int, default=6)
|
||||||
|
PAYMENT_VERIFICATION_DISPATCH_LEASE_SECONDS = config('PAYMENT_VERIFICATION_DISPATCH_LEASE_SECONDS', cast=int, default=900)
|
||||||
GOOGLE_OAUTH_CLIENT_ID = config('GOOGLE_OAUTH_CLIENT_ID', default='')
|
GOOGLE_OAUTH_CLIENT_ID = config('GOOGLE_OAUTH_CLIENT_ID', default='')
|
||||||
GOOGLE_OAUTH_CLIENT_SECRET = config('GOOGLE_OAUTH_CLIENT_SECRET', default='')
|
GOOGLE_OAUTH_CLIENT_SECRET = config('GOOGLE_OAUTH_CLIENT_SECRET', default='')
|
||||||
GOOGLE_OAUTH_REDIRECT_URI = config('GOOGLE_OAUTH_REDIRECT_URI', default='')
|
GOOGLE_OAUTH_REDIRECT_URI = config('GOOGLE_OAUTH_REDIRECT_URI', default='')
|
||||||
|
|||||||
Reference in New Issue
Block a user