From 6b66af5eb32ac15fc7c6980a299b5357ff72a13f 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, 18 Apr 2026 21:27:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D1=80=D1=83?= =?UTF-8?q?=D1=87=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260412_028_create_orders_table.py | 9 + .../delivery_providers/cdek/client.py | 13 + .../delivery_providers/cdek/order_mapper.py | 48 +++ app/adapters/tbank/base.py | 4 + app/adapters/tbank/client.py | 22 +- app/controllers/v1/delivery.py | 47 ++- app/domain/payment_notifications.py | 19 + app/repositories/order/models.py | 11 +- app/repositories/order/repository.py | 41 +++ app/runtime/logging.py | 2 +- app/schemas/payment.py | 13 + app/services/aggregator.py | 216 ++++++++++- config.example.yaml | 2 +- config.test.yaml | 2 +- docker-compose.yml | 4 +- spec/index.md | 5 +- spec/overview.md | 39 +- ..._add_tbank_payment_notification_webhook.md | 81 +++++ .../delivery_providers/cdek/test_client.py | 2 +- .../cdek/test_order_client.py | 65 ++++ .../cdek/test_order_mapper.py | 53 +++ tests/adapters/tbank/test_client.py | 12 +- tests/adapters/tbank/test_notifications.py | 135 +++++++ tests/config/fixtures/config.default.yaml | 2 +- .../config.invalid_price_multiplier.yaml | 2 +- .../fixtures/config.missing_adapter.yaml | 2 +- .../config.missing_address_suggestions.yaml | 2 +- .../config.missing_observability.yaml | 2 +- ...config.missing_observability_endpoint.yaml | 2 +- .../config.missing_price_multiplier.yaml | 2 +- .../config/fixtures/config.test.override.yaml | 2 +- tests/config/test_config_sections.py | 8 +- .../v1/test_tbank_notifications.py | 120 ++++++ tests/domain/test_payment_notifications.py | 55 +++ tests/repositories/cache/test_redis_cache.py | 2 +- tests/repositories/order/test_repository.py | 160 ++++++++ tests/services/test_tbank_notifications.py | 342 ++++++++++++++++++ tests/smoke/test_app_import.py | 3 + 38 files changed, 1519 insertions(+), 32 deletions(-) create mode 100644 app/domain/payment_notifications.py create mode 100644 spec/tasks/030_add_tbank_payment_notification_webhook.md create mode 100644 tests/adapters/delivery_providers/cdek/test_order_mapper.py create mode 100644 tests/adapters/tbank/test_notifications.py create mode 100644 tests/controllers/v1/test_tbank_notifications.py create mode 100644 tests/domain/test_payment_notifications.py create mode 100644 tests/services/test_tbank_notifications.py diff --git a/alembic/versions/20260412_028_create_orders_table.py b/alembic/versions/20260412_028_create_orders_table.py index d92907a..0e4576a 100644 --- a/alembic/versions/20260412_028_create_orders_table.py +++ b/alembic/versions/20260412_028_create_orders_table.py @@ -36,12 +36,21 @@ def upgrade() -> None: sa.Column("packages", postgresql.JSONB(astext_type=sa.Text()), nullable=False), sa.Column("services", postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.Column("comment", sa.String(length=1024), nullable=True), + sa.Column("payment_status", sa.String(length=64), nullable=True), + sa.Column("tbank_payment_id", sa.BigInteger(), nullable=True), + sa.Column("cdek_order_uuid", sa.String(length=128), nullable=True), sa.Column( "created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False, ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("order_uuid", name="uq_orders_order_uuid"), ) diff --git a/app/adapters/delivery_providers/cdek/client.py b/app/adapters/delivery_providers/cdek/client.py index 8760b0b..7b3f3be 100644 --- a/app/adapters/delivery_providers/cdek/client.py +++ b/app/adapters/delivery_providers/cdek/client.py @@ -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 diff --git a/app/adapters/delivery_providers/cdek/order_mapper.py b/app/adapters/delivery_providers/cdek/order_mapper.py index 8c6c20a..23b3278 100644 --- a/app/adapters/delivery_providers/cdek/order_mapper.py +++ b/app/adapters/delivery_providers/cdek/order_mapper.py @@ -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 diff --git a/app/adapters/tbank/base.py b/app/adapters/tbank/base.py index 29d7bcc..da1e5ac 100644 --- a/app/adapters/tbank/base.py +++ b/app/adapters/tbank/base.py @@ -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.""" diff --git a/app/adapters/tbank/client.py b/app/adapters/tbank/client.py index 1ac1cd1..c721a67 100644 --- a/app/adapters/tbank/client.py +++ b/app/adapters/tbank/client.py @@ -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() diff --git a/app/controllers/v1/delivery.py b/app/controllers/v1/delivery.py index 3780183..44969da 100644 --- a/app/controllers/v1/delivery.py +++ b/app/controllers/v1/delivery.py @@ -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) diff --git a/app/domain/payment_notifications.py b/app/domain/payment_notifications.py new file mode 100644 index 0000000..4cb161b --- /dev/null +++ b/app/domain/payment_notifications.py @@ -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 diff --git a/app/repositories/order/models.py b/app/repositories/order/models.py index 0d9c7dd..198e278 100644 --- a/app/repositories/order/models.py +++ b/app/repositories/order/models.py @@ -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, + ) diff --git a/app/repositories/order/repository.py b/app/repositories/order/repository.py index 112a3ef..9251680 100644 --- a/app/repositories/order/repository.py +++ b/app/repositories/order/repository.py @@ -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 diff --git a/app/runtime/logging.py b/app/runtime/logging.py index 99b0147..a8a4ad9 100644 --- a/app/runtime/logging.py +++ b/app/runtime/logging.py @@ -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) diff --git a/app/schemas/payment.py b/app/schemas/payment.py index f36e32a..a0b8c57 100644 --- a/app/schemas/payment.py +++ b/app/schemas/payment.py @@ -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) diff --git a/app/services/aggregator.py b/app/services/aggregator.py index c680891..412afc3 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -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, *, diff --git a/config.example.yaml b/config.example.yaml index 04e920a..5032924 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -25,7 +25,7 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://merchant.example.com/tbank/notification" + notification_url: "https://merchant.example.com/api/v1/delivery/tbank/notifications" success_url: "https://merchant.example.com/payment/success" auth: terminal_key: "change-me-terminal-key" diff --git a/config.test.yaml b/config.test.yaml index 9c864d3..28b272c 100644 --- a/config.test.yaml +++ b/config.test.yaml @@ -25,7 +25,7 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://merchant.test/tbank/notification" + notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications" success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" diff --git a/docker-compose.yml b/docker-compose.yml index 5f3e7a3..a01b60d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,12 +34,14 @@ services: container_name: postgres environment: POSTGRES_DB: g2s_aggregator + POSTGRES_USER: g2s_user + POSTGRES_PASSWORD: 7ed0a5a0f24be266 ports: - "5432:5432" volumes: - postgres:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d g2s_aggregator"] + test: ["CMD-SHELL", "pg_isready -U g2s_user -d g2s_aggregator"] interval: 5s timeout: 3s retries: 10 diff --git a/spec/index.md b/spec/index.md index b74ce90..d0d006d 100644 --- a/spec/index.md +++ b/spec/index.md @@ -36,9 +36,10 @@ | 027 | DONE | 2026-04-11 | Add TBank payment adapter, init_payment endpoint and rename order flow | `spec/tasks/027_add_tbank_payment_adapter_and_order_payment_link.md` | | 028 | DONE | 2026-04-12 | Add PostgreSQL adapter, order repository and persist order after payment link creation | `spec/tasks/028_add_postgresql_order_persistence.md` | | 029 | DONE | 2026-04-18 | Add TBank payment notification and success URLs | `spec/tasks/029_add_tbank_payment_urls.md` | +| 030 | TODO | 2026-04-18 | Add TBank payment notification webhook and CDEK order creation | `spec/tasks/030_add_tbank_payment_notification_webhook.md` | ## Summary -- Total: **30** -- TODO: **0** +- Total: **31** +- TODO: **1** - DONE: **30** diff --git a/spec/overview.md b/spec/overview.md index a28049a..61c182c 100644 --- a/spec/overview.md +++ b/spec/overview.md @@ -21,6 +21,9 @@ - Принимать запрос на инициализацию оплаты доставки через TBank и возвращать ссылку на оплату без регистрации заказа в CDEK - Принимать сумму оплаты в поле `price` в копейках - После успешного получения ссылки на оплату от TBank сохранять все данные заявки вместе со ссылкой на оплату в PostgreSQL; ошибка сохранения не блокирует возврат ссылки клиенту +- Принимать HTTP-уведомления TBank о статусах платежа на отдельный webhook endpoint +- При получении валидного уведомления TBank со статусом `CONFIRMED` находить сохранённую заявку в PostgreSQL и регистрировать заказ в CDEK +- Остальные валидные статусы платежа TBank подтверждать без регистрации заказа в CDEK - Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге - Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru` - Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest @@ -44,7 +47,7 @@ - Cache TTL: 15 минут - **TBank** — https://securepay.tinkoff.ru/v2/Init - Аутентификация: `TerminalKey` и token на основе password - - Операции: инициализация платежа и получение payment URL + - Операции: инициализация платежа, получение payment URL, проверка подписи HTTP-уведомлений ### Фаза 2+ - Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации @@ -57,9 +60,10 @@ - `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]` - `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]` - `POST /api/v1/delivery/init-payment` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse` +- `POST /api/v1/delivery/tbank/notifications` — принимает `TBankPaymentNotification`, возвращает plain text `OK` при успешной обработке - Парсинг и валидация входных данных через Pydantic - Маппинг исключений сервиса в HTTP-ответы -- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()` или `AggregatorService.init_payment()` +- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()`, `AggregatorService.init_payment()` или `AggregatorService.handle_tbank_payment_notification()` ### Service (`app/services/aggregator.py`) - `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]` @@ -71,6 +75,10 @@ - `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call - `AggregatorService.init_payment(request: InitPaymentRequest) -> InitPaymentResponse` - `AggregatorService.init_payment()` оркестрирует инициализацию платежа через injected TBank adapter dependency, затем сохраняет данные заявки вместе с `payment_url` через injected order repository; ошибка сохранения логируется, но не блокирует возврат `payment_url` +- `AggregatorService.handle_tbank_payment_notification(notification: TBankPaymentNotification) -> str` +- `AggregatorService.handle_tbank_payment_notification()` оркестрирует проверку подписи уведомления через injected TBank adapter, определение действия по статусу через Business Logic, чтение сохранённой заявки через injected order repository и регистрацию заказа через injected CDEK adapter при статусе `CONFIRMED` +- Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` не должно повторно создавать заказ в CDEK +- Валидные уведомления TBank со статусами, отличными от `CONFIRMED`, подтверждаются ответом `OK` без обращения к CDEK - Service не содержит бизнес-логики и provider HTTP-деталей ### Business Logic (`app/domain/`) @@ -79,6 +87,7 @@ - Логика сравнения цен - Правила применения конфигурируемого мультипликатора к ценам провайдеров - Округление цены после применения мультипликатора до целого значения по детерминированному правилу +- Правило выбора действия для TBank payment notification по статусу платежа - Нормализация входных данных (например, идентификаторов городов и правил округления веса) - Чистые функции, без IO, без зависимостей от фреймворков @@ -90,6 +99,9 @@ ### Repository (`app/repositories/order/`) - `OrderRepository` — сохранение данных заявки в PostgreSQL - Операции: `create_order(session, order_data)` — сохраняет запись заявки с данными `InitPaymentRequest` и `payment_url` +- Операции: `get_order_by_order_uuid(session, order_uuid)` — получает сохранённую заявку для payment notification flow +- Операции: `mark_payment_status(session, order_uuid, status, payment_id)` — сохраняет последний статус платежа TBank +- Операции: `mark_cdek_order_registered(session, order_uuid, cdek_order_uuid)` — сохраняет UUID заказа CDEK после успешной регистрации - SQLAlchemy ORM model таблицы `orders` в `models.py` - Без бизнес-решений; только CRUD-примитивы @@ -113,6 +125,7 @@ - `base.py` — исключения TBank payment adapter - `client.py` — `TBankAdapter` - `TBankAdapter.create_payment_link(order_uuid: str, amount_kopecks: int) -> str` инициализирует платёж TBank и возвращает payment URL +- `TBankAdapter.verify_payment_notification(notification: TBankPaymentNotification) -> None` проверяет token HTTP-уведомления TBank по top-level scalar fields, исключая `Token` и вложенные объекты - TBank adapter инкапсулирует HTTP-взаимодействие с TBank Init API, auth token, retries, timeout, serialization и error handling - TBank adapter владеет собственной секцией конфигурации `tbank_payment` с полями `init_url`, `auth.terminal_key`, `auth.password`, `timeout_seconds`, `retry_attempts`, `retry_backoff_seconds` @@ -226,6 +239,28 @@ packages: list[{number: str, weight: int, length: int, width: int, height: int, payment_url: str ``` +### Входная: `TBankPaymentNotification` +``` +TerminalKey: str +OrderId: str +Success: bool +Status: str +PaymentId: int +ErrorCode: str +Amount: int +Token: str +``` + +`PaymentId` приходит из TBank как JSON-число (long integer) и валидируется как положительное целое. +Модель уведомления должна допускать дополнительные top-level поля TBank. Проверка token использует все top-level scalar fields кроме `Token`, добавляет `Password` из `tbank_payment.auth.password`, сортирует ключи по алфавиту, конкатенирует строковые представления значений (булевы — как lowercase `true`/`false`) и сравнивает SHA-256 hash с `Token`. Вложенные объекты, включая `Data` и `Receipt`, не участвуют в проверке token. + +### Выходная: TBank notification response +``` +OK +``` + +Успешно обработанное HTTP-уведомление TBank возвращает `HTTP 200` и plain text body `OK`. + --- ## Технологический стек diff --git a/spec/tasks/030_add_tbank_payment_notification_webhook.md b/spec/tasks/030_add_tbank_payment_notification_webhook.md new file mode 100644 index 0000000..4728874 --- /dev/null +++ b/spec/tasks/030_add_tbank_payment_notification_webhook.md @@ -0,0 +1,81 @@ +--- +id: 030 +title: Add TBank payment notification webhook and CDEK order creation +status: TODO +created: 2026-04-18 +--- + +## Context +TBank Init API уже получает `NotificationURL`, а данные заявки сохраняются в PostgreSQL после создания payment link. Сейчас приложение не принимает HTTP-уведомления TBank и не создаёт заказ в CDEK после подтверждения оплаты. + +## Goal +Добавить `POST /api/v1/delivery/tbank/notifications`, который принимает payment notification от TBank, проверяет token уведомления, при валидном статусе `CONFIRMED` находит сохранённую заявку по `OrderId` и регистрирует заказ в CDEK. Валидные уведомления с другими статусами должны подтверждаться без регистрации заказа в CDEK. + +## Constraints +- Controller отвечает только за routing, DTO validation и HTTP error mapping; endpoint вызывает ровно один метод Service: `AggregatorService.handle_tbank_payment_notification()`. +- Service выполняет только orchestration: verification через TBank adapter, выбор действия через pure Business Logic, чтение/обновление заявки через OrderRepository и вызов injected CDEK order adapter. +- Правило `CONFIRMED` -> создать заказ CDEK, остальные статусы -> подтвердить без CDEK должно быть pure Business Logic в `app/domain/`. +- TBank-specific token verification должна быть инкапсулирована в `app/adapters/tbank/` и использовать `tbank_payment.auth.password`. +- Проверка token MUST следовать контракту TBank HTTP-уведомлений: использовать top-level scalar fields кроме `Token`, не включать вложенные объекты (`Data`, `Receipt`), добавить `Password`, отсортировать ключи по алфавиту, конкатенировать значения, посчитать SHA-256 и сравнить с `Token`. +- Успешно обработанное уведомление MUST возвращать `HTTP 200` с plain text body `OK`. +- Валидные уведомления со статусами, отличными от `CONFIRMED`, MUST возвращать `OK` без чтения CDEK и без создания заказа. +- Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` MUST возвращать `OK` без повторного вызова CDEK. +- Если token невалиден, endpoint MUST возвращать deterministic `400` и не обращаться к repository или CDEK. +- Если заявка для `OrderId` не найдена или CDEK registration не завершилась успешно, endpoint MUST не возвращать `OK`, чтобы TBank мог повторить notification delivery. +- Repository содержит только CRUD/query/update primitives; без workflow logic и business decisions. +- CDEK order payload MUST передавать `order_uuid` как external идентификатор заказа CDEK (поле `number` в CDEK order contract), чтобы повторные вызовы CDEK при HTTP timeout/ретрае обрабатывались идемпотентно на стороне CDEK и никогда не создавали дубль заказа. +- Service MUST трактовать ответ CDEK "заказ с таким external id уже существует" (возврат существующего `entity.uuid` либо CDEK-specific duplicate response) как успех, сохранять возвращённый `cdek_order_uuid` и отвечать `OK`, а не как ошибку с повторной регистрацией. +- Не изменять public contract `POST /api/v1/delivery/init-payment`, price flow и address suggestion flow. +- Не добавлять новые payment providers, refund flow, recurring payments, ручной retry endpoint или endpoint чтения заявок. +- Не изменять файлы в `spec/`. + +## Acceptance criteria +- Существует schema `TBankPaymentNotification` с обязательными полями `TerminalKey`, `OrderId`, `Success`, `Status`, `PaymentId`, `ErrorCode`, `Amount`, `Token` и поддержкой дополнительных top-level полей TBank. `PaymentId` валидируется как положительное целое (TBank присылает long integer) и сохраняется в `tbank_payment_id` колонке `BIGINT`. +- `POST /api/v1/delivery/tbank/notifications` реализован в существующем controller `app/controllers/v1/delivery.py` и возвращает `PlainTextResponse("OK")` при успешной обработке. +- Controller делегирует обработку ровно в `AggregatorService.handle_tbank_payment_notification()` и не содержит branching по TBank status. +- `TBankAdapter` умеет проверять token payment notification через `tbank_payment.auth.password`; невалидный token маппится в service/controller error path без repository/CDEK side effects. +- В `app/domain/` есть pure function, которая для `Status == "CONFIRMED"`, `Success == true` и `ErrorCode == "0"` возвращает действие регистрации CDEK, а для остальных статусов возвращает действие acknowledge-only. +- `OrderRepository` предоставляет primitive для получения заявки по `order_uuid`, сохранения последнего TBank payment status/payment id и сохранения `cdek_order_uuid`. +- Таблица `orders` содержит минимум поля `payment_status`, `tbank_payment_id` (BIGINT), `cdek_order_uuid`, `updated_at` в исходной миграции; отдельная миграция не вводится, так как production БД ещё не развёрнута. +- При валидном `CONFIRMED` notification service получает order по `OrderId`, реконструирует `InitPaymentRequest` из сохранённых данных, вызывает injected CDEK order adapter registration и сохраняет полученный `cdek_order_uuid`. +- Если для `OrderId` уже сохранён `cdek_order_uuid`, повторный `CONFIRMED` notification возвращает `OK` без повторного вызова CDEK. +- CDEK order mapper проставляет `InitPaymentRequest.order_uuid` в поле external номера заказа CDEK (`number`), так что повторный POST с тем же external id не создаёт второй заказ в CDEK. +- Если CDEK create фактически завершился успешно, но сохранение `cdek_order_uuid` в DB не прошло, следующий валидный `CONFIRMED` notification MUST завершиться сохранением того же `cdek_order_uuid` (полученного по тому же external id) без создания второго заказа в CDEK. +- Валидные notification со статусами `AUTHORIZED`, `REJECTED`, `CANCELED`, `DEADLINE_EXPIRED` и неизвестными status values возвращают `OK` без регистрации CDEK order. +- Ошибка поиска заявки или ошибка CDEK registration возвращает deterministic non-OK HTTP response и логируется. +- `NotificationURL` в runtime/test/example конфигурации, если он присутствует в репозитории, указывает на `/api/v1/delivery/tbank/notifications`. + +## Definition of Done +- [ ] Добавлена schema `TBankPaymentNotification`. +- [ ] Добавлена pure Business Logic для выбора действия по TBank payment notification status. +- [ ] Добавлена token verification logic в TBank adapter. +- [ ] Расширена SQLAlchemy model и обновлена существующая Alembic migration `20260412_028_create_orders_table` payment/CDEK status fields (production БД отсутствует, новая миграция не нужна). +- [ ] Расширен `OrderRepository` primitives для read/update операций, нужных webhook flow. +- [ ] Реализован `AggregatorService.handle_tbank_payment_notification()` с DI dependencies. +- [ ] Добавлен endpoint `POST /api/v1/delivery/tbank/notifications` в существующий delivery controller. +- [ ] Wiring использует существующие `TBankAdapter`, `OrderRepository` и CDEK provider/adapter без provider HTTP details в Service/Controller. +- [ ] Повторные `CONFIRMED` notification обрабатываются идемпотентно. +- [ ] CDEK order mapper проставляет `order_uuid` в поле external номера заказа CDEK. +- [ ] Service корректно обрабатывает ответ CDEK "заказ уже существует" как успех и сохраняет возвращённый `cdek_order_uuid`. +- [ ] Валидные non-`CONFIRMED` statuses подтверждаются без CDEK side effects. +- [ ] Обновлены tests для domain, adapter, repository, service, controller и smoke wiring. +- [ ] Все команды из раздела Commands проходят. + +## Tests +- Добавить `tests/domain/test_payment_notifications.py`: `CONFIRMED` + `Success=true` + `ErrorCode=0` -> create CDEK order action; остальные known/unknown statuses -> acknowledge-only. +- Обновить или добавить `tests/adapters/tbank/test_notifications.py`: valid token, invalid token, exclusion of `Token`, exclusion of nested `Data`/`Receipt`, inclusion of extra scalar fields, deterministic SHA-256 comparison. +- Обновить `tests/repositories/order/test_repository.py`: получение заявки по `order_uuid`, сохранение `payment_status`/`tbank_payment_id`, сохранение `cdek_order_uuid`, duplicate/idempotency scenario. +- Обновить `tests/adapters/delivery_providers/cdek/test_order_mapper.py`: CDEK order payload содержит `InitPaymentRequest.order_uuid` в поле external номера заказа (`number`). +- Добавить `tests/services/test_tbank_notifications.py`: valid `CONFIRMED` вызывает repository lookup, CDEK registration и save `cdek_order_uuid`; duplicate `CONFIRMED` не вызывает CDEK; non-`CONFIRMED` возвращает `OK` без CDEK; invalid token не обращается к repository; missing order/CDEK failure возвращает service error; сценарий "CDEK create succeeded, save `cdek_order_uuid` raised" — следующий `CONFIRMED` notification вызывает CDEK повторно с тем же external id, получает тот же `cdek_order_uuid`, сохраняет его и возвращает `OK` (суммарно ровно один реальный заказ в CDEK). +- Добавить `tests/controllers/v1/test_tbank_notifications.py`: endpoint возвращает plain text `OK` для successful service response, 400 для invalid token, 503 для temporary processing failure, controller делегирует ровно один service method. +- Обновить `tests/smoke/test_app_import.py` при необходимости для проверки wiring нового endpoint. + +## Commands +- `poetry run pytest tests/domain/test_payment_notifications.py -q` +- `poetry run pytest tests/adapters/tbank/test_notifications.py -q` +- `poetry run pytest tests/repositories/order/test_repository.py -q` +- `poetry run pytest tests/services/test_tbank_notifications.py -q` +- `poetry run pytest tests/controllers/v1/test_tbank_notifications.py -q` +- `poetry run pytest tests/smoke/test_app_import.py -q` +- `poetry run pytest -q` +- `python3 spec/gen_spec_index.py --check` diff --git a/tests/adapters/delivery_providers/cdek/test_client.py b/tests/adapters/delivery_providers/cdek/test_client.py index 8bef333..1b7ed45 100644 --- a/tests/adapters/delivery_providers/cdek/test_client.py +++ b/tests/adapters/delivery_providers/cdek/test_client.py @@ -298,7 +298,7 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://merchant.test/tbank/notification" + notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications" success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" diff --git a/tests/adapters/delivery_providers/cdek/test_order_client.py b/tests/adapters/delivery_providers/cdek/test_order_client.py index 3882cfa..5735d45 100644 --- a/tests/adapters/delivery_providers/cdek/test_order_client.py +++ b/tests/adapters/delivery_providers/cdek/test_order_client.py @@ -119,6 +119,7 @@ def test_provider_register_order_posts_cdek_contract_payload_and_maps_response() "method": "POST", "url": "https://api.cdek.test/v2/orders", "json": { + "number": "order-uuid-1", "type": 2, "tariff_code": 535, "comment": "Test order", @@ -245,6 +246,70 @@ def test_cdek_client_register_order_maps_4xx_to_request_error() -> None: assert len(http_client.calls) == 1 +def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> None: + duplicate_response = httpx.Response( + 409, + json={ + "entity": {"uuid": "existing-cdek-order-uuid"}, + "requests": [ + { + "errors": [ + { + "code": "v2_entity_already_exists", + "message": "Order with this number already exists", + } + ] + } + ], + }, + request=httpx.Request("POST", "https://api.cdek.test/v2/orders"), + ) + http_client = SequenceHTTPClient([duplicate_response]) + client = CDEKClient( + http_client=http_client, # type: ignore[arg-type] + auth_client=StubAuthClient(), # type: ignore[arg-type] + base_url="https://api.cdek.test/v2", + retry_attempts=2, + ) + + result = asyncio.run(client.register_order(_make_order_request())) + + assert result == "existing-cdek-order-uuid" + assert len(http_client.calls) == 1 + + +def test_cdek_client_register_order_4xx_with_unrelated_entity_uuid_raises_request_error() -> None: + unrelated_response = httpx.Response( + 409, + json={ + "entity": {"uuid": "unrelated-uuid"}, + "requests": [ + { + "errors": [ + { + "code": "v2_recipient_phone_invalid", + "message": "Recipient phone is invalid", + } + ] + } + ], + }, + request=httpx.Request("POST", "https://api.cdek.test/v2/orders"), + ) + http_client = SequenceHTTPClient([unrelated_response]) + client = CDEKClient( + http_client=http_client, # type: ignore[arg-type] + auth_client=StubAuthClient(), # type: ignore[arg-type] + base_url="https://api.cdek.test/v2", + retry_attempts=2, + ) + + with pytest.raises(CDEKRequestError): + asyncio.run(client.register_order(_make_order_request())) + + assert len(http_client.calls) == 1 + + def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> None: first_response = httpx.Response( 503, diff --git a/tests/adapters/delivery_providers/cdek/test_order_mapper.py b/tests/adapters/delivery_providers/cdek/test_order_mapper.py new file mode 100644 index 0000000..59891bc --- /dev/null +++ b/tests/adapters/delivery_providers/cdek/test_order_mapper.py @@ -0,0 +1,53 @@ +from app.adapters.delivery_providers.cdek.order_mapper import ( + map_cdek_order_request, +) +from app.schemas.payment import InitPaymentRequest + + +def _make_order_request(**overrides: object) -> InitPaymentRequest: + payload: dict[str, object] = { + "order_uuid": "order-uuid-1", + "price": 125000, + "type": 2, + "tariff_code": 535, + "comment": "Test order", + "sender": { + "name": "Petr Petrov", + "email": "sender@example.com", + "phone": {"number": "+79009876543"}, + }, + "recipient": { + "name": "Ivan Ivanov", + "email": "ivan@example.com", + "phone": {"number": "+79001234567"}, + }, + "from_location": { + "address": "Lenina 1", + "city": "Moscow", + "country_code": "RU", + }, + "to_location": { + "address": "Pushkina 10", + "city": "Novosibirsk", + "country_code": "RU", + }, + "services": [{"code": "INSURANCE", "parameter": "1000"}], + "packages": [ + { + "number": "1", + "weight": 1, + "length": 20, + "width": 15, + "height": 10, + "comment": "Package 1", + } + ], + } + payload.update(overrides) + return InitPaymentRequest(**payload) + + +def test_cdek_order_payload_uses_order_uuid_as_external_number() -> None: + payload = map_cdek_order_request(_make_order_request()) + + assert payload["number"] == "order-uuid-1" diff --git a/tests/adapters/tbank/test_client.py b/tests/adapters/tbank/test_client.py index b46515d..60e5ec3 100644 --- a/tests/adapters/tbank/test_client.py +++ b/tests/adapters/tbank/test_client.py @@ -49,7 +49,7 @@ class SequenceHTTPClient: def _make_adapter( http_client: SequenceHTTPClient, *, - notification_url: str = "https://example.test/tbank/notify", + notification_url: str = "https://example.test/api/v1/delivery/tbank/notifications", success_url: str = "https://example.test/payment/success", retry_attempts: int = 0, retry_backoff_seconds: float = 0.2, @@ -92,7 +92,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None expected_token = hashlib.sha256( ( "125000" - "https://example.test/tbank/notify" + "https://example.test/api/v1/delivery/tbank/notifications" "order-uuid-1" "test-password" "https://example.test/payment/success" @@ -108,7 +108,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None "TerminalKey": "TBankTest", "Amount": 125000, "OrderId": "order-uuid-1", - "NotificationURL": "https://example.test/tbank/notify", + "NotificationURL": "https://example.test/api/v1/delivery/tbank/notifications", "SuccessURL": "https://example.test/payment/success", "Token": expected_token, }, @@ -131,7 +131,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None: http_client = SequenceHTTPClient([response]) config = TBankPaymentConfig( init_url="https://securepay.tinkoff.ru/v2/Init", - notification_url="https://merchant.test/tbank/notification", + notification_url="https://merchant.test/api/v1/delivery/tbank/notifications", success_url="https://merchant.test/payment/success", auth=TBankPaymentAuthConfig( terminal_key="ConfigTerminal", @@ -156,7 +156,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None: expected_token = hashlib.sha256( ( "9900" - "https://merchant.test/tbank/notification" + "https://merchant.test/api/v1/delivery/tbank/notifications" "order-uuid-2" "config-password" "https://merchant.test/payment/success" @@ -168,7 +168,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None: "TerminalKey": "ConfigTerminal", "Amount": 9900, "OrderId": "order-uuid-2", - "NotificationURL": "https://merchant.test/tbank/notification", + "NotificationURL": "https://merchant.test/api/v1/delivery/tbank/notifications", "SuccessURL": "https://merchant.test/payment/success", "Token": expected_token, } diff --git a/tests/adapters/tbank/test_notifications.py b/tests/adapters/tbank/test_notifications.py new file mode 100644 index 0000000..7eee1f4 --- /dev/null +++ b/tests/adapters/tbank/test_notifications.py @@ -0,0 +1,135 @@ +import hashlib + +import pytest + +from app.adapters.tbank.base import TBankPaymentNotificationTokenError +from app.adapters.tbank.client import TBankAdapter +from app.schemas.payment import TBankPaymentNotification + + +def _make_adapter() -> TBankAdapter: + return TBankAdapter( + http_client=object(), # type: ignore[arg-type] + init_url="https://securepay.tinkoff.ru/v2/Init", + notification_url="https://merchant.test/api/v1/delivery/tbank/notifications", + success_url="https://merchant.test/payment/success", + terminal_key="TestTerminal", + password="test-password", + ) + + +def _base_payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "TerminalKey": "TestTerminal", + "OrderId": "order-uuid-1", + "Success": True, + "Status": "CONFIRMED", + "PaymentId": 8347568144, + "ErrorCode": "0", + "Amount": 125000, + } + payload.update(overrides) + return payload + + +def _build_notification_token(payload: dict[str, object]) -> str: + token_payload = { + key: value + for key, value in payload.items() + if key != "Token" and not isinstance(value, (dict, list)) + } + token_payload["Password"] = "test-password" + token_source = "".join( + _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: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def _signed_notification(**overrides: object) -> TBankPaymentNotification: + payload = _base_payload(**overrides) + if "Token" not in payload: + payload["Token"] = _build_notification_token(payload) + return TBankPaymentNotification(**payload) + + +def test_verify_payment_notification_accepts_valid_token() -> None: + adapter = _make_adapter() + notification = _signed_notification() + + adapter.verify_payment_notification(notification) + + +def test_verify_payment_notification_rejects_invalid_token() -> None: + adapter = _make_adapter() + notification = _signed_notification(Token="invalid-token") + + with pytest.raises(TBankPaymentNotificationTokenError): + adapter.verify_payment_notification(notification) + + +def test_verify_payment_notification_excludes_token_from_hash_source() -> None: + adapter = _make_adapter() + payload = _base_payload() + payload["Token"] = _build_notification_token(payload) + notification = TBankPaymentNotification(**payload) + + adapter.verify_payment_notification(notification) + + +def test_verify_payment_notification_excludes_nested_data_and_receipt() -> None: + adapter = _make_adapter() + notification = _signed_notification( + Data={"nested": "ignored"}, + Receipt={"Items": [{"Name": "Box", "Price": 125000}]}, + ) + + adapter.verify_payment_notification(notification) + + +def test_verify_payment_notification_includes_extra_scalar_fields() -> None: + adapter = _make_adapter() + notification = _signed_notification(CardId="card-id-1") + + adapter.verify_payment_notification(notification) + + payload_without_extra = notification.model_dump(mode="python") + del payload_without_extra["CardId"] + payload_without_extra["Token"] = _build_notification_token(payload_without_extra) + invalid_notification = TBankPaymentNotification( + **payload_without_extra, + CardId="card-id-1", + ) + + with pytest.raises(TBankPaymentNotificationTokenError): + adapter.verify_payment_notification(invalid_notification) + + +def test_verify_payment_notification_serializes_booleans_as_lowercase() -> None: + adapter = _make_adapter() + notification = _signed_notification(Success=False) + + adapter.verify_payment_notification(notification) + + +def test_verify_payment_notification_uses_deterministic_sha256_comparison() -> None: + payload = _base_payload() + expected_token = hashlib.sha256( + ( + "125000" + "0" + "order-uuid-1" + "test-password" + "8347568144" + "CONFIRMED" + "true" + "TestTerminal" + ).encode("utf-8") + ).hexdigest() + + assert _build_notification_token(payload) == expected_token diff --git a/tests/config/fixtures/config.default.yaml b/tests/config/fixtures/config.default.yaml index 2c77dd2..1bec7ce 100644 --- a/tests/config/fixtures/config.default.yaml +++ b/tests/config/fixtures/config.default.yaml @@ -17,7 +17,7 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://default.test/tbank/notification" + notification_url: "https://default.test/api/v1/delivery/tbank/notifications" success_url: "https://default.test/payment/success" auth: terminal_key: "yaml-terminal-key" diff --git a/tests/config/fixtures/config.invalid_price_multiplier.yaml b/tests/config/fixtures/config.invalid_price_multiplier.yaml index 01fee1c..f59208b 100644 --- a/tests/config/fixtures/config.invalid_price_multiplier.yaml +++ b/tests/config/fixtures/config.invalid_price_multiplier.yaml @@ -25,7 +25,7 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://merchant.test/tbank/notification" + notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications" success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" diff --git a/tests/config/fixtures/config.missing_adapter.yaml b/tests/config/fixtures/config.missing_adapter.yaml index b0c2b0f..4ff21ed 100644 --- a/tests/config/fixtures/config.missing_adapter.yaml +++ b/tests/config/fixtures/config.missing_adapter.yaml @@ -14,7 +14,7 @@ repository: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://merchant.test/tbank/notification" + notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications" success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" diff --git a/tests/config/fixtures/config.missing_address_suggestions.yaml b/tests/config/fixtures/config.missing_address_suggestions.yaml index abdb769..dd5b540 100644 --- a/tests/config/fixtures/config.missing_address_suggestions.yaml +++ b/tests/config/fixtures/config.missing_address_suggestions.yaml @@ -25,7 +25,7 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://merchant.test/tbank/notification" + notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications" success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" diff --git a/tests/config/fixtures/config.missing_observability.yaml b/tests/config/fixtures/config.missing_observability.yaml index 99ad8c8..8f2f193 100644 --- a/tests/config/fixtures/config.missing_observability.yaml +++ b/tests/config/fixtures/config.missing_observability.yaml @@ -25,7 +25,7 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://merchant.test/tbank/notification" + notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications" success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" diff --git a/tests/config/fixtures/config.missing_observability_endpoint.yaml b/tests/config/fixtures/config.missing_observability_endpoint.yaml index 458059d..04ab09f 100644 --- a/tests/config/fixtures/config.missing_observability_endpoint.yaml +++ b/tests/config/fixtures/config.missing_observability_endpoint.yaml @@ -25,7 +25,7 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://merchant.test/tbank/notification" + notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications" success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" diff --git a/tests/config/fixtures/config.missing_price_multiplier.yaml b/tests/config/fixtures/config.missing_price_multiplier.yaml index 1d037eb..4d20895 100644 --- a/tests/config/fixtures/config.missing_price_multiplier.yaml +++ b/tests/config/fixtures/config.missing_price_multiplier.yaml @@ -24,7 +24,7 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://merchant.test/tbank/notification" + notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications" success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" diff --git a/tests/config/fixtures/config.test.override.yaml b/tests/config/fixtures/config.test.override.yaml index 9ce56e7..7ca8ca4 100644 --- a/tests/config/fixtures/config.test.override.yaml +++ b/tests/config/fixtures/config.test.override.yaml @@ -17,7 +17,7 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://override.test/tbank/notification" + notification_url: "https://override.test/api/v1/delivery/tbank/notifications" success_url: "https://override.test/payment/success" auth: terminal_key: "override-terminal-key" diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index 1313ac0..91027b8 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -111,7 +111,7 @@ def _write_tbank_url_validation_config( include_success_url: bool = True, ) -> Path: notification_url = ( - ' notification_url: "https://merchant.test/tbank/notification"\n' + ' notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"\n' if include_notification_url else "" ) @@ -179,7 +179,7 @@ def test_configuration_sections_are_loaded_from_yaml_file( assert settings.tbank_payment.init_url == "https://securepay.tinkoff.ru/v2/Init" assert ( settings.tbank_payment.notification_url - == "https://merchant.test/tbank/notification" + == "https://merchant.test/api/v1/delivery/tbank/notifications" ) assert settings.tbank_payment.success_url == "https://merchant.test/payment/success" assert settings.tbank_payment.auth.terminal_key == "test-terminal-key" @@ -260,7 +260,7 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) - assert first.tbank_payment.auth.terminal_key == "test-terminal-key" assert ( first.tbank_payment.notification_url - == "https://merchant.test/tbank/notification" + == "https://merchant.test/api/v1/delivery/tbank/notifications" ) assert first.postgres.dsn.endswith("/g2s_aggregator_test") assert first.address_suggestions.country_to_provider["RU"] == "dadata" @@ -287,7 +287,7 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment( assert settings.tbank_payment.auth.password == "override-password" assert ( settings.tbank_payment.notification_url - == "https://override.test/tbank/notification" + == "https://override.test/api/v1/delivery/tbank/notifications" ) assert settings.tbank_payment.success_url == "https://override.test/payment/success" assert settings.tbank_payment.timeout_seconds == 4.25 diff --git a/tests/controllers/v1/test_tbank_notifications.py b/tests/controllers/v1/test_tbank_notifications.py new file mode 100644 index 0000000..7bdedef --- /dev/null +++ b/tests/controllers/v1/test_tbank_notifications.py @@ -0,0 +1,120 @@ +import asyncio + +import httpx + +from app.controllers.v1.delivery import get_aggregator_service +from app.main import create_app +from app.schemas.payment import TBankPaymentNotification +from app.services.aggregator import ( + InvalidTBankPaymentNotificationError, + TBankPaymentNotificationProcessingError, +) + + +class StubAggregatorService: + def __init__(self, *, response: str = "OK", error: Exception | None = None) -> None: + self._response = response + self._error = error + self.calls: list[TBankPaymentNotification] = [] + + async def handle_tbank_payment_notification( + self, + notification: TBankPaymentNotification, + ) -> str: + self.calls.append(notification) + if self._error is not None: + raise self._error + return self._response + + +def _install_service_override(app, service: StubAggregatorService) -> None: + async def override_service() -> StubAggregatorService: + return service + + app.dependency_overrides[get_aggregator_service] = override_service + + +def _valid_payload() -> dict[str, object]: + return { + "TerminalKey": "TestTerminal", + "OrderId": "order-uuid-1", + "Success": True, + "Status": "CONFIRMED", + "PaymentId": 8347568144, + "ErrorCode": "0", + "Amount": 125000, + "Token": "signed-token", + "Data": {"nested": "accepted by schema"}, + } + + +def _post_notification(app, payload: dict[str, object]) -> httpx.Response: + async def run_request() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + return await client.post( + "/api/v1/delivery/tbank/notifications", + json=payload, + ) + + return asyncio.run(run_request()) + + +def test_post_tbank_notification_returns_plain_text_ok_and_delegates_once() -> None: + service = StubAggregatorService(response="OK") + app = create_app() + _install_service_override(app, service) + + response = _post_notification(app, _valid_payload()) + + assert response.status_code == 200 + assert response.text == "OK" + assert response.headers["content-type"].startswith("text/plain") + assert service.calls == [TBankPaymentNotification(**_valid_payload())] + + +def test_post_tbank_notification_maps_invalid_token_to_400() -> None: + service = StubAggregatorService( + error=InvalidTBankPaymentNotificationError("invalid token") + ) + app = create_app() + _install_service_override(app, service) + + response = _post_notification(app, _valid_payload()) + + assert response.status_code == 400 + assert response.json()["detail"]["code"] == "invalid_tbank_payment_notification" + assert service.calls == [TBankPaymentNotification(**_valid_payload())] + + +def test_post_tbank_notification_maps_processing_failure_to_503() -> None: + service = StubAggregatorService( + error=TBankPaymentNotificationProcessingError("temporary failure") + ) + app = create_app() + _install_service_override(app, service) + + response = _post_notification(app, _valid_payload()) + + assert response.status_code == 503 + assert ( + response.json()["detail"]["code"] + == "tbank_payment_notification_processing_unavailable" + ) + assert service.calls == [TBankPaymentNotification(**_valid_payload())] + + +def test_post_tbank_notification_rejects_invalid_payload_without_service_call() -> None: + service = StubAggregatorService() + app = create_app() + _install_service_override(app, service) + payload = _valid_payload() + del payload["Token"] + + response = _post_notification(app, payload) + + assert response.status_code == 422 + assert service.calls == [] diff --git a/tests/domain/test_payment_notifications.py b/tests/domain/test_payment_notifications.py new file mode 100644 index 0000000..c655a3a --- /dev/null +++ b/tests/domain/test_payment_notifications.py @@ -0,0 +1,55 @@ +from app.domain.payment_notifications import ( + TBankPaymentNotificationAction, + resolve_tbank_payment_notification_action, +) + + +def test_confirmed_success_zero_error_code_registers_cdek_order() -> None: + result = resolve_tbank_payment_notification_action( + status="CONFIRMED", + success=True, + error_code="0", + ) + + assert result is TBankPaymentNotificationAction.REGISTER_CDEK_ORDER + + +def test_confirmed_without_success_acknowledges_only() -> None: + result = resolve_tbank_payment_notification_action( + status="CONFIRMED", + success=False, + error_code="0", + ) + + assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY + + +def test_confirmed_with_non_zero_error_code_acknowledges_only() -> None: + result = resolve_tbank_payment_notification_action( + status="CONFIRMED", + success=True, + error_code="101", + ) + + assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY + + +def test_known_non_confirmed_statuses_acknowledge_only() -> None: + for status in ("AUTHORIZED", "REJECTED", "CANCELED", "DEADLINE_EXPIRED"): + result = resolve_tbank_payment_notification_action( + status=status, + success=True, + error_code="0", + ) + + assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY + + +def test_unknown_status_acknowledges_only() -> None: + result = resolve_tbank_payment_notification_action( + status="UNKNOWN_STATUS", + success=True, + error_code="0", + ) + + assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY diff --git a/tests/repositories/cache/test_redis_cache.py b/tests/repositories/cache/test_redis_cache.py index 1eda307..8c95eb0 100644 --- a/tests/repositories/cache/test_redis_cache.py +++ b/tests/repositories/cache/test_redis_cache.py @@ -181,7 +181,7 @@ adapter: tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://merchant.test/tbank/notification" + notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications" success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" diff --git a/tests/repositories/order/test_repository.py b/tests/repositories/order/test_repository.py index 84867f4..a4d4545 100644 --- a/tests/repositories/order/test_repository.py +++ b/tests/repositories/order/test_repository.py @@ -101,7 +101,11 @@ def test_create_order_persists_all_required_fields() -> None: assert persisted_order.packages == order_data.packages assert persisted_order.services == order_data.services assert persisted_order.comment == "Test payment" + assert persisted_order.payment_status is None + assert persisted_order.tbank_payment_id is None + assert persisted_order.cdek_order_uuid is None assert persisted_order.created_at is not None + assert persisted_order.updated_at is not None asyncio.run(_with_repository(run)) @@ -143,3 +147,159 @@ def test_create_order_rejects_duplicate_order_uuid() -> None: await repository.create_order(session, order_data) asyncio.run(_with_repository(run)) + + +def test_get_order_by_order_uuid_returns_persisted_order() -> None: + async def run( + repository: OrderRepository, + _session_factory: async_sessionmaker[AsyncSession], + ) -> None: + order_data = _make_order_data() + + async with repository.session() as session: + await repository.create_order(session, order_data) + + async with repository.session() as session: + order = await repository.get_order_by_order_uuid(session, "order-uuid-1") + + assert order is not None + assert order.order_uuid == "order-uuid-1" + assert order.payment_url == "https://pay.test/payment/1" + + asyncio.run(_with_repository(run)) + + +def test_get_order_by_order_uuid_returns_none_for_missing_order() -> None: + async def run( + repository: OrderRepository, + _session_factory: async_sessionmaker[AsyncSession], + ) -> None: + async with repository.session() as session: + order = await repository.get_order_by_order_uuid(session, "missing-order") + + assert order is None + + asyncio.run(_with_repository(run)) + + +def test_mark_payment_status_persists_status_and_payment_id() -> None: + async def run( + repository: OrderRepository, + session_factory: async_sessionmaker[AsyncSession], + ) -> None: + async with repository.session() as session: + await repository.create_order(session, _make_order_data()) + + async with repository.session() as session: + order = await repository.mark_payment_status( + session, + "order-uuid-1", + "CONFIRMED", + 8347568144, + ) + + async with session_factory() as session: + result = await session.execute( + select(Order).where(Order.order_uuid == "order-uuid-1") + ) + persisted_order = result.scalar_one() + + assert order is not None + assert persisted_order.payment_status == "CONFIRMED" + assert persisted_order.tbank_payment_id == 8347568144 + + asyncio.run(_with_repository(run)) + + +def test_mark_payment_status_returns_none_for_missing_order() -> None: + async def run( + repository: OrderRepository, + _session_factory: async_sessionmaker[AsyncSession], + ) -> None: + async with repository.session() as session: + order = await repository.mark_payment_status( + session, + "missing-order", + "CONFIRMED", + 8347568144, + ) + + assert order is None + + asyncio.run(_with_repository(run)) + + +def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None: + async def run( + repository: OrderRepository, + session_factory: async_sessionmaker[AsyncSession], + ) -> None: + async with repository.session() as session: + await repository.create_order(session, _make_order_data()) + + async with repository.session() as session: + order = await repository.mark_cdek_order_registered( + session, + "order-uuid-1", + "cdek-order-uuid-1", + ) + + async with session_factory() as session: + result = await session.execute( + select(Order).where(Order.order_uuid == "order-uuid-1") + ) + persisted_order = result.scalar_one() + + assert order is not None + assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1" + + asyncio.run(_with_repository(run)) + + +def test_mark_cdek_order_registered_is_idempotent_for_same_uuid() -> None: + async def run( + repository: OrderRepository, + session_factory: async_sessionmaker[AsyncSession], + ) -> None: + async with repository.session() as session: + await repository.create_order(session, _make_order_data()) + + async with repository.session() as session: + await repository.mark_cdek_order_registered( + session, + "order-uuid-1", + "cdek-order-uuid-1", + ) + + async with repository.session() as session: + await repository.mark_cdek_order_registered( + session, + "order-uuid-1", + "cdek-order-uuid-1", + ) + + async with session_factory() as session: + result = await session.execute(select(Order)) + orders = result.scalars().all() + + assert len(orders) == 1 + assert orders[0].cdek_order_uuid == "cdek-order-uuid-1" + + asyncio.run(_with_repository(run)) + + +def test_mark_cdek_order_registered_returns_none_for_missing_order() -> None: + async def run( + repository: OrderRepository, + _session_factory: async_sessionmaker[AsyncSession], + ) -> None: + async with repository.session() as session: + order = await repository.mark_cdek_order_registered( + session, + "missing-order", + "cdek-order-uuid-1", + ) + + assert order is None + + asyncio.run(_with_repository(run)) diff --git a/tests/services/test_tbank_notifications.py b/tests/services/test_tbank_notifications.py new file mode 100644 index 0000000..961ab64 --- /dev/null +++ b/tests/services/test_tbank_notifications.py @@ -0,0 +1,342 @@ +import asyncio +from dataclasses import dataclass +from typing import Any + +import pytest + +from app.adapters.tbank.base import TBankPaymentNotificationTokenError +from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification +from app.services.aggregator import ( + AggregatorService, + InvalidTBankPaymentNotificationError, + TBankPaymentNotificationProcessingError, +) + + +@dataclass +class StoredOrder: + order_uuid: str = "order-uuid-1" + payment_url: str = "https://pay.test/payment/1" + price: int = 125000 + delivery_type: int = 2 + tariff_code: int = 535 + comment: str | None = "Test payment" + sender: dict[str, Any] | None = None + recipient: dict[str, Any] | None = None + from_location: dict[str, Any] | None = None + to_location: dict[str, Any] | None = None + packages: list[dict[str, Any]] | None = None + services: list[dict[str, Any]] | None = None + payment_status: str | None = None + tbank_payment_id: int | None = None + cdek_order_uuid: str | None = None + + def __post_init__(self) -> None: + if self.sender is None: + self.sender = { + "name": "Petr Petrov", + "email": "sender@example.com", + "phone": {"number": "+79009876543"}, + } + if self.recipient is None: + self.recipient = { + "name": "Ivan Ivanov", + "email": "ivan@example.com", + "phone": {"number": "+79001234567"}, + } + if self.from_location is None: + self.from_location = { + "address": "Lenina 1", + "city": "Moscow", + "country_code": "RU", + } + if self.to_location is None: + self.to_location = { + "address": "Pushkina 10", + "city": "Novosibirsk", + "country_code": "RU", + } + if self.packages is None: + self.packages = [ + { + "number": "1", + "weight": 1, + "length": 20, + "width": 15, + "height": 10, + "comment": "Package 1", + } + ] + if self.services is None: + self.services = [{"code": "INSURANCE", "parameter": "1000"}] + + +class StubPaymentAdapter: + def __init__(self, *, verify_error: Exception | None = None) -> None: + self._verify_error = verify_error + self.notifications: list[TBankPaymentNotification] = [] + + async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: + raise NotImplementedError + + def verify_payment_notification( + self, + notification: TBankPaymentNotification, + ) -> None: + self.notifications.append(notification) + if self._verify_error is not None: + raise self._verify_error + + +class StubOrderSessionContext: + def __init__(self, session: object) -> None: + self._session = session + + async def __aenter__(self) -> object: + return self._session + + async def __aexit__(self, exc_type, exc, traceback) -> None: + return None + + +class StubOrderRepository: + def __init__( + self, + *, + orders: list[StoredOrder] | None = None, + mark_cdek_errors: list[Exception | None] | None = None, + ) -> None: + self._orders = {order.order_uuid: order for order in orders or []} + self._mark_cdek_errors = mark_cdek_errors or [] + self.session_value = object() + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + def session(self) -> StubOrderSessionContext: + self.calls.append(("session", ())) + return StubOrderSessionContext(self.session_value) + + async def create_order(self, session: object, order_data: object) -> object: + raise NotImplementedError + + async def get_order_by_order_uuid( + self, + session: object, + order_uuid: str, + ) -> StoredOrder | None: + self.calls.append(("get_order_by_order_uuid", (session, order_uuid))) + return self._orders.get(order_uuid) + + async def mark_payment_status( + self, + session: object, + order_uuid: str, + status: str, + payment_id: int, + ) -> StoredOrder | None: + self.calls.append( + ("mark_payment_status", (session, order_uuid, status, payment_id)) + ) + order = self._orders.get(order_uuid) + if order is None: + return None + order.payment_status = status + order.tbank_payment_id = payment_id + return order + + async def mark_cdek_order_registered( + self, + session: object, + order_uuid: str, + cdek_order_uuid: str, + ) -> StoredOrder | None: + self.calls.append( + ("mark_cdek_order_registered", (session, order_uuid, cdek_order_uuid)) + ) + if self._mark_cdek_errors: + error = self._mark_cdek_errors.pop(0) + if error is not None: + raise error + + order = self._orders.get(order_uuid) + if order is None: + return None + order.cdek_order_uuid = cdek_order_uuid + return order + + +class StubCDEKOrderAdapter: + def __init__( + self, + *, + responses: list[str] | None = None, + error: Exception | None = None, + ) -> None: + self._responses = responses or ["cdek-order-uuid-1"] + self._error = error + self.calls: list[InitPaymentRequest] = [] + + async def register_order(self, request: InitPaymentRequest) -> str: + self.calls.append(request) + if self._error is not None: + raise self._error + return self._responses.pop(0) + + +def _make_notification(**overrides: object) -> TBankPaymentNotification: + payload: dict[str, object] = { + "TerminalKey": "TestTerminal", + "OrderId": "order-uuid-1", + "Success": True, + "Status": "CONFIRMED", + "PaymentId": 8347568144, + "ErrorCode": "0", + "Amount": 125000, + "Token": "signed-token", + } + payload.update(overrides) + return TBankPaymentNotification(**payload) + + +def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None: + order = StoredOrder() + payment_adapter = StubPaymentAdapter() + order_repository = StubOrderRepository(orders=[order]) + cdek_adapter = StubCDEKOrderAdapter(responses=["cdek-order-uuid-1"]) + service = AggregatorService( + providers=[], + payment_adapter=payment_adapter, + order_repository=order_repository, + order_registration_adapter=cdek_adapter, + ) + notification = _make_notification() + + result = asyncio.run(service.handle_tbank_payment_notification(notification)) + + assert result == "OK" + assert payment_adapter.notifications == [notification] + assert order.payment_status == "CONFIRMED" + assert order.tbank_payment_id == 8347568144 + assert order.cdek_order_uuid == "cdek-order-uuid-1" + assert len(cdek_adapter.calls) == 1 + assert cdek_adapter.calls[0].order_uuid == "order-uuid-1" + + +def test_duplicate_confirmed_notification_does_not_call_cdek() -> None: + order = StoredOrder(cdek_order_uuid="existing-cdek-order-uuid") + cdek_adapter = StubCDEKOrderAdapter() + service = AggregatorService( + providers=[], + payment_adapter=StubPaymentAdapter(), + order_repository=StubOrderRepository(orders=[order]), + order_registration_adapter=cdek_adapter, + ) + + result = asyncio.run( + service.handle_tbank_payment_notification(_make_notification()) + ) + + assert result == "OK" + assert cdek_adapter.calls == [] + assert order.cdek_order_uuid == "existing-cdek-order-uuid" + + +def test_non_confirmed_notification_acknowledges_without_cdek() -> None: + order = StoredOrder() + cdek_adapter = StubCDEKOrderAdapter() + service = AggregatorService( + providers=[], + payment_adapter=StubPaymentAdapter(), + order_repository=StubOrderRepository(orders=[order]), + order_registration_adapter=cdek_adapter, + ) + + result = asyncio.run( + service.handle_tbank_payment_notification( + _make_notification(Status="AUTHORIZED") + ) + ) + + assert result == "OK" + assert order.payment_status == "AUTHORIZED" + assert cdek_adapter.calls == [] + + +def test_invalid_token_does_not_access_repository_or_cdek() -> None: + order_repository = StubOrderRepository(orders=[StoredOrder()]) + cdek_adapter = StubCDEKOrderAdapter() + service = AggregatorService( + providers=[], + payment_adapter=StubPaymentAdapter( + verify_error=TBankPaymentNotificationTokenError("bad token") + ), + order_repository=order_repository, + order_registration_adapter=cdek_adapter, + ) + + with pytest.raises(InvalidTBankPaymentNotificationError): + asyncio.run(service.handle_tbank_payment_notification(_make_notification())) + + assert order_repository.calls == [] + assert cdek_adapter.calls == [] + + +def test_missing_order_returns_processing_error_without_cdek() -> None: + cdek_adapter = StubCDEKOrderAdapter() + service = AggregatorService( + providers=[], + payment_adapter=StubPaymentAdapter(), + order_repository=StubOrderRepository(), + order_registration_adapter=cdek_adapter, + ) + + with pytest.raises(TBankPaymentNotificationProcessingError): + asyncio.run(service.handle_tbank_payment_notification(_make_notification())) + + assert cdek_adapter.calls == [] + + +def test_cdek_registration_failure_returns_processing_error() -> None: + cdek_adapter = StubCDEKOrderAdapter(error=RuntimeError("cdek down")) + service = AggregatorService( + providers=[], + payment_adapter=StubPaymentAdapter(), + order_repository=StubOrderRepository(orders=[StoredOrder()]), + order_registration_adapter=cdek_adapter, + ) + + with pytest.raises(TBankPaymentNotificationProcessingError): + asyncio.run(service.handle_tbank_payment_notification(_make_notification())) + + assert len(cdek_adapter.calls) == 1 + + +def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id() -> None: + order = StoredOrder() + order_repository = StubOrderRepository( + orders=[order], + mark_cdek_errors=[RuntimeError("db down"), None], + ) + cdek_adapter = StubCDEKOrderAdapter( + responses=["same-cdek-order-uuid", "same-cdek-order-uuid"] + ) + service = AggregatorService( + providers=[], + payment_adapter=StubPaymentAdapter(), + order_repository=order_repository, + order_registration_adapter=cdek_adapter, + ) + notification = _make_notification() + + with pytest.raises(TBankPaymentNotificationProcessingError): + asyncio.run(service.handle_tbank_payment_notification(notification)) + + assert order.cdek_order_uuid is None + + result = asyncio.run(service.handle_tbank_payment_notification(notification)) + + assert result == "OK" + assert order.cdek_order_uuid == "same-cdek-order-uuid" + assert [request.order_uuid for request in cdek_adapter.calls] == [ + "order-uuid-1", + "order-uuid-1", + ] diff --git a/tests/smoke/test_app_import.py b/tests/smoke/test_app_import.py index a8a6bff..4d80bdc 100644 --- a/tests/smoke/test_app_import.py +++ b/tests/smoke/test_app_import.py @@ -35,6 +35,9 @@ def test_app_import_smoke(monkeypatch) -> None: assert isinstance(main_module.app, FastAPI) assert isinstance(main_module.app.state.settings, Settings) + assert "/api/v1/delivery/tbank/notifications" in { + route.path for route in main_module.app.routes + } assert logging_bootstrap_calls == [None] assert tracing_bootstrap_calls == [ (main_module.app, main_module.app.state.settings.observability)