import pytest 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") 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 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 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" 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")) 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() 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 payment.status == Payment.OrderStatusChoices.PENDING 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