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()