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

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
@@ -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"),
)
@@ -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,
*,
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+3 -1
View File
@@ -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
+3 -2
View File
@@ -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**
+37 -2
View File
@@ -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`.
---
## Технологический стек
@@ -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`
@@ -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"
@@ -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,
@@ -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"
+6 -6
View File
@@ -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,
}
+135
View File
@@ -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
+1 -1
View File
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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"
+4 -4
View File
@@ -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
@@ -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 == []
@@ -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
+1 -1
View File
@@ -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"
+160
View File
@@ -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))
+342
View File
@@ -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",
]
+3
View File
@@ -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)