fix(payments): make checkout attempts idempotent and reconcilable
This commit is contained in:
@@ -42,9 +42,14 @@ def test_discount_code_admin_crud_and_restore(client, user_factory, auth_headers
|
||||
assert DiscountCode.objects.filter(pk=code.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gateway_result,expected", [({"errors": {"code": -9}}, 502), (None, 502)])
|
||||
def test_payment_create_gateway_failures_leave_no_active_payment(client, user_factory, auth_headers, event_factory,
|
||||
mocker, settings, gateway_result, expected):
|
||||
@pytest.mark.parametrize(
|
||||
"gateway_result,expected_status,expected_payment_status",
|
||||
[({"errors": {"code": -9}}, "failed", Payment.OrderStatusChoices.FAILED), (None, "pending", Payment.OrderStatusChoices.PENDING)],
|
||||
)
|
||||
def test_payment_create_gateway_failure_is_persisted_conservatively(
|
||||
client, user_factory, auth_headers, event_factory, mocker, settings,
|
||||
gateway_result, expected_status, expected_payment_status,
|
||||
):
|
||||
settings.ZARINPAL_MERCHANT_ID = "test"
|
||||
settings.ZARINPAL_REQUEST_URL = "https://gateway.example/request"
|
||||
user = user_factory()
|
||||
@@ -56,8 +61,9 @@ def test_payment_create_gateway_failures_leave_no_active_payment(client, user_fa
|
||||
gateway.return_value.json.return_value = gateway_result
|
||||
response = _send(client, "post", "/api/payments/create", auth_headers(user),
|
||||
{"event_id": event.pk, "description": "Ticket"})
|
||||
assert response.status_code == expected
|
||||
assert not Payment.objects.filter(user=user, event=event).exists()
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == expected_status
|
||||
assert Payment.objects.filter(user=user, event=event, status=expected_payment_status).count() == 1
|
||||
assert Registration.objects.filter(user=user, event=event).exists()
|
||||
|
||||
|
||||
@@ -74,14 +80,16 @@ def test_payment_callback_verification_failures_notify_once(client, user_factory
|
||||
base_amount=25000, authority="AUTH-FAIL", status=Payment.OrderStatusChoices.PENDING)
|
||||
gateway = mocker.patch("apps.payments.api.views.requests.post")
|
||||
notify = mocker.patch("apps.payments.api.views.notify_user")
|
||||
retry = mocker.patch("apps.payments.api.views.reconcile_payment.apply_async")
|
||||
if gateway_result is None:
|
||||
gateway.side_effect = RuntimeError("offline")
|
||||
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=failed" in response.url
|
||||
assert response.status_code == 302 and "status=pending" in response.url
|
||||
payment.refresh_from_db()
|
||||
registration.refresh_from_db()
|
||||
assert payment.status == Payment.OrderStatusChoices.FAILED
|
||||
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||
assert registration.status == Registration.StatusChoices.PENDING
|
||||
notify.assert_called_once()
|
||||
notify.assert_not_called()
|
||||
retry.assert_called_once_with(args=(payment.id,), countdown=60)
|
||||
|
||||
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
|
||||
@@ -57,6 +57,108 @@ class TestPaymentsAPIIntegrationTests:
|
||||
payment = Payment.objects.get(user=self.user, event=self.event)
|
||||
assert payment.discount_code == code
|
||||
|
||||
def test_create_payment_reuses_an_active_attempt(self, mocker):
|
||||
gateway = mocker.patch('apps.payments.api.views.requests.post')
|
||||
gateway.return_value.json.return_value = {'data': {'code': 100, 'authority': 'AUTH-REUSED'}}
|
||||
first = self.client.post(
|
||||
'/api/payments/create',
|
||||
data=json.dumps({
|
||||
'event_id': self.event.id,
|
||||
'description': 'First tap',
|
||||
'idempotency_key': 'b0b849d1-8d8f-4f0d-94b6-7b60876f1501',
|
||||
}),
|
||||
content_type='application/json',
|
||||
**self._headers(),
|
||||
)
|
||||
second = self.client.post(
|
||||
'/api/payments/create',
|
||||
data=json.dumps({
|
||||
'event_id': self.event.id,
|
||||
'description': 'Repeated tap',
|
||||
'idempotency_key': 'b5f06335-1301-48c2-b504-f009c8e087d3',
|
||||
}),
|
||||
content_type='application/json',
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert first.json()['payment_id'] == second.json()['payment_id']
|
||||
assert first.json()['start_pay_url'] == second.json()['start_pay_url']
|
||||
assert Payment.objects.filter(user=self.user, event=self.event).count() == 1
|
||||
gateway.assert_called_once()
|
||||
|
||||
def test_create_payment_replays_same_key_after_gateway_timeout(self, mocker):
|
||||
gateway = mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('timeout'))
|
||||
payload = {
|
||||
'event_id': self.event.id,
|
||||
'description': 'Checkout retry',
|
||||
'idempotency_key': 'ed7e1426-320d-4faf-b808-7ad63e73477c',
|
||||
}
|
||||
|
||||
first = self.client.post('/api/payments/create', data=json.dumps(payload), content_type='application/json', **self._headers())
|
||||
second = self.client.post('/api/payments/create', data=json.dumps(payload), content_type='application/json', **self._headers())
|
||||
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert first.json()['status'] == second.json()['status'] == 'pending'
|
||||
assert first.json()['payment_id'] == second.json()['payment_id']
|
||||
assert Payment.objects.filter(user=self.user, event=self.event).count() == 1
|
||||
gateway.assert_called_once()
|
||||
|
||||
def test_gateway_server_error_is_not_treated_as_a_definitive_failure(self, mocker):
|
||||
gateway_response = mocker.Mock(status_code=503)
|
||||
gateway_response.json.return_value = {"errors": {"code": -9}}
|
||||
mocker.patch("apps.payments.api.views.requests.post", return_value=gateway_response)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/payments/create",
|
||||
data=json.dumps({"event_id": self.event.id, "description": "Gateway server error"}),
|
||||
content_type="application/json",
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "pending"
|
||||
assert Payment.objects.filter(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
authority__isnull=True,
|
||||
).exists()
|
||||
|
||||
def test_definitive_gateway_rejection_allows_a_new_attempt(self, mocker):
|
||||
gateway = mocker.patch('apps.payments.api.views.requests.post')
|
||||
gateway.side_effect = [
|
||||
mocker.Mock(**{'json.return_value': {'errors': {'code': -9}}}),
|
||||
mocker.Mock(**{'json.return_value': {'data': {'code': 100, 'authority': 'AUTH-RETRY'}}}),
|
||||
]
|
||||
rejected = self.client.post(
|
||||
'/api/payments/create',
|
||||
data=json.dumps({
|
||||
'event_id': self.event.id,
|
||||
'description': 'Rejected attempt',
|
||||
'idempotency_key': '060826cf-b8c0-4828-b0f2-d37c0f2678dd',
|
||||
}),
|
||||
content_type='application/json',
|
||||
**self._headers(),
|
||||
)
|
||||
retried = self.client.post(
|
||||
'/api/payments/create',
|
||||
data=json.dumps({
|
||||
'event_id': self.event.id,
|
||||
'description': 'New attempt',
|
||||
'idempotency_key': '865f3c43-2888-4fd0-8706-1c2ae60cb5f6',
|
||||
}),
|
||||
content_type='application/json',
|
||||
**self._headers(),
|
||||
)
|
||||
|
||||
assert rejected.status_code == retried.status_code == 200
|
||||
assert rejected.json()['status'] == 'failed'
|
||||
assert retried.json()['status'] == 'pending'
|
||||
assert Payment.objects.filter(user=self.user, event=self.event).count() == 2
|
||||
assert Payment.objects.filter(user=self.user, event=self.event, status=Payment.OrderStatusChoices.FAILED).count() == 1
|
||||
assert gateway.call_count == 2
|
||||
|
||||
def test_callback_success_marks_paid(self, mocker):
|
||||
mock_post = mocker.patch('apps.payments.api.views.requests.post')
|
||||
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH123')
|
||||
@@ -83,18 +185,87 @@ class TestPaymentsAPIIntegrationTests:
|
||||
response = self.client.get('/api/payments/callback', {'Status': 'OK'})
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_callback_not_ok_cancels(self):
|
||||
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')
|
||||
gateway = mocker.patch('apps.payments.api.views.requests.post')
|
||||
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.CANCELED
|
||||
assert payment.status == Payment.OrderStatusChoices.FAILED
|
||||
assert 'status=failed' in response.url
|
||||
|
||||
def test_create_payment_gateway_failure(self, mocker):
|
||||
mock_post = mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down'))
|
||||
def test_late_canceled_callback_does_not_overwrite_verified_payment(self, mocker):
|
||||
registration = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=self.event.price,
|
||||
)
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
registration=registration,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
authority='AUTH-LATE-NOK',
|
||||
)
|
||||
mock_post = mocker.patch('apps.payments.api.views.requests.post')
|
||||
mock_response = mocker.Mock()
|
||||
mock_response.json.return_value = {'data': {'code': 100, 'ref_id': 'REF-LATE-NOK'}}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
success_response = self.client.get(
|
||||
'/api/payments/callback',
|
||||
{'Authority': payment.authority, 'Status': 'OK'},
|
||||
)
|
||||
late_failure_response = self.client.get(
|
||||
'/api/payments/callback',
|
||||
{'Authority': payment.authority, 'Status': 'NOK'},
|
||||
)
|
||||
|
||||
payment.refresh_from_db()
|
||||
registration.refresh_from_db()
|
||||
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
|
||||
|
||||
def test_create_payment_gateway_timeout_stays_pending(self, mocker):
|
||||
mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('down'))
|
||||
response = self.client.post('/api/payments/create', data=json.dumps({'event_id': self.event.id, 'description': 'Gateway fail'}), content_type='application/json', **self._headers())
|
||||
assert response.status_code == 502
|
||||
assert not Payment.objects.filter(user=self.user).exists()
|
||||
assert response.status_code == 200
|
||||
assert response.json()['status'] == 'pending'
|
||||
assert Payment.objects.filter(user=self.user, event=self.event, status=Payment.OrderStatusChoices.PENDING).exists()
|
||||
|
||||
def test_verification_timeout_stays_pending_and_queues_reconciliation(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='AUTH-TIMEOUT')
|
||||
mocker.patch('apps.payments.api.views.requests.post', side_effect=RuntimeError('gateway timeout'))
|
||||
reconcile = mocker.patch('apps.payments.api.views.reconcile_payment.apply_async')
|
||||
notify = mocker.patch('apps.payments.api.views.notify_user')
|
||||
|
||||
response = self.client.get('/api/payments/callback', {'Authority': payment.authority, 'Status': 'OK'})
|
||||
|
||||
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 payment.status == Payment.OrderStatusChoices.PENDING
|
||||
reconcile.assert_called_once_with(args=(payment.id,), countdown=60)
|
||||
notify.assert_not_called()
|
||||
|
||||
def test_payment_status_is_user_scoped_and_reflects_saved_state(self):
|
||||
payment = Payment.objects.create(user=self.user, event=self.event, base_amount=self.event.price, amount=self.event.price, status=Payment.OrderStatusChoices.PENDING, authority='AUTH-STATUS')
|
||||
own = self.client.get(f'/api/payments/status/{payment.id}', **self._headers())
|
||||
assert own.status_code == 200
|
||||
assert own.json()['payment_id'] == payment.id
|
||||
assert own.json()['status'] == 'pending'
|
||||
|
||||
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_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)
|
||||
|
||||
Reference in New Issue
Block a user