fix(payments): make checkout attempts idempotent and reconcilable
This commit is contained in:
139
apps/payments/tests/integration/test_payment_reconciliation.py
Normal file
139
apps/payments/tests/integration/test_payment_reconciliation.py
Normal file
@@ -0,0 +1,139 @@
|
||||
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
|
||||
Reference in New Issue
Block a user