fix(payments): make checkout attempts idempotent and reconcilable

This commit is contained in:
2026-09-30 21:14:16 +03:30
parent 77567178ad
commit 1daa755543
9 changed files with 862 additions and 173 deletions

View File

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