Добавлена обработка уведомлений через ручку

This commit is contained in:
Раис Юсупалиев
2026-04-18 21:27:15 +03:00
parent 78e9ad4fa9
commit 6b66af5eb3
38 changed files with 1519 additions and 32 deletions
@@ -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
+4
View File
@@ -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."""
+21 -1
View File
@@ -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()
+46 -1
View File
@@ -1,6 +1,7 @@
"""Delivery API controller skeleton."""
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import PlainTextResponse
from app.adapters.postgres.engine import create_postgres_engine, create_postgres_session_factory
from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider
@@ -14,7 +15,11 @@ from app.config import Settings
from app.controllers.http_client import build_controller_http_client
from app.repositories.cache.redis_cache import PriceCache
from app.repositories.order import OrderRepository
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.schemas.payment import (
InitPaymentRequest,
InitPaymentResponse,
TBankPaymentNotification,
)
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import AddressSuggestion, DeliveryPrice
from app.services.aggregator import (
@@ -24,7 +29,9 @@ from app.services.aggregator import (
InvalidAddressSuggestRequestError,
InvalidDeliveryRequestError,
InvalidInitPaymentRequestError,
InvalidTBankPaymentNotificationError,
InitPaymentUnavailableError,
TBankPaymentNotificationProcessingError,
UnsupportedAddressSuggestionCountryError,
)
@@ -68,6 +75,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
cache=cache,
payment_adapter=payment_adapter,
order_repository=order_repository,
order_registration_adapter=cdek_provider,
address_suggestion_providers=(
dadata_provider,
yandex_geosuggest_provider,
@@ -191,3 +199,40 @@ async def init_payment(
"message": "Payment initialization is temporarily unavailable.",
},
) from exc
@router.post(
"/tbank/notifications",
response_class=PlainTextResponse,
)
async def handle_tbank_payment_notification(
notification: TBankPaymentNotification,
service: AggregatorService = Depends(get_aggregator_service),
) -> PlainTextResponse:
try:
response_body = await service.handle_tbank_payment_notification(notification)
except InvalidTBankPaymentNotificationError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "invalid_tbank_payment_notification",
"message": "TBank payment notification token is invalid.",
},
) from exc
except TBankPaymentNotificationProcessingError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "tbank_payment_notification_processing_unavailable",
"message": "TBank payment notification processing is temporarily unavailable.",
},
) from exc
except AggregatorServiceError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "tbank_payment_notification_processing_unavailable",
"message": "TBank payment notification processing is temporarily unavailable.",
},
) from exc
return PlainTextResponse(response_body)
+19
View File
@@ -0,0 +1,19 @@
"""Pure rules for TBank payment notification handling."""
from enum import Enum
class TBankPaymentNotificationAction(str, Enum):
ACKNOWLEDGE_ONLY = "acknowledge_only"
REGISTER_CDEK_ORDER = "register_cdek_order"
def resolve_tbank_payment_notification_action(
*,
status: str,
success: bool,
error_code: str,
) -> TBankPaymentNotificationAction:
if status == "CONFIRMED" and success is True and error_code == "0":
return TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
return TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
+10 -1
View File
@@ -4,7 +4,7 @@ from datetime import datetime
from typing import Any
from uuid import UUID, uuid4
from sqlalchemy import DateTime, Integer, String, UniqueConstraint, Uuid, func
from sqlalchemy import BigInteger, DateTime, Integer, String, UniqueConstraint, Uuid, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.types import JSON
@@ -56,8 +56,17 @@ class Order(Base):
nullable=True,
)
comment: Mapped[str | None] = mapped_column(String(1024), nullable=True)
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
cdek_order_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+41
View File
@@ -4,6 +4,7 @@ from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.repositories.order.models import Order
@@ -51,3 +52,43 @@ class OrderRepository:
await session.flush()
await session.refresh(order)
return order
async def get_order_by_order_uuid(
self,
session: AsyncSession,
order_uuid: str,
) -> Order | None:
result = await session.execute(
select(Order).where(Order.order_uuid == order_uuid)
)
return result.scalar_one_or_none()
async def mark_payment_status(
self,
session: AsyncSession,
order_uuid: str,
status: str,
payment_id: int,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
order.payment_status = status
order.tbank_payment_id = payment_id
await session.flush()
return order
async def mark_cdek_order_registered(
self,
session: AsyncSession,
order_uuid: str,
cdek_order_uuid: str,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
order.cdek_order_uuid = cdek_order_uuid
await session.flush()
return order
+1 -1
View File
@@ -21,7 +21,7 @@ def configure_logging(
foreign_pre_chain=list(_shared_processors()),
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.JSONRenderer(sort_keys=True),
structlog.processors.JSONRenderer(sort_keys=True, ensure_ascii=False),
],
)
handler = logging.StreamHandler(resolved_stream)
+13
View File
@@ -60,3 +60,16 @@ class InitPaymentRequest(BaseModel):
class InitPaymentResponse(BaseModel):
payment_url: str = Field(min_length=1)
class TBankPaymentNotification(BaseModel):
model_config = ConfigDict(extra="allow")
TerminalKey: str = Field(min_length=1)
OrderId: str = Field(min_length=1)
Success: bool
Status: str = Field(min_length=1)
PaymentId: int = Field(gt=0, strict=True)
ErrorCode: str = Field(min_length=1)
Amount: int
Token: str = Field(min_length=1)
+215 -1
View File
@@ -18,8 +18,13 @@ from app.adapters.address_suggestions.base import (
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentNotificationTokenError,
TBankPaymentRequestError,
)
from app.domain.payment_notifications import (
TBankPaymentNotificationAction,
resolve_tbank_payment_notification_action,
)
from app.domain.price import (
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
DEFAULT_WEIGHT_ROUND_SCALE,
@@ -28,7 +33,11 @@ from app.domain.price import (
normalize_delivery_request,
)
from app.repositories.order import OrderData
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.schemas.payment import (
InitPaymentRequest,
InitPaymentResponse,
TBankPaymentNotification,
)
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import AddressSuggestion, DeliveryPrice
@@ -63,6 +72,14 @@ class InitPaymentUnavailableError(AggregatorServiceError):
"""Raised when payment initialization cannot be completed."""
class InvalidTBankPaymentNotificationError(AggregatorServiceError):
"""Raised when a TBank payment notification is invalid."""
class TBankPaymentNotificationProcessingError(AggregatorServiceError):
"""Raised when a TBank payment notification cannot be processed."""
class PriceCacheProtocol(Protocol):
async def get(self, key: str) -> object | None: ...
@@ -72,12 +89,42 @@ class PriceCacheProtocol(Protocol):
class PaymentAdapterProtocol(Protocol):
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: ...
def verify_payment_notification(
self,
notification: TBankPaymentNotification,
) -> None: ...
class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(self, request: InitPaymentRequest) -> str: ...
class OrderRepositoryProtocol(Protocol):
def session(self) -> AbstractAsyncContextManager[object]: ...
async def create_order(self, session: object, order_data: OrderData) -> object: ...
async def get_order_by_order_uuid(
self,
session: object,
order_uuid: str,
) -> object | None: ...
async def mark_payment_status(
self,
session: object,
order_uuid: str,
status: str,
payment_id: int,
) -> object | None: ...
async def mark_cdek_order_registered(
self,
session: object,
order_uuid: str,
cdek_order_uuid: str,
) -> object | None: ...
class FilterAndSortPricesFn(Protocol):
def __call__(
@@ -96,6 +143,7 @@ class AggregatorService:
cache: PriceCacheProtocol | None = None,
payment_adapter: PaymentAdapterProtocol | None = None,
order_repository: OrderRepositoryProtocol | None = None,
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
*,
@@ -107,6 +155,7 @@ class AggregatorService:
self._cache = cache
self._payment_adapter = payment_adapter
self._order_repository = order_repository
self._order_registration_adapter = order_registration_adapter
self._weight_round_scale = weight_round_scale
self._provider_price_multiplier = provider_price_multiplier
self._filter_and_sort_prices = filter_and_sort_prices_fn
@@ -217,6 +266,155 @@ class AggregatorService:
await self._persist_order(request=request, payment_url=payment_url)
return InitPaymentResponse(payment_url=payment_url)
async def handle_tbank_payment_notification(
self,
notification: TBankPaymentNotification,
) -> str:
if self._payment_adapter is None:
raise TBankPaymentNotificationProcessingError(
"Payment adapter is not configured."
)
try:
self._payment_adapter.verify_payment_notification(notification)
except TBankPaymentNotificationTokenError as exc:
raise InvalidTBankPaymentNotificationError(
"TBank payment notification token is invalid."
) from exc
except TBankPaymentAdapterError as exc:
raise TBankPaymentNotificationProcessingError(
"TBank payment notification verification failed."
) from exc
action = resolve_tbank_payment_notification_action(
status=notification.Status,
success=notification.Success,
error_code=notification.ErrorCode,
)
order = await self._load_order_and_mark_payment_status(notification)
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
return "OK"
existing_cdek_order_uuid = getattr(order, "cdek_order_uuid", None)
if existing_cdek_order_uuid:
return "OK"
cdek_order_uuid = await self._register_cdek_order(order)
await self._save_cdek_order_uuid(
order_uuid=notification.OrderId,
cdek_order_uuid=cdek_order_uuid,
)
return "OK"
async def _load_order_and_mark_payment_status(
self,
notification: TBankPaymentNotification,
) -> object:
if self._order_repository is None:
raise TBankPaymentNotificationProcessingError(
"Order repository is not configured."
)
try:
async with self._order_repository.session() as session:
order = await self._order_repository.get_order_by_order_uuid(
session,
notification.OrderId,
)
if order is None:
logger.warning(
"tbank_payment_notification_order_not_found",
order_uuid=notification.OrderId,
)
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,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found for TBank payment notification."
)
return order
except TBankPaymentNotificationProcessingError:
raise
except Exception as exc:
logger.exception(
"tbank_payment_notification_order_update_failed",
order_uuid=notification.OrderId,
payment_status=notification.Status,
tbank_payment_id=notification.PaymentId,
)
raise TBankPaymentNotificationProcessingError(
"TBank payment notification order update failed."
) from exc
async def _register_cdek_order(self, order: object) -> str:
if self._order_registration_adapter is None:
raise TBankPaymentNotificationProcessingError(
"CDEK order registration adapter is not configured."
)
try:
request = self._to_init_payment_request_from_order(order)
return await self._order_registration_adapter.register_order(request)
except Exception as exc:
logger.exception(
"cdek_order_registration_failed",
order_uuid=getattr(order, "order_uuid", None),
)
raise TBankPaymentNotificationProcessingError(
"CDEK order registration failed."
) from exc
async def _save_cdek_order_uuid(
self,
*,
order_uuid: str,
cdek_order_uuid: str,
) -> None:
if self._order_repository is None:
raise TBankPaymentNotificationProcessingError(
"Order repository is not configured."
)
try:
async with self._order_repository.session() as session:
order = await self._order_repository.mark_cdek_order_registered(
session,
order_uuid,
cdek_order_uuid,
)
if order is None:
logger.warning(
"cdek_order_uuid_order_not_found",
order_uuid=order_uuid,
cdek_order_uuid=cdek_order_uuid,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found while saving CDEK order UUID."
)
except TBankPaymentNotificationProcessingError:
raise
except Exception as exc:
logger.exception(
"cdek_order_uuid_persistence_failed",
order_uuid=order_uuid,
cdek_order_uuid=cdek_order_uuid,
)
raise TBankPaymentNotificationProcessingError(
"CDEK order UUID persistence failed."
) from exc
async def _persist_order(
self,
*,
@@ -260,6 +458,22 @@ class AggregatorService:
comment=request.comment,
)
@staticmethod
def _to_init_payment_request_from_order(order: object) -> InitPaymentRequest:
return InitPaymentRequest(
order_uuid=getattr(order, "order_uuid"),
price=getattr(order, "price"),
type=getattr(order, "delivery_type"),
tariff_code=getattr(order, "tariff_code"),
comment=getattr(order, "comment"),
sender=getattr(order, "sender"),
recipient=getattr(order, "recipient"),
from_location=getattr(order, "from_location"),
to_location=getattr(order, "to_location"),
services=getattr(order, "services"),
packages=getattr(order, "packages"),
)
async def _get_provider_prices(
self,
*,