From aeee641c6c80c97dfe929b423d87d95030bfc442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=B0=D0=B8=D1=81=20=D0=AE=D1=81=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=B2?= Date: Sat, 20 Jun 2026 00:57:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B3=D0=BE=D0=BD=D0=BA=D0=B0=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=BE=D0=B2=20=D0=BE=D1=82=20tab?= =?UTF-8?q?nk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../delivery_providers/cdek/client.py | 32 ++++++++++++ app/domain/payment_notifications.py | 37 ++++++++++++++ app/repositories/order/repository.py | 9 ++-- app/services/aggregator.py | 30 ++++++----- .../cdek/test_order_info_client.py | 50 +++++++++++++++++++ tests/domain/test_payment_notifications.py | 25 ++++++++++ tests/services/test_provider_routing.py | 4 +- tests/services/test_tbank_notifications.py | 2 + 8 files changed, 173 insertions(+), 16 deletions(-) diff --git a/app/adapters/delivery_providers/cdek/client.py b/app/adapters/delivery_providers/cdek/client.py index 9a8271b..9d38e6a 100644 --- a/app/adapters/delivery_providers/cdek/client.py +++ b/app/adapters/delivery_providers/cdek/client.py @@ -233,6 +233,13 @@ class CDEKClient: url, failure_message="CDEK get order failed", ) + requests_with_errors = _extract_requests_with_errors(raw_payload) + if requests_with_errors: + log.warning( + "cdek_order_request_errors", + cdek_order_uuid=cdek_order_uuid, + requests=requests_with_errors, + ) try: return map_cdek_order_info_response(raw_payload) except CDEKOrderMappingError as exc: @@ -513,3 +520,28 @@ def _response_text_or_none(response: httpx.Response) -> str | None: return response.text except Exception: return None + + +def _extract_requests_with_errors( + payload: dict[str, Any], +) -> list[dict[str, object]]: + requests = payload.get("requests") + if not isinstance(requests, list): + return [] + + requests_with_errors: list[dict[str, object]] = [] + for request in requests: + if not isinstance(request, dict): + continue + errors = request.get("errors") + if not isinstance(errors, list) or not errors: + continue + requests_with_errors.append( + { + "request_uuid": request.get("request_uuid"), + "type": request.get("type"), + "state": request.get("state"), + "errors": errors, + } + ) + return requests_with_errors diff --git a/app/domain/payment_notifications.py b/app/domain/payment_notifications.py index 4cb161b..c78b9bd 100644 --- a/app/domain/payment_notifications.py +++ b/app/domain/payment_notifications.py @@ -17,3 +17,40 @@ def resolve_tbank_payment_notification_action( if status == "CONFIRMED" and success is True and error_code == "0": return TBankPaymentNotificationAction.REGISTER_CDEK_ORDER return TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY + + +# Монотонный приоритет статусов платежа TBank по жизненному циклу. +# Используется, чтобы внеочередное уведомление не понижало уже записанный статус +# (например, AUTHORIZED, пришедший после CONFIRMED, не должен затирать CONFIRMED). +# Неизвестные статусы получают ранг 0 и не перезаписывают известный статус. +_TBANK_PAYMENT_STATUS_RANK: dict[str, int] = { + "NEW": 10, + "FORM_SHOWED": 20, + "AUTHORIZING": 30, + "3DS_CHECKING": 30, + "3DS_CHECKED": 30, + "REJECTED": 40, + "AUTH_FAIL": 40, + "DEADLINE_EXPIRED": 40, + "AUTHORIZED": 40, + "CONFIRMING": 50, + "CONFIRMED": 60, + "REVERSING": 70, + "PARTIAL_REVERSED": 70, + "REVERSED": 70, + "REFUNDING": 70, + "PARTIAL_REFUNDED": 70, + "REFUNDED": 70, + "CANCELED": 70, +} + + +def tbank_payment_status_rank(status: str) -> int: + return _TBANK_PAYMENT_STATUS_RANK.get(status, 0) + + +def should_apply_tbank_payment_status(current: str | None, new: str) -> bool: + """Применять новый статус, только если он не понижает текущий по жизненному циклу.""" + if current is None: + return True + return tbank_payment_status_rank(new) >= tbank_payment_status_rank(current) diff --git a/app/repositories/order/repository.py b/app/repositories/order/repository.py index d3a6833..f555286 100644 --- a/app/repositories/order/repository.py +++ b/app/repositories/order/repository.py @@ -50,10 +50,13 @@ class OrderRepository: self, session: AsyncSession, order_uuid: str, + *, + for_update: bool = False, ) -> Order | None: - result = await session.execute( - select(Order).where(Order.order_uuid == order_uuid) - ) + statement = select(Order).where(Order.order_uuid == order_uuid) + if for_update: + statement = statement.with_for_update() + result = await session.execute(statement) return result.scalar_one_or_none() async def mark_payment_status( diff --git a/app/services/aggregator.py b/app/services/aggregator.py index 3892b85..a374ee5 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -30,6 +30,7 @@ from app.adapters.tbank.base import ( from app.domain.payment_notifications import ( TBankPaymentNotificationAction, resolve_tbank_payment_notification_action, + should_apply_tbank_payment_status, ) from app.domain.price import ( DEFAULT_PROVIDER_PRICE_MULTIPLIER, @@ -537,9 +538,12 @@ class AggregatorService: try: async with self._order_repository.session() as session: + # FOR UPDATE: сериализуем конкурентные уведомления по одному заказу, + # чтобы внеочередной статус не затирал уже записанный (lost update). order = await self._order_repository.get_order_by_order_uuid( session, notification.OrderId, + for_update=True, ) if order is None: logger.warning( @@ -549,19 +553,21 @@ class AggregatorService: raise TBankPaymentNotificationProcessingError( "Order was not found for TBank payment notification." ) - updated_order = await self._order_repository.mark_payment_status( - session, - notification.OrderId, - notification.Status, - notification.PaymentId, - ) - if updated_order is None: - logger.warning( - "tbank_payment_notification_order_not_found", - order_uuid=notification.OrderId, + if should_apply_tbank_payment_status( + order.payment_status, notification.Status + ): + await self._order_repository.mark_payment_status( + session, + notification.OrderId, + notification.Status, + notification.PaymentId, ) - raise TBankPaymentNotificationProcessingError( - "Order was not found for TBank payment notification." + else: + logger.info( + "tbank_payment_status_downgrade_skipped", + order_uuid=notification.OrderId, + current_status=order.payment_status, + incoming_status=notification.Status, ) return order except TBankPaymentNotificationProcessingError: diff --git a/tests/adapters/delivery_providers/cdek/test_order_info_client.py b/tests/adapters/delivery_providers/cdek/test_order_info_client.py index 8b08d26..1055ff2 100644 --- a/tests/adapters/delivery_providers/cdek/test_order_info_client.py +++ b/tests/adapters/delivery_providers/cdek/test_order_info_client.py @@ -1,5 +1,6 @@ import asyncio from typing import Any +from unittest.mock import patch import httpx import pytest @@ -87,6 +88,55 @@ def test_get_order_parses_status_and_waybill_uuid() -> None: assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"} +def test_get_order_logs_request_errors() -> None: + response = httpx.Response( + 200, + json={ + "entity": {"uuid": "cdek-order-uuid", "statuses": []}, + "requests": [ + { + "request_uuid": "request-uuid", + "type": "CREATE", + "state": "INVALID", + "errors": [ + { + "code": "invalid_order", + "message": "Order data is invalid", + } + ], + } + ], + }, + request=httpx.Request( + "GET", "https://api.cdek.test/v2/orders/cdek-order-uuid" + ), + ) + client = _make_client(SequenceHTTPClient([response])) + + with patch( + "app.adapters.delivery_providers.cdek.client.log.warning" + ) as warning_mock: + asyncio.run(client.get_order("cdek-order-uuid")) + + warning_mock.assert_called_once_with( + "cdek_order_request_errors", + cdek_order_uuid="cdek-order-uuid", + requests=[ + { + "request_uuid": "request-uuid", + "type": "CREATE", + "state": "INVALID", + "errors": [ + { + "code": "invalid_order", + "message": "Order data is invalid", + } + ], + } + ], + ) + + def test_get_order_retries_on_5xx_and_succeeds() -> None: flaky = httpx.Response( 503, diff --git a/tests/domain/test_payment_notifications.py b/tests/domain/test_payment_notifications.py index c655a3a..c55e0b4 100644 --- a/tests/domain/test_payment_notifications.py +++ b/tests/domain/test_payment_notifications.py @@ -1,6 +1,7 @@ from app.domain.payment_notifications import ( TBankPaymentNotificationAction, resolve_tbank_payment_notification_action, + should_apply_tbank_payment_status, ) @@ -53,3 +54,27 @@ def test_unknown_status_acknowledges_only() -> None: ) assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY + + +def test_authorized_does_not_downgrade_confirmed() -> None: + assert should_apply_tbank_payment_status("CONFIRMED", "AUTHORIZED") is False + + +def test_confirmed_overwrites_authorized() -> None: + assert should_apply_tbank_payment_status("AUTHORIZED", "CONFIRMED") is True + + +def test_first_status_always_applies() -> None: + assert should_apply_tbank_payment_status(None, "AUTHORIZED") is True + + +def test_same_status_applies() -> None: + assert should_apply_tbank_payment_status("CONFIRMED", "CONFIRMED") is True + + +def test_refund_overwrites_confirmed() -> None: + assert should_apply_tbank_payment_status("CONFIRMED", "REFUNDED") is True + + +def test_unknown_status_does_not_overwrite_known() -> None: + assert should_apply_tbank_payment_status("CONFIRMED", "WAT") is False diff --git a/tests/services/test_provider_routing.py b/tests/services/test_provider_routing.py index 7509b37..7a185cf 100644 --- a/tests/services/test_provider_routing.py +++ b/tests/services/test_provider_routing.py @@ -136,7 +136,9 @@ class StubOrderRepository: def session(self) -> StubSession: return StubSession() - async def get_order_by_order_uuid(self, session: object, order_uuid: str) -> StoredOrder: + async def get_order_by_order_uuid( + self, session: object, order_uuid: str, *, for_update: bool = False + ) -> StoredOrder: return self._order async def mark_payment_status( diff --git a/tests/services/test_tbank_notifications.py b/tests/services/test_tbank_notifications.py index ef095ec..389ebea 100644 --- a/tests/services/test_tbank_notifications.py +++ b/tests/services/test_tbank_notifications.py @@ -90,6 +90,8 @@ class StubOrderRepository: self, session: object, order_uuid: str, + *, + for_update: bool = False, ) -> StoredOrder | None: self.calls.append(("get_order_by_order_uuid", (session, order_uuid))) return self._orders.get(order_uuid)