Добавлена обработка уведомлений через ручку
This commit is contained in:
@@ -12,6 +12,7 @@ from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
||||
from app.adapters.delivery_providers.cdek.mapper import map_cdek_response
|
||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
CDEKOrderMappingError,
|
||||
map_cdek_existing_order_response,
|
||||
map_cdek_order_request,
|
||||
map_cdek_order_response,
|
||||
)
|
||||
@@ -123,6 +124,11 @@ class CDEKClient:
|
||||
)
|
||||
|
||||
if 400 <= response.status_code < 500:
|
||||
existing_order_uuid = map_cdek_existing_order_response(
|
||||
_response_json_or_none(response)
|
||||
)
|
||||
if existing_order_uuid is not None:
|
||||
return existing_order_uuid
|
||||
raise CDEKRequestError(
|
||||
"CDEK order registration request was rejected with status "
|
||||
f"{response.status_code}."
|
||||
@@ -243,3 +249,10 @@ class CDEKProvider(DeliveryProvider):
|
||||
|
||||
async def register_order(self, request: InitPaymentRequest) -> str:
|
||||
return await self._client.register_order(request)
|
||||
|
||||
|
||||
def _response_json_or_none(response: httpx.Response) -> object | None:
|
||||
try:
|
||||
return response.json()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -15,6 +15,7 @@ class CDEKOrderMappingError(ValueError):
|
||||
|
||||
def map_cdek_order_request(request: InitPaymentRequest) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"number": request.order_uuid,
|
||||
"type": request.type,
|
||||
"tariff_code": request.tariff_code,
|
||||
"sender": _map_order_party(request.sender),
|
||||
@@ -44,6 +45,22 @@ def map_cdek_order_response(payload: dict[str, Any]) -> str:
|
||||
return order_uuid
|
||||
|
||||
|
||||
_CDEK_DUPLICATE_ORDER_ERROR_CODES = frozenset({"v2_entity_already_exists"})
|
||||
|
||||
|
||||
def map_cdek_existing_order_response(payload: object) -> str | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
if not _contains_cdek_duplicate_error_code(payload):
|
||||
return None
|
||||
|
||||
try:
|
||||
return map_cdek_order_response(payload)
|
||||
except CDEKOrderMappingError:
|
||||
return _find_first_uuid(payload)
|
||||
|
||||
|
||||
def _map_order_party(party: PaymentParty) -> dict[str, Any]:
|
||||
return {
|
||||
"name": party.name,
|
||||
@@ -56,3 +73,34 @@ def _map_order_package(package: PaymentPackage) -> dict[str, Any]:
|
||||
payload = package.model_dump(mode="python", exclude_none=True)
|
||||
payload["weight"] = package.weight * 1000
|
||||
return payload
|
||||
|
||||
|
||||
def _contains_cdek_duplicate_error_code(payload: object) -> bool:
|
||||
if isinstance(payload, dict):
|
||||
code = payload.get("code")
|
||||
if isinstance(code, str) and code in _CDEK_DUPLICATE_ORDER_ERROR_CODES:
|
||||
return True
|
||||
return any(
|
||||
_contains_cdek_duplicate_error_code(value)
|
||||
for value in payload.values()
|
||||
)
|
||||
if isinstance(payload, list):
|
||||
return any(_contains_cdek_duplicate_error_code(item) for item in payload)
|
||||
return False
|
||||
|
||||
|
||||
def _find_first_uuid(payload: object) -> str | None:
|
||||
if isinstance(payload, dict):
|
||||
value = payload.get("uuid")
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
for nested_value in payload.values():
|
||||
nested_uuid = _find_first_uuid(nested_value)
|
||||
if nested_uuid is not None:
|
||||
return nested_uuid
|
||||
if isinstance(payload, list):
|
||||
for item in payload:
|
||||
nested_uuid = _find_first_uuid(item)
|
||||
if nested_uuid is not None:
|
||||
return nested_uuid
|
||||
return None
|
||||
|
||||
@@ -22,3 +22,7 @@ class TBankPaymentRequestError(TBankPaymentAdapterError):
|
||||
self.error_code = error_code
|
||||
self.provider_message = provider_message
|
||||
self.details = details
|
||||
|
||||
|
||||
class TBankPaymentNotificationTokenError(TBankPaymentRequestError):
|
||||
"""Raised when a TBank payment notification token is invalid."""
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.tbank.base import (
|
||||
TBankPaymentAdapterError,
|
||||
TBankPaymentNotificationTokenError,
|
||||
TBankPaymentRequestError,
|
||||
)
|
||||
from app.config import TBankPaymentConfig
|
||||
from app.schemas.payment import TBankPaymentNotification
|
||||
|
||||
|
||||
class TBankAdapter:
|
||||
@@ -112,6 +115,17 @@ class TBankAdapter:
|
||||
|
||||
raise TBankPaymentAdapterError("TBank payment init request failed unexpectedly.")
|
||||
|
||||
def verify_payment_notification(
|
||||
self,
|
||||
notification: TBankPaymentNotification,
|
||||
) -> None:
|
||||
payload = notification.model_dump(mode="python")
|
||||
expected_token = _build_tbank_token(payload, password=self._password)
|
||||
if not hmac.compare_digest(expected_token, notification.Token):
|
||||
raise TBankPaymentNotificationTokenError(
|
||||
"TBank payment notification token is invalid."
|
||||
)
|
||||
|
||||
def _build_payload(self, *, order_uuid: str, amount_kopecks: int) -> dict[str, Any]:
|
||||
if not order_uuid.strip():
|
||||
raise TBankPaymentRequestError("TBank payment order id must be non-empty.")
|
||||
@@ -164,11 +178,17 @@ def _build_tbank_token(payload: dict[str, Any], *, password: str) -> str:
|
||||
}
|
||||
token_payload["Password"] = password
|
||||
token_source = "".join(
|
||||
str(token_payload[key]) for key in sorted(token_payload)
|
||||
_stringify_token_value(token_payload[key]) for key in sorted(token_payload)
|
||||
)
|
||||
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _stringify_token_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _response_json_or_none(response: httpx.Response) -> object | None:
|
||||
try:
|
||||
return response.json()
|
||||
|
||||
Reference in New Issue
Block a user