Files
guilan-ace-backend/apps/payments/tests/integration/test_payment_reconciliation.py
Amirhossein Khalili 6c5fadef2f feat: derive event lifecycle status from dates and flags
Replace persisted event statuses with publication and cancellation flags, and compute public lifecycle labels from event timing and registration capacity. Keep filters, registration checks, and profile responses aligned with the derived status.
2026-10-03 12:46:16 +03:30

307 lines
12 KiB
Python

from datetime import timedelta
import pytest
from django.utils import timezone
from apps.events.models import Registration
from apps.payments.models import Payment
from apps.payments.tasks import reconcile_payment
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
def _pending_payment(user_factory, event_factory):
user = user_factory()
event = event_factory(price=25000)
registration = Registration.objects.create(
event=event,
user=user,
status=Registration.StatusChoices.PENDING,
)
payment = Payment.objects.create(
user=user,
event=event,
registration=registration,
base_amount=25000,
amount=25000,
authority=f"AUTH-{user.pk}",
status=Payment.OrderStatusChoices.PENDING,
)
return payment, registration
def test_reconciliation_retry_confirms_payment_after_transient_timeout(
user_factory, event_factory, mocker, settings
):
payment, registration = _pending_payment(user_factory, event_factory)
settings.ZARINPAL_MERCHANT_ID = "test"
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
gateway = mocker.patch("apps.payments.services.requests.post")
gateway.side_effect = [
RuntimeError("temporary 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")
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"}}})
]
result = reconcile_payment.run(payment.id)
payment.refresh_from_db()
registration.refresh_from_db()
assert result == "paid"
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()
def test_reconciliation_exhaustion_leaves_payment_pending_for_review(
user_factory, event_factory, mocker, settings
):
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")
mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
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 == 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()
def test_reconciliation_marks_only_explicit_gateway_failure_as_failed(
user_factory, event_factory, mocker, settings
):
payment, registration = _pending_payment(user_factory, event_factory)
settings.ZARINPAL_MERCHANT_ID = "test"
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
gateway = mocker.patch("apps.payments.services.requests.post")
gateway.return_value.json.return_value = {"data": {"code": -51}}
notify = mocker.patch("apps.payments.api.views._notify_payment_status")
result = reconcile_payment.run(payment.id)
payment.refresh_from_db()
registration.refresh_from_db()
assert result == "failed"
assert gateway.call_count == 1
assert payment.status == Payment.OrderStatusChoices.FAILED
assert registration.status == Registration.StatusChoices.CANCELLED
notify.assert_called_once()
def test_failed_attempt_does_not_cancel_registration_used_by_another_active_payment(
user_factory, event_factory, mocker, settings
):
payment, registration = _pending_payment(user_factory, event_factory)
Payment.objects.create(
user=payment.user,
event=payment.event,
registration=registration,
base_amount=payment.base_amount,
amount=payment.amount,
authority=f"AUTH-OTHER-{payment.user_id}",
status=Payment.OrderStatusChoices.PENDING,
)
settings.ZARINPAL_MERCHANT_ID = "test"
settings.ZARINPAL_VERIFY_URL = "https://gateway.example/verify"
gateway = mocker.patch("apps.payments.services.requests.post")
gateway.return_value.json.return_value = {"data": {"code": -51}}
reconcile_payment.run(payment.id)
payment.refresh_from_db()
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.is_cancelled = True
payment.event.save(update_fields=["is_cancelled"])
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()