This commit is contained in:
@@ -233,6 +233,13 @@ class CDEKClient:
|
|||||||
url,
|
url,
|
||||||
failure_message="CDEK get order failed",
|
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:
|
try:
|
||||||
return map_cdek_order_info_response(raw_payload)
|
return map_cdek_order_info_response(raw_payload)
|
||||||
except CDEKOrderMappingError as exc:
|
except CDEKOrderMappingError as exc:
|
||||||
@@ -513,3 +520,28 @@ def _response_text_or_none(response: httpx.Response) -> str | None:
|
|||||||
return response.text
|
return response.text
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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
|
||||||
|
|||||||
@@ -17,3 +17,40 @@ def resolve_tbank_payment_notification_action(
|
|||||||
if status == "CONFIRMED" and success is True and error_code == "0":
|
if status == "CONFIRMED" and success is True and error_code == "0":
|
||||||
return TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
|
return TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
|
||||||
return TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
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)
|
||||||
|
|||||||
@@ -50,10 +50,13 @@ class OrderRepository:
|
|||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
order_uuid: str,
|
order_uuid: str,
|
||||||
|
*,
|
||||||
|
for_update: bool = False,
|
||||||
) -> Order | None:
|
) -> Order | None:
|
||||||
result = await session.execute(
|
statement = select(Order).where(Order.order_uuid == order_uuid)
|
||||||
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()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def mark_payment_status(
|
async def mark_payment_status(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from app.adapters.tbank.base import (
|
|||||||
from app.domain.payment_notifications import (
|
from app.domain.payment_notifications import (
|
||||||
TBankPaymentNotificationAction,
|
TBankPaymentNotificationAction,
|
||||||
resolve_tbank_payment_notification_action,
|
resolve_tbank_payment_notification_action,
|
||||||
|
should_apply_tbank_payment_status,
|
||||||
)
|
)
|
||||||
from app.domain.price import (
|
from app.domain.price import (
|
||||||
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||||
@@ -537,9 +538,12 @@ class AggregatorService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with self._order_repository.session() as session:
|
async with self._order_repository.session() as session:
|
||||||
|
# FOR UPDATE: сериализуем конкурентные уведомления по одному заказу,
|
||||||
|
# чтобы внеочередной статус не затирал уже записанный (lost update).
|
||||||
order = await self._order_repository.get_order_by_order_uuid(
|
order = await self._order_repository.get_order_by_order_uuid(
|
||||||
session,
|
session,
|
||||||
notification.OrderId,
|
notification.OrderId,
|
||||||
|
for_update=True,
|
||||||
)
|
)
|
||||||
if order is None:
|
if order is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -549,19 +553,21 @@ class AggregatorService:
|
|||||||
raise TBankPaymentNotificationProcessingError(
|
raise TBankPaymentNotificationProcessingError(
|
||||||
"Order was not found for TBank payment notification."
|
"Order was not found for TBank payment notification."
|
||||||
)
|
)
|
||||||
updated_order = await self._order_repository.mark_payment_status(
|
if should_apply_tbank_payment_status(
|
||||||
|
order.payment_status, notification.Status
|
||||||
|
):
|
||||||
|
await self._order_repository.mark_payment_status(
|
||||||
session,
|
session,
|
||||||
notification.OrderId,
|
notification.OrderId,
|
||||||
notification.Status,
|
notification.Status,
|
||||||
notification.PaymentId,
|
notification.PaymentId,
|
||||||
)
|
)
|
||||||
if updated_order is None:
|
else:
|
||||||
logger.warning(
|
logger.info(
|
||||||
"tbank_payment_notification_order_not_found",
|
"tbank_payment_status_downgrade_skipped",
|
||||||
order_uuid=notification.OrderId,
|
order_uuid=notification.OrderId,
|
||||||
)
|
current_status=order.payment_status,
|
||||||
raise TBankPaymentNotificationProcessingError(
|
incoming_status=notification.Status,
|
||||||
"Order was not found for TBank payment notification."
|
|
||||||
)
|
)
|
||||||
return order
|
return order
|
||||||
except TBankPaymentNotificationProcessingError:
|
except TBankPaymentNotificationProcessingError:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
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"}
|
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:
|
def test_get_order_retries_on_5xx_and_succeeds() -> None:
|
||||||
flaky = httpx.Response(
|
flaky = httpx.Response(
|
||||||
503,
|
503,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from app.domain.payment_notifications import (
|
from app.domain.payment_notifications import (
|
||||||
TBankPaymentNotificationAction,
|
TBankPaymentNotificationAction,
|
||||||
resolve_tbank_payment_notification_action,
|
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
|
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
|
||||||
|
|||||||
@@ -136,7 +136,9 @@ class StubOrderRepository:
|
|||||||
def session(self) -> StubSession:
|
def session(self) -> StubSession:
|
||||||
return 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
|
return self._order
|
||||||
|
|
||||||
async def mark_payment_status(
|
async def mark_payment_status(
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ class StubOrderRepository:
|
|||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
order_uuid: str,
|
order_uuid: str,
|
||||||
|
*,
|
||||||
|
for_update: bool = False,
|
||||||
) -> StoredOrder | None:
|
) -> StoredOrder | None:
|
||||||
self.calls.append(("get_order_by_order_uuid", (session, order_uuid)))
|
self.calls.append(("get_order_by_order_uuid", (session, order_uuid)))
|
||||||
return self._orders.get(order_uuid)
|
return self._orders.get(order_uuid)
|
||||||
|
|||||||
Reference in New Issue
Block a user