feat(events): harden registration checkout lifecycle

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

View File

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

View File

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

View File

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

View File

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