From e0361736705c109bd31e711fb182c3b9ec2822f9 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 8 Nov 2025 11:00:28 +0300 Subject: [PATCH] Verify YooKassa webhooks against API --- app/services/payment/yookassa.py | 69 ++++++++++++------- .../services/test_payment_service_webhooks.py | 60 ++++++++++++++++ 2 files changed, 105 insertions(+), 24 deletions(-) diff --git a/app/services/payment/yookassa.py b/app/services/payment/yookassa.py index 45bacc04..f51d6179 100644 --- a/app/services/payment/yookassa.py +++ b/app/services/payment/yookassa.py @@ -786,31 +786,52 @@ class YooKassaPaymentMixin: logger.warning("Webhook без payment id: %s", event) return False - remote_data: Optional[Dict[str, Any]] = None - if getattr(self, "yookassa_service", None): - try: - remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] - yookassa_payment_id - ) - except Exception as error: # pragma: no cover - диагностический лог - logger.warning( - "Не удалось запросить актуальный статус платежа YooKassa %s: %s", - yookassa_payment_id, - error, - exc_info=True, - ) + if not getattr(self, "yookassa_service", None): + logger.error( + "Не настроена служба YooKassa для верификации платежей, webhook %s отклонён", + yookassa_payment_id, + ) + return False - if remote_data: - previous_status = event_object.get("status") - event_object = self._merge_remote_yookassa_payload(event_object, remote_data) - if previous_status and event_object.get("status") != previous_status: - logger.info( - "Статус платежа YooKassa %s скорректирован по данным API: %s → %s", - yookassa_payment_id, - previous_status, - event_object.get("status"), - ) - event["object"] = event_object + try: + remote_data = await self.yookassa_service.get_payment_info( # type: ignore[union-attr] + yookassa_payment_id + ) + except Exception as error: # pragma: no cover - диагностический лог + logger.warning( + "Не удалось запросить актуальный статус платежа YooKassa %s: %s", + yookassa_payment_id, + error, + exc_info=True, + ) + return False + + if not remote_data: + logger.error( + "YooKassa API вернул пустой ответ при проверке платежа %s", + yookassa_payment_id, + ) + return False + + remote_payment_id = remote_data.get("id") + if remote_payment_id and remote_payment_id != yookassa_payment_id: + logger.error( + "Несовпадение идентификаторов платежа при проверке YooKassa: webhook=%s, api=%s", + yookassa_payment_id, + remote_payment_id, + ) + return False + + previous_status = event_object.get("status") + event_object = self._merge_remote_yookassa_payload(event_object, remote_data) + if previous_status and event_object.get("status") != previous_status: + logger.info( + "Статус платежа YooKassa %s скорректирован по данным API: %s → %s", + yookassa_payment_id, + previous_status, + event_object.get("status"), + ) + event["object"] = event_object payment_module = import_module("app.services.payment_service") diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py index 7285835b..6fbd83e4 100644 --- a/tests/services/test_payment_service_webhooks.py +++ b/tests/services/test_payment_service_webhooks.py @@ -564,6 +564,17 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot))) service.build_topup_success_keyboard = AsyncMock(return_value=None) + remote_payload = { + "id": "yk_123", + "status": "succeeded", + "paid": True, + "payment_method": {"type": "bank_card"}, + } + + service.yookassa_service = SimpleNamespace( + get_payment_info=AsyncMock(return_value=remote_payload) + ) + payload = { "object": { "id": "yk_123", @@ -576,6 +587,7 @@ async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) result = await service.process_yookassa_webhook(fake_session, payload) assert result is True + service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_123") assert transactions and transactions[0]["amount_kopeks"] == 10000 assert payment.transaction_id == 999 assert payment.is_paid is True @@ -749,6 +761,24 @@ async def test_process_yookassa_webhook_handles_cancellation(monkeypatch: pytest get_info_mock.assert_awaited_once_with("yk_cancel") +@pytest.mark.anyio("asyncio") +async def test_process_yookassa_webhook_api_failure(monkeypatch: pytest.MonkeyPatch) -> None: + service = _make_service(DummyBot()) + db = FakeSession() + + service.yookassa_service = SimpleNamespace( + get_payment_info=AsyncMock(return_value=None) + ) + + result = await service.process_yookassa_webhook( + db, + {"object": {"id": "yk_fail", "status": "succeeded", "paid": True}}, + ) + + assert result is False + service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_fail") + + @pytest.mark.anyio("asyncio") async def test_process_yookassa_webhook_restores_missing_payment( monkeypatch: pytest.MonkeyPatch, @@ -858,6 +888,24 @@ async def test_process_yookassa_webhook_restores_missing_payment( monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot))) service.build_topup_success_keyboard = AsyncMock(return_value=None) + remote_payload = { + "id": "yk_456", + "status": "succeeded", + "paid": True, + "amount": {"value": "150.00", "currency": "RUB"}, + "metadata": {"user_id": "21", "payment_purpose": "balance_topup"}, + "description": "Пополнение", + "payment_method": {"type": "bank_card"}, + "created_at": "2024-01-02T12:00:00Z", + "captured_at": "2024-01-02T12:05:00Z", + "confirmation": {"confirmation_url": "https://pay.example"}, + "refundable": False, + } + + service.yookassa_service = SimpleNamespace( + get_payment_info=AsyncMock(return_value=remote_payload) + ) + payload = { "object": { "id": "yk_456", @@ -884,6 +932,7 @@ async def test_process_yookassa_webhook_restores_missing_payment( assert user.balance_kopeks == 15000 assert bot.sent_messages assert admin_calls + service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_456") @pytest.mark.anyio("asyncio") @@ -901,11 +950,22 @@ async def test_process_yookassa_webhook_missing_metadata(monkeypatch: pytest.Mon monkeypatch.setattr(payment_service_module, "create_yookassa_payment", create_mock) monkeypatch.setattr(payment_service_module, "update_yookassa_payment_status", update_mock) + service.yookassa_service = SimpleNamespace( + get_payment_info=AsyncMock( + return_value={ + "id": "yk_missing", + "status": "succeeded", + "paid": True, + } + ) + ) + payload = {"object": {"id": "yk_missing", "status": "succeeded", "paid": True}} result = await service.process_yookassa_webhook(db, payload) assert result is False + service.yookassa_service.get_payment_info.assert_awaited_once_with("yk_missing") create_mock.assert_not_awaited() update_mock.assert_not_awaited()