Добавлен адаптер к tbank и формирование ссылки на оплату

This commit is contained in:
Раис Юсупалиев
2026-04-12 18:56:41 +03:00
parent ab0b66e1c2
commit 2b201a08be
34 changed files with 1322 additions and 268 deletions
@@ -17,7 +17,7 @@ from app.adapters.delivery_providers.cdek.order_mapper import (
) )
from app.cities import cities_map from app.cities import cities_map
from app.config import AdapterConfig from app.config import AdapterConfig
from app.schemas.order import OrderCreateRequest, OrderCreateResponse from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -94,7 +94,7 @@ class CDEKClient:
raise CDEKClientError("CDEK tariff request failed unexpectedly.") raise CDEKClientError("CDEK tariff request failed unexpectedly.")
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse: async def register_order(self, request: InitPaymentRequest) -> str:
payload = map_cdek_order_request(request) payload = map_cdek_order_request(request)
for attempt in range(self._retry_attempts + 1): for attempt in range(self._retry_attempts + 1):
try: try:
@@ -241,5 +241,5 @@ class CDEKProvider(DeliveryProvider):
raw_payload = await self._client.get_raw_price(request) raw_payload = await self._client.get_raw_price(request)
return map_cdek_response(raw_payload) return map_cdek_response(raw_payload)
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse: async def register_order(self, request: InitPaymentRequest) -> str:
return await self._client.register_order(request) return await self._client.register_order(request)
@@ -2,11 +2,10 @@
from typing import Any from typing import Any
from app.schemas.order import ( from app.schemas.payment import (
OrderCreateRequest, InitPaymentRequest,
OrderCreateResponse, PaymentPackage,
OrderPackage, PaymentParty,
OrderParty,
) )
@@ -14,7 +13,7 @@ class CDEKOrderMappingError(ValueError):
"""Raised when CDEK order payload cannot be mapped.""" """Raised when CDEK order payload cannot be mapped."""
def map_cdek_order_request(request: OrderCreateRequest) -> dict[str, Any]: def map_cdek_order_request(request: InitPaymentRequest) -> dict[str, Any]:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"type": request.type, "type": request.type,
"tariff_code": request.tariff_code, "tariff_code": request.tariff_code,
@@ -33,7 +32,7 @@ def map_cdek_order_request(request: OrderCreateRequest) -> dict[str, Any]:
return payload return payload
def map_cdek_order_response(payload: dict[str, Any]) -> OrderCreateResponse: def map_cdek_order_response(payload: dict[str, Any]) -> str:
entity = payload.get("entity") entity = payload.get("entity")
if not isinstance(entity, dict): if not isinstance(entity, dict):
raise CDEKOrderMappingError("CDEK order response must include entity object.") raise CDEKOrderMappingError("CDEK order response must include entity object.")
@@ -42,10 +41,10 @@ def map_cdek_order_response(payload: dict[str, Any]) -> OrderCreateResponse:
if not isinstance(order_uuid, str) or not order_uuid: if not isinstance(order_uuid, str) or not order_uuid:
raise CDEKOrderMappingError("CDEK order response must include entity.uuid.") raise CDEKOrderMappingError("CDEK order response must include entity.uuid.")
return OrderCreateResponse(provider="cdek", order_uuid=order_uuid) return order_uuid
def _map_order_party(party: OrderParty) -> dict[str, Any]: def _map_order_party(party: PaymentParty) -> dict[str, Any]:
return { return {
"name": party.name, "name": party.name,
"email": party.email, "email": party.email,
@@ -53,7 +52,7 @@ def _map_order_party(party: OrderParty) -> dict[str, Any]:
} }
def _map_order_package(package: OrderPackage) -> dict[str, Any]: def _map_order_package(package: PaymentPackage) -> dict[str, Any]:
payload = package.model_dump(mode="python", exclude_none=True) payload = package.model_dump(mode="python", exclude_none=True)
payload["weight"] = package.weight * 1000 payload["weight"] = package.weight * 1000
return payload return payload
+5
View File
@@ -0,0 +1,5 @@
"""TBank payment adapter package."""
from app.adapters.tbank.client import TBankAdapter
__all__ = ["TBankAdapter"]
+9
View File
@@ -0,0 +1,9 @@
"""TBank payment adapter errors."""
class TBankPaymentAdapterError(RuntimeError):
"""Raised when a TBank payment adapter call fails."""
class TBankPaymentRequestError(TBankPaymentAdapterError):
"""Raised when TBank rejects payment request data."""
+157
View File
@@ -0,0 +1,157 @@
"""TBank payment adapter."""
import asyncio
from collections.abc import Awaitable, Callable
import hashlib
from typing import Any
import httpx
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentRequestError,
)
from app.config import TBankPaymentConfig
class TBankAdapter:
def __init__(
self,
http_client: httpx.AsyncClient,
*,
init_url: str,
terminal_key: str,
password: str,
timeout_seconds: float = 10.0,
retry_attempts: int = 2,
retry_backoff_seconds: float = 0.2,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> None:
self._http_client = http_client
self._init_url = init_url
self._terminal_key = terminal_key
self._password = password
self._timeout_seconds = timeout_seconds
self._retry_attempts = retry_attempts
self._retry_backoff_seconds = retry_backoff_seconds
self._sleep = sleep
@classmethod
def from_config(
cls,
*,
http_client: httpx.AsyncClient,
config: TBankPaymentConfig,
) -> "TBankAdapter":
return cls(
http_client=http_client,
init_url=config.init_url,
terminal_key=config.auth.terminal_key,
password=config.auth.password,
timeout_seconds=config.timeout_seconds,
retry_attempts=config.retry_attempts,
retry_backoff_seconds=config.retry_backoff_seconds,
)
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
payload = self._build_payload(
order_uuid=order_uuid,
amount_kopecks=amount_kopecks,
)
for attempt in range(self._retry_attempts + 1):
try:
response = await self._http_client.post(
self._init_url,
json=payload,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=self._timeout_seconds,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
raise TBankPaymentAdapterError(
"TBank payment init request failed after retry attempts."
) from exc
if self._should_retry(response.status_code):
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
raise TBankPaymentAdapterError(
"TBank payment init request failed with retriable status "
f"{response.status_code}."
)
if 400 <= response.status_code < 500:
raise TBankPaymentRequestError(
"TBank payment init request was rejected with status "
f"{response.status_code}."
)
try:
response.raise_for_status()
raw_payload = response.json()
except (httpx.HTTPError, TypeError, ValueError) as exc:
raise TBankPaymentAdapterError(
"TBank payment init returned invalid payload."
) from exc
return self._map_payment_url(raw_payload)
raise TBankPaymentAdapterError("TBank payment init request failed unexpectedly.")
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.")
if amount_kopecks <= 0:
raise TBankPaymentRequestError("TBank payment amount must be positive.")
payload: dict[str, Any] = {
"TerminalKey": self._terminal_key,
"Amount": amount_kopecks,
"OrderId": order_uuid,
}
payload["Token"] = _build_tbank_token(payload, password=self._password)
return payload
def _retry_delay(self, attempt: int) -> float:
return self._retry_backoff_seconds * (attempt + 1)
@staticmethod
def _should_retry(status_code: int) -> bool:
return status_code == 429 or status_code >= 500
@staticmethod
def _map_payment_url(payload: object) -> str:
if not isinstance(payload, dict):
raise TBankPaymentAdapterError(
"TBank payment init payload must be a JSON object."
)
if payload.get("Success") is False:
raise TBankPaymentRequestError("TBank payment init request was rejected.")
payment_url = payload.get("PaymentURL")
if not isinstance(payment_url, str) or not payment_url.strip():
raise TBankPaymentAdapterError(
"TBank payment init response must include PaymentURL."
)
return payment_url.strip()
def _build_tbank_token(payload: dict[str, Any], *, password: str) -> str:
token_payload = {
key: value
for key, value in payload.items()
if key != "Token" and not isinstance(value, (dict, list))
}
token_payload["Password"] = password
token_source = "".join(
str(token_payload[key]) for key in sorted(token_payload)
)
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
+15
View File
@@ -48,6 +48,19 @@ class AdapterConfig(BaseModel):
cdek_cache_ttl_seconds: int = Field(default=900, gt=0) cdek_cache_ttl_seconds: int = Field(default=900, gt=0)
class TBankPaymentAuthConfig(BaseModel):
terminal_key: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
class TBankPaymentConfig(BaseModel):
init_url: str = Field(..., min_length=1)
auth: TBankPaymentAuthConfig
timeout_seconds: float = Field(default=10.0, gt=0)
retry_attempts: int = Field(default=2, ge=0)
retry_backoff_seconds: float = Field(default=0.2, ge=0)
class DadataAddressSuggestionsConfig(BaseModel): class DadataAddressSuggestionsConfig(BaseModel):
url: str = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" url: str = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
api_key: str = "" api_key: str = ""
@@ -107,6 +120,7 @@ class Settings(BaseSettings):
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig) business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
repository: RepositoryConfig = Field(default_factory=RepositoryConfig) repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
adapter: AdapterConfig = Field(default_factory=AdapterConfig) adapter: AdapterConfig = Field(default_factory=AdapterConfig)
tbank_payment: TBankPaymentConfig
address_suggestions: AddressSuggestionsConfig = Field( address_suggestions: AddressSuggestionsConfig = Field(
default_factory=AddressSuggestionsConfig default_factory=AddressSuggestionsConfig
) )
@@ -138,6 +152,7 @@ class _RequiredYamlSections(BaseModel):
business_logic: dict[str, Any] business_logic: dict[str, Any]
repository: dict[str, Any] repository: dict[str, Any]
adapter: dict[str, Any] adapter: dict[str, Any]
tbank_payment: dict[str, Any]
address_suggestions: dict[str, Any] address_suggestions: dict[str, Any]
observability: dict[str, Any] observability: dict[str, Any]
+28 -14
View File
@@ -8,10 +8,11 @@ from app.adapters.address_suggestions.yandex_geosuggest import (
YandexGeosuggestAddressSuggestionProvider, YandexGeosuggestAddressSuggestionProvider,
) )
from app.adapters.delivery_providers.cdek import CDEKProvider from app.adapters.delivery_providers.cdek import CDEKProvider
from app.adapters.tbank import TBankAdapter
from app.config import Settings from app.config import Settings
from app.controllers.http_client import build_controller_http_client from app.controllers.http_client import build_controller_http_client
from app.repositories.cache.redis_cache import PriceCache from app.repositories.cache.redis_cache import PriceCache
from app.schemas.order import OrderCreateRequest, OrderCreateResponse from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import AddressSuggestion, DeliveryPrice from app.schemas.response import AddressSuggestion, DeliveryPrice
from app.services.aggregator import ( from app.services.aggregator import (
@@ -20,7 +21,8 @@ from app.services.aggregator import (
AggregatorServiceError, AggregatorServiceError,
InvalidAddressSuggestRequestError, InvalidAddressSuggestRequestError,
InvalidDeliveryRequestError, InvalidDeliveryRequestError,
InvalidOrderCreateRequestError, InvalidInitPaymentRequestError,
InitPaymentUnavailableError,
UnsupportedAddressSuggestionCountryError, UnsupportedAddressSuggestionCountryError,
) )
@@ -38,6 +40,10 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client=http_client, http_client=http_client,
adapter_config=settings.adapter, adapter_config=settings.adapter,
) )
payment_adapter = TBankAdapter.from_config(
http_client=http_client,
config=settings.tbank_payment,
)
dadata_provider = DadataAddressSuggestionProvider.from_config( dadata_provider = DadataAddressSuggestionProvider.from_config(
http_client=http_client, http_client=http_client,
config=settings.address_suggestions.dadata, config=settings.address_suggestions.dadata,
@@ -55,7 +61,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
service = AggregatorService( service = AggregatorService(
providers=providers, providers=providers,
cache=cache, cache=cache,
order_adapter=cdek_provider, payment_adapter=payment_adapter,
address_suggestion_providers=( address_suggestion_providers=(
dadata_provider, dadata_provider,
yandex_geosuggest_provider, yandex_geosuggest_provider,
@@ -146,28 +152,36 @@ async def suggest_addresses(
@router.post( @router.post(
"/order", "/init-payment",
response_model=OrderCreateResponse, response_model=InitPaymentResponse,
) )
async def create_delivery_order( async def init_payment(
order_request: OrderCreateRequest, payment_request: InitPaymentRequest,
service: AggregatorService = Depends(get_aggregator_service), service: AggregatorService = Depends(get_aggregator_service),
) -> OrderCreateResponse: ) -> InitPaymentResponse:
try: try:
return await service.create_order(order_request) return await service.init_payment(payment_request)
except InvalidOrderCreateRequestError as exc: except InvalidInitPaymentRequestError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail={ detail={
"code": "invalid_order_create_request", "code": "invalid_init_payment_request",
"message": "Order request contains invalid or unsupported CDEK data.", "message": "Payment request contains invalid or unsupported TBank data.",
},
) from exc
except InitPaymentUnavailableError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "init_payment_unavailable",
"message": "Payment initialization is temporarily unavailable.",
}, },
) from exc ) from exc
except AggregatorServiceError as exc: except AggregatorServiceError as exc:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={ detail={
"code": "order_creation_unavailable", "code": "init_payment_unavailable",
"message": "CDEK order creation is temporarily unavailable.", "message": "Payment initialization is temporarily unavailable.",
}, },
) from exc ) from exc
+18 -17
View File
@@ -1,20 +1,20 @@
"""Schemas for CDEK order registration.""" """Schemas for delivery payment initialization."""
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
class OrderPhone(BaseModel): class PaymentPhone(BaseModel):
number: str = Field(min_length=1) number: str = Field(min_length=1)
class OrderParty(BaseModel): class PaymentParty(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1) name: str = Field(min_length=1)
email: str = Field(min_length=1) email: str = Field(min_length=1)
phone: OrderPhone phone: PaymentPhone
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
@@ -24,18 +24,18 @@ class OrderParty(BaseModel):
return value return value
class OrderLocation(BaseModel): class DeliveryLocation(BaseModel):
address: str = Field(min_length=1) address: str = Field(min_length=1)
city: str = Field(min_length=1) city: str = Field(min_length=1)
country_code: str = Field(min_length=2, max_length=2) country_code: str = Field(min_length=2, max_length=2)
class OrderService(BaseModel): class PaymentService(BaseModel):
code: str = Field(min_length=1) code: str = Field(min_length=1)
parameter: str = Field(min_length=1) parameter: str = Field(min_length=1)
class OrderPackage(BaseModel): class PaymentPackage(BaseModel):
number: str = Field(min_length=1) number: str = Field(min_length=1)
weight: int = Field(gt=0) weight: int = Field(gt=0)
length: int = Field(gt=0) length: int = Field(gt=0)
@@ -44,18 +44,19 @@ class OrderPackage(BaseModel):
comment: str | None = None comment: str | None = None
class OrderCreateRequest(BaseModel): class InitPaymentRequest(BaseModel):
order_uuid: str = Field(min_length=1)
price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.")
type: Literal[2] type: Literal[2]
tariff_code: Literal[535] tariff_code: Literal[535]
comment: str | None = None comment: str | None = None
sender: OrderParty sender: PaymentParty
recipient: OrderParty recipient: PaymentParty
from_location: OrderLocation from_location: DeliveryLocation
to_location: OrderLocation to_location: DeliveryLocation
services: list[OrderService] | None = Field(default=None, min_length=1) services: list[PaymentService] | None = Field(default=None, min_length=1)
packages: list[OrderPackage] = Field(min_length=1) packages: list[PaymentPackage] = Field(min_length=1)
class OrderCreateResponse(BaseModel): class InitPaymentResponse(BaseModel):
provider: str = Field(min_length=1) payment_url: str = Field(min_length=1)
order_uuid: str = Field(min_length=1)
+32 -28
View File
@@ -13,6 +13,10 @@ from app.adapters.address_suggestions.base import (
AddressSuggestionRequestError, AddressSuggestionRequestError,
) )
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentRequestError,
)
from app.domain.price import ( from app.domain.price import (
DEFAULT_PROVIDER_PRICE_MULTIPLIER, DEFAULT_PROVIDER_PRICE_MULTIPLIER,
DEFAULT_WEIGHT_ROUND_SCALE, DEFAULT_WEIGHT_ROUND_SCALE,
@@ -20,7 +24,7 @@ from app.domain.price import (
filter_and_sort_prices, filter_and_sort_prices,
normalize_delivery_request, normalize_delivery_request,
) )
from app.schemas.order import OrderCreateRequest, OrderCreateResponse from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import AddressSuggestion, DeliveryPrice from app.schemas.response import AddressSuggestion, DeliveryPrice
@@ -45,12 +49,12 @@ class AddressSuggestionsUnavailableError(AggregatorServiceError):
"""Raised when address suggestions cannot be completed.""" """Raised when address suggestions cannot be completed."""
class InvalidOrderCreateRequestError(AggregatorServiceError): class InvalidInitPaymentRequestError(AggregatorServiceError):
"""Raised when provider rejects order creation payload as invalid.""" """Raised when provider rejects payment init payload as invalid."""
class OrderCreationUnavailableError(AggregatorServiceError): class InitPaymentUnavailableError(AggregatorServiceError):
"""Raised when order creation cannot be completed.""" """Raised when payment initialization cannot be completed."""
class PriceCacheProtocol(Protocol): class PriceCacheProtocol(Protocol):
@@ -59,6 +63,10 @@ class PriceCacheProtocol(Protocol):
async def set(self, key: str, value: object, ttl: int | None = None) -> None: ... async def set(self, key: str, value: object, ttl: int | None = None) -> None: ...
class PaymentAdapterProtocol(Protocol):
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: ...
class FilterAndSortPricesFn(Protocol): class FilterAndSortPricesFn(Protocol):
def __call__( def __call__(
self, self,
@@ -69,18 +77,12 @@ class FilterAndSortPricesFn(Protocol):
) -> list[object]: ... ) -> list[object]: ...
class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(
self, request: OrderCreateRequest
) -> OrderCreateResponse: ...
class AggregatorService: class AggregatorService:
def __init__( def __init__(
self, self,
providers: Sequence[DeliveryProvider], providers: Sequence[DeliveryProvider],
cache: PriceCacheProtocol | None = None, cache: PriceCacheProtocol | None = None,
order_adapter: OrderRegistrationAdapterProtocol | None = None, payment_adapter: PaymentAdapterProtocol | None = None,
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (), address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
address_suggestion_country_to_provider: Mapping[str, str] | None = None, address_suggestion_country_to_provider: Mapping[str, str] | None = None,
*, *,
@@ -90,7 +92,7 @@ class AggregatorService:
) -> None: ) -> None:
self._providers = tuple(providers) self._providers = tuple(providers)
self._cache = cache self._cache = cache
self._order_adapter = order_adapter self._payment_adapter = payment_adapter
self._weight_round_scale = weight_round_scale self._weight_round_scale = weight_round_scale
self._provider_price_multiplier = provider_price_multiplier self._provider_price_multiplier = provider_price_multiplier
self._filter_and_sort_prices = filter_and_sort_prices_fn self._filter_and_sort_prices = filter_and_sort_prices_fn
@@ -168,27 +170,29 @@ class AggregatorService:
return [self._coerce_address_suggestion(item) for item in suggestions] return [self._coerce_address_suggestion(item) for item in suggestions]
async def create_order(self, request: OrderCreateRequest) -> OrderCreateResponse: async def init_payment(self, request: InitPaymentRequest) -> InitPaymentResponse:
if self._order_adapter is None: if self._payment_adapter is None:
raise OrderCreationUnavailableError( raise InitPaymentUnavailableError("Payment adapter is not configured.")
"Order registration adapter is not configured."
)
try: try:
created_order = await self._order_adapter.register_order(request) payment_url = await self._payment_adapter.create_payment_link(
except ProviderRequestError as exc: order_uuid=request.order_uuid,
raise InvalidOrderCreateRequestError( amount_kopecks=request.price,
"Order request is invalid for the configured provider." )
except TBankPaymentRequestError as exc:
raise InvalidInitPaymentRequestError(
"Payment init request is invalid for the configured provider."
) from exc
except TBankPaymentAdapterError as exc:
raise InitPaymentUnavailableError(
"Payment initialization is temporarily unavailable."
) from exc ) from exc
except Exception as exc: except Exception as exc:
raise OrderCreationUnavailableError( raise InitPaymentUnavailableError(
"Order creation is temporarily unavailable." "Payment initialization is temporarily unavailable."
) from exc ) from exc
return OrderCreateResponse.model_validate( return InitPaymentResponse(payment_url=payment_url)
created_order,
from_attributes=True,
)
async def _get_provider_prices( async def _get_provider_prices(
self, self,
+9
View File
@@ -23,6 +23,15 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "change-me-terminal-key"
password: "change-me-password"
timeout_seconds: 10.0
retry_attempts: 2
retry_backoff_seconds: 0.2
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
+9
View File
@@ -23,6 +23,15 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
timeout_seconds: 10.0
retry_attempts: 2
retry_backoff_seconds: 0.2
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
app: app:
image: yusupal1ev/g2s-aggregator:0.0.0 image: yusupal1ev/g2s-aggregator:0.0.2
container_name: g2s-aggregator container_name: g2s-aggregator
ports: ports:
- "8000:8000" - "8000:8000"
+6 -5
View File
@@ -12,15 +12,16 @@ grant_type=client_credentials&client_id={{client_id}}&client_secret={{client_sec
GET {{base_url}}/v2/orders?cdek_number=10240410458 GET {{base_url}}/v2/orders?cdek_number=10240410458
Authorization: Bearer {{auth_token}} Authorization: Bearer {{auth_token}}
### 3. Регистрация заказа (тип "доставка", до двери) ### 3. Инициализация оплаты доставки
POST {{base_url}}/v2/orders POST http://localhost:8000/api/v1/delivery/init-payment
Authorization: Bearer {{auth_token}}
Content-Type: application/json Content-Type: application/json
{ {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2, "type": 2,
"tariff_code": 535, "tariff_code": 535,
"comment": "Тестовый заказ", "comment": "Тестовая оплата доставки",
"sender": { "sender": {
"name": "Петр Петров", "name": "Петр Петров",
"email": "sender@example.com", "email": "sender@example.com",
@@ -58,7 +59,7 @@ Content-Type: application/json
} }
> {% > {%
client.global.set("order_uuid", response.body.entity.uuid); client.global.set("payment_url", response.body.payment_url);
%} %}
### 5. Список доступных тарифов ### 5. Список доступных тарифов
Generated
+379 -1
View File
@@ -18,6 +18,25 @@ typing-extensions = "*"
[package.extras] [package.extras]
hiredis = ["hiredis (>=1.0)"] hiredis = ["hiredis (>=1.0)"]
[[package]]
name = "alembic"
version = "1.18.4"
description = "A database migration tool for SQLAlchemy."
optional = false
python-versions = ">=3.10"
files = [
{file = "alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"},
{file = "alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc"},
]
[package.dependencies]
Mako = "*"
SQLAlchemy = ">=1.4.23"
typing-extensions = ">=4.12"
[package.extras]
tz = ["tzdata"]
[[package]] [[package]]
name = "annotated-doc" name = "annotated-doc"
version = "0.0.4" version = "0.0.4"
@@ -82,6 +101,75 @@ files = [
{file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"},
] ]
[[package]]
name = "asyncpg"
version = "0.31.0"
description = "An asyncio PostgreSQL driver"
optional = false
python-versions = ">=3.9.0"
files = [
{file = "asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61"},
{file = "asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be"},
{file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8"},
{file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1"},
{file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3"},
{file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8"},
{file = "asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095"},
{file = "asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540"},
{file = "asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d"},
{file = "asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab"},
{file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c"},
{file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109"},
{file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da"},
{file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9"},
{file = "asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24"},
{file = "asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047"},
{file = "asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad"},
{file = "asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d"},
{file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a"},
{file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671"},
{file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec"},
{file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20"},
{file = "asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8"},
{file = "asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186"},
{file = "asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b"},
{file = "asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e"},
{file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403"},
{file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4"},
{file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2"},
{file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602"},
{file = "asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696"},
{file = "asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab"},
{file = "asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44"},
{file = "asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5"},
{file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2"},
{file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2"},
{file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218"},
{file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d"},
{file = "asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b"},
{file = "asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be"},
{file = "asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2"},
{file = "asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31"},
{file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7"},
{file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e"},
{file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c"},
{file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a"},
{file = "asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d"},
{file = "asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3"},
{file = "asyncpg-0.31.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ebb3cde58321a1f89ce41812be3f2a98dddedc1e76d0838aba1d724f1e4e1a95"},
{file = "asyncpg-0.31.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6974f36eb9a224d8fb428bcf66bd411aa12cf57c2967463178149e73d4de366"},
{file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2b685f400ceae428f79f78b58110470d7b4466929a7f78d455964b17ad1008"},
{file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb223567dea5f47c45d347f2bde5486be8d9f40339f27217adb3fb1c3be51298"},
{file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:22be6e02381bab3101cd502d9297ac71e2f966c86e20e78caead9934c98a8af6"},
{file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37a58919cfef2448a920df00d1b2f821762d17194d0dbf355d6dde8d952c04f9"},
{file = "asyncpg-0.31.0-cp39-cp39-win32.whl", hash = "sha256:c1a9c5b71d2371a2290bc93336cd05ba4ec781683cab292adbddc084f89443c6"},
{file = "asyncpg-0.31.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1e1ab5bc65373d92dd749d7308c5b26fb2dc0fbe5d3bf68a32b676aa3bcd24a"},
{file = "asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735"},
]
[package.extras]
gssauth = ["gssapi", "sspilib"]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" version = "2026.2.25"
@@ -280,6 +368,78 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4
[package.extras] [package.extras]
grpc = ["grpcio (>=1.44.0,<2.0.0)"] grpc = ["grpcio (>=1.44.0,<2.0.0)"]
[[package]]
name = "greenlet"
version = "3.4.0"
description = "Lightweight in-process concurrent programming"
optional = false
python-versions = ">=3.10"
files = [
{file = "greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398"},
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb"},
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b"},
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf"},
{file = "greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab"},
{file = "greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83"},
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81"},
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2"},
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71"},
{file = "greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711"},
{file = "greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267"},
{file = "greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc"},
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077"},
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de"},
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08"},
{file = "greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2"},
{file = "greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e"},
{file = "greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf"},
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55"},
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729"},
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c"},
{file = "greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940"},
{file = "greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a"},
{file = "greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf"},
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd"},
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf"},
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda"},
{file = "greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d"},
{file = "greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802"},
{file = "greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed"},
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72"},
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f"},
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a"},
{file = "greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705"},
{file = "greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff"},
]
[package.extras]
docs = ["Sphinx", "furo"]
test = ["objgraph", "psutil", "setuptools"]
[[package]] [[package]]
name = "grpcio" name = "grpcio"
version = "1.78.0" version = "1.78.0"
@@ -512,6 +672,123 @@ files = [
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
] ]
[[package]]
name = "mako"
version = "1.3.10"
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
optional = false
python-versions = ">=3.8"
files = [
{file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"},
{file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"},
]
[package.dependencies]
MarkupSafe = ">=0.9.2"
[package.extras]
babel = ["Babel"]
lingua = ["lingua"]
testing = ["pytest"]
[[package]]
name = "markupsafe"
version = "3.0.3"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.9"
files = [
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"},
{file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"},
{file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"},
{file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"},
{file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"},
{file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"},
{file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"},
{file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"},
{file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"},
{file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"},
{file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"},
{file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"},
{file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"},
{file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"},
{file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"},
{file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"},
{file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"},
{file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"},
{file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"},
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"},
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"},
{file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"},
{file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"},
{file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"},
{file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"},
{file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"},
{file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"},
{file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"},
{file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"},
{file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"},
{file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"},
{file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"},
{file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"},
{file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"},
]
[[package]] [[package]]
name = "opentelemetry-api" name = "opentelemetry-api"
version = "1.40.0" version = "1.40.0"
@@ -1151,6 +1428,107 @@ urllib3 = ">=1.21.1,<3"
socks = ["PySocks (>=1.5.6,!=1.5.7)"] socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "sqlalchemy"
version = "2.0.49"
description = "Database Abstraction Library"
optional = false
python-versions = ">=3.7"
files = [
{file = "sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f"},
{file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b"},
{file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1"},
{file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339"},
{file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d"},
{file = "sqlalchemy-2.0.49-cp310-cp310-win32.whl", hash = "sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3"},
{file = "sqlalchemy-2.0.49-cp310-cp310-win_amd64.whl", hash = "sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75"},
{file = "sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe"},
{file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014"},
{file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536"},
{file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88"},
{file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700"},
{file = "sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a"},
{file = "sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af"},
{file = "sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b"},
{file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982"},
{file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672"},
{file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e"},
{file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750"},
{file = "sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0"},
{file = "sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4"},
{file = "sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120"},
{file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2"},
{file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3"},
{file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7"},
{file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33"},
{file = "sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b"},
{file = "sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d"},
{file = "sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba"},
{file = "sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e"},
{file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a"},
{file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066"},
{file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187"},
{file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401"},
{file = "sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5"},
{file = "sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158"},
{file = "sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7"},
{file = "sqlalchemy-2.0.49-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a"},
{file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a"},
{file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b"},
{file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4"},
{file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72"},
{file = "sqlalchemy-2.0.49-cp38-cp38-win32.whl", hash = "sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662"},
{file = "sqlalchemy-2.0.49-cp38-cp38-win_amd64.whl", hash = "sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f"},
{file = "sqlalchemy-2.0.49-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f"},
{file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613"},
{file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1"},
{file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe"},
{file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717"},
{file = "sqlalchemy-2.0.49-cp39-cp39-win32.whl", hash = "sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3"},
{file = "sqlalchemy-2.0.49-cp39-cp39-win_amd64.whl", hash = "sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f"},
{file = "sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0"},
{file = "sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f"},
]
[package.dependencies]
greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""}
typing-extensions = ">=4.6.0"
[package.extras]
aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"]
aioodbc = ["aioodbc", "greenlet (>=1)"]
aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"]
asyncio = ["greenlet (>=1)"]
asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"]
mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"]
mssql = ["pyodbc"]
mssql-pymssql = ["pymssql"]
mssql-pyodbc = ["pyodbc"]
mypy = ["mypy (>=0.910)"]
mysql = ["mysqlclient (>=1.4.0)"]
mysql-connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=8)"]
oracle-oracledb = ["oracledb (>=1.0.1)"]
postgresql = ["psycopg2 (>=2.7)"]
postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"]
postgresql-pg8000 = ["pg8000 (>=1.29.1)"]
postgresql-psycopg = ["psycopg (>=3.0.7)"]
postgresql-psycopg2binary = ["psycopg2-binary"]
postgresql-psycopg2cffi = ["psycopg2cffi"]
postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
pymysql = ["pymysql"]
sqlcipher = ["sqlcipher3_binary"]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "0.52.1" version = "0.52.1"
@@ -1612,4 +1990,4 @@ type = ["pytest-mypy"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.14" python-versions = "^3.14"
content-hash = "3156d28f769918f0fc547653c11a84e33728d9abed9e7be1a885aa7ae512d6dd" content-hash = "a6d02bc869931fda634adb1c542cf314710f59e72d9962cb54b55571a42be2f2"
+3
View File
@@ -22,6 +22,9 @@ opentelemetry-instrumentation-httpx = "^0.61b0"
opentelemetry-instrumentation-redis = "^0.61b0" opentelemetry-instrumentation-redis = "^0.61b0"
pytest = "^9.0.2" pytest = "^9.0.2"
redis = "^7.3.0" redis = "^7.3.0"
sqlalchemy = "^2.0.49"
asyncpg = "^0.31.0"
alembic = "^1.18.4"
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] pythonpath = ["."]
+3 -2
View File
@@ -33,9 +33,10 @@
| 024 | DONE | 2026-03-29 | Add TomTom address suggestion adapter and Europe routing | `spec/tasks/024_add_tomtom_address_suggestion_adapter.md` | | 024 | DONE | 2026-03-29 | Add TomTom address suggestion adapter and Europe routing | `spec/tasks/024_add_tomtom_address_suggestion_adapter.md` |
| 025 | DONE | 2026-04-03 | Remove company from Create Delivery Order parties | `spec/tasks/025_remove_company_from_create_delivery_order.md` | | 025 | DONE | 2026-04-03 | Remove company from Create Delivery Order parties | `spec/tasks/025_remove_company_from_create_delivery_order.md` |
| 026 | DONE | 2026-04-05 | Align CDEK order contract with single phone and kilogram package weight | `spec/tasks/026_align_cdek_order_contract_single_phone_and_weight_units.md` | | 026 | DONE | 2026-04-05 | Align CDEK order contract with single phone and kilogram package weight | `spec/tasks/026_align_cdek_order_contract_single_phone_and_weight_units.md` |
| 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` |
## Summary ## Summary
- Total: **27** - Total: **28**
- TODO: **0** - TODO: **0**
- DONE: **27** - DONE: **28**
+33 -20
View File
@@ -17,8 +17,9 @@
- Принимать запрос на расчёт стоимости доставки (идентификаторы городов отправления/назначения, вес, габариты) - Принимать запрос на расчёт стоимости доставки (идентификаторы городов отправления/назначения, вес, габариты)
- Поддерживать необязательный параметр `parcel_type` в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления - Поддерживать необязательный параметр `parcel_type` в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления
- Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед созданием заказа - Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед инициализацией оплаты доставки
- Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери" - Принимать запрос на инициализацию оплаты доставки через TBank и возвращать ссылку на оплату без регистрации заказа в CDEK
- Принимать сумму оплаты в поле `price` в копейках
- Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге - Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге
- Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru` - Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru`
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest - Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest
@@ -38,8 +39,11 @@
### Фаза 1 ### Фаза 1
- **CDEK** — https://apidoc.cdek.ru/ - **CDEK** — https://apidoc.cdek.ru/
- Аутентификация: OAuth2 (client credentials) - Аутентификация: OAuth2 (client credentials)
- Операции: расчёт тарифа, регистрация заказа - Операции: расчёт тарифа
- Cache TTL: 15 минут - Cache TTL: 15 минут
- **TBank** — https://securepay.tinkoff.ru/v2/Init
- Аутентификация: `TerminalKey` и token на основе password
- Операции: инициализация платежа и получение payment URL
### Фаза 2+ ### Фаза 2+
- Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации - Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации
@@ -51,10 +55,10 @@
### Controller (`app/controllers/v1/delivery.py`) ### Controller (`app/controllers/v1/delivery.py`)
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]` - `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
- `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]` - `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]`
- `POST /api/v1/delivery/order` — принимает `OrderCreateRequest`, возвращает `OrderCreateResponse` - `POST /api/v1/delivery/init-payment` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse`
- Парсинг и валидация входных данных через Pydantic - Парсинг и валидация входных данных через Pydantic
- Маппинг исключений сервиса в HTTP-ответы - Маппинг исключений сервиса в HTTP-ответы
- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()` или `AggregatorService.create_order()` - Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()` или `AggregatorService.init_payment()`
### Service (`app/services/aggregator.py`) ### Service (`app/services/aggregator.py`)
- `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]` - `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]`
@@ -64,8 +68,8 @@
- Сортирует тарифы по цене - Сортирует тарифы по цене
- `AggregatorService.suggest_addresses(request: AddressSuggestRequest) -> list[AddressSuggestion]` - `AggregatorService.suggest_addresses(request: AddressSuggestRequest) -> list[AddressSuggestion]`
- `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call - `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call
- `AggregatorService.create_order(request: OrderCreateRequest) -> OrderCreateResponse` - `AggregatorService.init_payment(request: InitPaymentRequest) -> InitPaymentResponse`
- `AggregatorService.create_order()` оркестрирует регистрацию заказа в CDEK через injected adapter dependency - `AggregatorService.init_payment()` оркестрирует инициализацию платежа через injected TBank adapter dependency
- Service не содержит бизнес-логики и provider HTTP-деталей - Service не содержит бизнес-логики и provider HTTP-деталей
### Business Logic (`app/domain/`) ### Business Logic (`app/domain/`)
@@ -92,14 +96,18 @@
- `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с) - `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с)
- `cdek/auth.py` — управление OAuth2-токеном - `cdek/auth.py` — управление OAuth2-токеном
- `cdek/mapper.py` — ответ CDEK → `list[DeliveryPrice]` - `cdek/mapper.py` — ответ CDEK → `list[DeliveryPrice]`
- `cdek/order_mapper.py` — request/response mapping для регистрации заказа CDEK - `cdek/order_mapper.py` — request/response mapping для CDEK order contract; публичный `init-payment` flow не вызывает регистрацию заказа в CDEK
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case - Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case
- Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API - Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API
Для сценария создания заказа CDEK adapter принимает валидированную order model, отправляет контракт `Регистрация заказа (тип "доставка", до двери)` из `http-client.http` и возвращает внутреннюю response model без утечки HTTP-деталей в Service. Для CDEK order contract mapper принимает `InitPaymentRequest`, сериализует `sender.phone` и `recipient.phone` в provider payload `phones` с одним элементом, не отправляет `services` при отсутствии значения, конвертирует `packages[*].weight` из килограммов в граммы и возвращает `entity.uuid` строкой.
Во внутреннем order flow `sender` и `recipient` содержат ровно одно поле `phone`, а CDEK adapter сериализует его в provider payload `phones` с одним элементом.
Поле `services` в order flow является необязательным; при отсутствии значения adapter не отправляет `services` в CDEK payload. ### Adapter (`app/adapters/tbank`)
Поле `packages[*].weight` во входном order request задаётся в килограммах, а CDEK adapter конвертирует его в граммы перед отправкой в provider API. - `base.py` — исключения TBank payment adapter
- `client.py` — `TBankAdapter`
- `TBankAdapter.create_payment_link(order_uuid: str, amount_kopecks: int) -> str` инициализирует платёж TBank и возвращает payment URL
- 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`
### Adapter (`app/adapters/address_suggestions`) ### Adapter (`app/adapters/address_suggestions`)
- `base.py` — абстрактный интерфейс `AddressSuggestionProvider`: - `base.py` — абстрактный интерфейс `AddressSuggestionProvider`:
@@ -168,8 +176,10 @@ flat: str | None
postal_code: str | None postal_code: str | None
``` ```
### Входная: `OrderCreateRequest` ### Входная: `InitPaymentRequest`
``` ```
order_uuid: str
price: int
type: Literal[2] type: Literal[2]
tariff_code: Literal[535] tariff_code: Literal[535]
comment: str | None comment: str | None
@@ -193,14 +203,14 @@ services: list[{code: str, parameter: str}] | None
packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}] packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}]
``` ```
`from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; order flow не выполняет address suggestion lookup. `price` задаётся в копейках, является обязательным целым числом и должен быть больше 0.
`sender.phone` и `recipient.phone` представляют единственный телефон для соответствующей стороны заказа; передача нескольких телефонов во входном API не поддерживается. `from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; payment flow не выполняет address suggestion lookup.
`packages[*].weight` в `OrderCreateRequest` задаётся в килограммах, а в payload CDEK должен передаваться в граммах. `sender.phone` и `recipient.phone` представляют единственный телефон для соответствующей стороны; передача нескольких телефонов во входном API не поддерживается.
`packages[*].weight` в `InitPaymentRequest` задаётся в килограммах; CDEK order mapper конвертирует его в граммы для provider payload.
### Выходная: `OrderCreateResponse` ### Выходная: `InitPaymentResponse`
``` ```
provider: str payment_url: str
order_uuid: str
``` ```
--- ---
@@ -246,6 +256,9 @@ app/
│ ├── auth.py │ ├── auth.py
│ ├── mapper.py │ ├── mapper.py
│ └── order_mapper.py │ └── order_mapper.py
│ └── tbank/
│ ├── base.py
│ └── client.py
├── repositories/ ├── repositories/
│ └── cache/ │ └── cache/
│ └── redis_cache.py # Repository │ └── redis_cache.py # Repository
@@ -253,7 +266,7 @@ app/
│ ├── address.py │ ├── address.py
│ ├── request.py │ ├── request.py
│ ├── response.py │ ├── response.py
│ └── order.py │ └── payment.py
└── config.py └── config.py
``` ```
@@ -0,0 +1,72 @@
---
id: 027
title: Add TBank payment adapter, init_payment endpoint and rename order flow
status: DONE
created: 2026-04-11
---
## Context
В проекте ещё нет платёжного адаптера, каталог `app/adapters/` содержит только `delivery_providers/` и `address_suggestions/`. Текущий endpoint `POST /api/v1/delivery/order` регистрирует заказ в CDEK. В новом флоу endpoint будет инициировать оплату через TBank и возвращать ссылку на оплату, без регистрации заказа в CDEK. В связи с изменением бизнес-смысла необходимо переименовать endpoint, сервисный метод, все DTO и модели флоу.
## Goal
1. Добавить новый payment adapter `TBankAdapter` для генерации ссылки на оплату TBank.
2. Переименовать endpoint `POST /api/v1/delivery/order``POST /api/v1/delivery/init-payment`.
3. Переименовать сервисный метод `AggregatorService.create_order()``AggregatorService.init_payment()`.
4. Переименовать все DTO и модели флоу: `OrderCreateRequest``InitPaymentRequest`, `OrderCreateResponse``InitPaymentResponse`, `InvalidOrderCreateRequestError``InvalidInitPaymentRequestError`, `OrderCreationUnavailableError``InitPaymentUnavailableError`.
5. Переименовать файл схем `app/schemas/order.py``app/schemas/payment.py`.
6. Добавить в `InitPaymentRequest` обязательное поле `price: int` (в копейках) и вызвать `payment_adapter.create_payment_link()` из `init_payment()`, вернув `payment_url` в `InitPaymentResponse`.
## Constraints
- Новый payment adapter должен располагаться в отдельном каталоге `app/adapters/tbank/` с модулями `base.py` и `client.py`; не размещать payment logic внутри `delivery_providers/` или `address_suggestions/`.
- Payment adapter MUST инкапсулировать HTTP взаимодействие с TBank API, auth, retries, serialization и error handling; наружу должен экспонироваться только service-facing method (`create_payment_link(order_uuid: str, amount_kopecks: int) -> str`), без утечки HTTP деталей в Service.
- Payment adapter MUST владеть собственной секцией конфигурации (`TBankPaymentConfig`) с обязательными полями auth и URL; конфигурация читается из `config.yaml`, который остаётся в `.gitignore`.
- `InitPaymentRequest` MUST содержать обязательное поле `price: int` в копейках с валидацией `gt=0`; единица измерения явно зафиксирована в schema и в `spec/overview.md`.
- `AggregatorService.init_payment()` MUST вызывать `payment_adapter.create_payment_link()`. Payment adapter передаётся в service через dependency injection (новый аргумент конструктора).
- Ошибки payment adapter MUST маппиться в `InvalidInitPaymentRequestError` (→ 400) и `InitPaymentUnavailableError` (→ 503) на уровне controller.
- Controller `POST /api/v1/delivery/init-payment` MUST вызывать ровно один метод Service (`AggregatorService.init_payment()`); старый endpoint `/order` удаляется, новые дополнительные endpoints запрещены.
- Service слой не содержит HTTP, retries или TBank-специфичных деталей.
- Scope задачи не включает: повторные попытки оплаты, refund flow, webhook обработку платёжных уведомлений, persistence заказов, изменения price flow, address suggestion flow, а также поддержку других платёжных провайдеров.
- Не изменять файлы в `spec/` кроме создания этой задачи и обновления `spec/overview.md` при необходимости (обновление выполняется Planner).
## Acceptance criteria
- В `app/adapters/tbank/` существуют `base.py` с исключениями адаптера и `client.py` с реализацией `TBankAdapter`.
- `TBankAdapter` реализует async метод `create_payment_link(order_uuid: str, amount_kopecks: int) -> str`, принимает идентификатор заказа и сумму в копейках и возвращает URL.
- `TBankAdapter` имеет собственную конфигурацию `TBankPaymentConfig` с обязательными полями auth и URL; конфигурация читается из yaml.
- Файл `app/schemas/order.py` переименован в `app/schemas/payment.py`; все импорты обновлены.
- `InitPaymentRequest` (бывший `OrderCreateRequest`) содержит обязательное поле `price: int` (в копейках) с валидацией `gt=0`; запрос без `price` или с нецелым/отрицательным значением возвращает 422.
- `InitPaymentResponse` (бывший `OrderCreateResponse`) содержит поле `payment_url: str` (минимум 1 символ) и возвращается из endpoint `POST /api/v1/delivery/init-payment`.
- `AggregatorService.init_payment()` (бывший `create_order()`) принимает `InitPaymentRequest`, вызывает `payment_adapter.create_payment_link()` с `order_uuid` и `price`, и возвращает `InitPaymentResponse` с `payment_url`.
- Сервисные исключения переименованы: `InvalidInitPaymentRequestError` (бывший `InvalidOrderCreateRequestError`), `InitPaymentUnavailableError` (бывший `OrderCreationUnavailableError`).
- Endpoint `POST /api/v1/delivery/order` удалён; endpoint `POST /api/v1/delivery/init-payment` возвращает `InitPaymentResponse`.
- Ошибки TBank provider request мапятся в 400, transport/недоступность — в 503 на уровне controller.
- Controller продолжает вызывать ровно один метод Service.
- Пример запроса в `http-client.http` обновлён: использует `/init-payment`, содержит поле `price` в копейках и отражает обновлённый response contract.
- `config.yaml` пример (в `.gitignore`) содержит секцию с параметрами TBank adapter.
## Definition of Done
- [ ] Создан модуль `app/adapters/tbank/` с `base.py` и `client.py`, реализующий `TBankAdapter`.
- [ ] Добавлена конфигурация TBank adapter (`TBankPaymentConfig`) в `app/config.py` и соответствующий пример в `config.yaml`.
- [ ] `app/schemas/order.py` переименован в `app/schemas/payment.py`; все импорты в контроллере, сервисе и тестах обновлены.
- [ ] `OrderCreateRequest``InitPaymentRequest`, `OrderCreateResponse``InitPaymentResponse` во всех файлах.
- [ ] `InvalidOrderCreateRequestError``InvalidInitPaymentRequestError`, `OrderCreationUnavailableError``InitPaymentUnavailableError` во всех файлах.
- [ ] `AggregatorService.create_order()``AggregatorService.init_payment()`; метод вызывает TBank adapter и возвращает `InitPaymentResponse`.
- [ ] Payment adapter injected в service через wiring в `app/controllers/v1/delivery.py::_build_aggregator_service`.
- [ ] Endpoint `/order` удалён, добавлен `/init-payment`; controller function переименована в `init_payment`.
- [ ] Controller маппит `InvalidInitPaymentRequestError` → 400, `InitPaymentUnavailableError` → 503.
- [ ] Обновлены unit tests для schemas, service, controller, а также добавлены unit tests для TBank adapter (HTTP client с stub transport).
- [ ] Обновлён пример запроса в `http-client.http`.
## Tests
- Добавить `tests/adapters/tbank/test_client.py` с проверкой: формирование payment request payload (auth, сумма в копейках, order uuid), маппинг успешного ответа в URL, маппинг 4xx в provider request error, маппинг 5xx/transport в client error, retry behavior если реализован.
- Переименовать `tests/services/test_order.py``tests/services/test_init_payment.py`; обновить: stub payment adapter; success case включает вызов payment adapter и возврат `payment_url`; маппинг payment adapter errors в `InvalidInitPaymentRequestError` / `InitPaymentUnavailableError`.
- Переименовать `tests/controllers/v1/test_order.py``tests/controllers/v1/test_init_payment.py`; обновить: endpoint `/init-payment`; payload содержит `price`; success response включает `payment_url`; запрос без `price` и с `price <= 0` возвращает 422; сценарии 400/503 продолжают работать.
- Обновить `tests/config/test_config_sections.py` если добавлена новая обязательная секция в yaml.
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки wiring payment adapter.
## Commands
- `poetry run pytest tests/adapters/tbank/test_client.py -q`
- `poetry run pytest tests/services/test_init_payment.py -q`
- `poetry run pytest tests/controllers/v1/test_init_payment.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -296,6 +296,12 @@ adapter:
cdek_timeout_seconds: 7.5 cdek_timeout_seconds: 7.5
cdek_cache_ttl_seconds: 777 cdek_cache_ttl_seconds: 777
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
observability: observability:
enabled: false enabled: false
service_name: "cdek-adapter-test-service" service_name: "cdek-adapter-test-service"
@@ -10,7 +10,7 @@ from app.adapters.delivery_providers.cdek.client import (
CDEKProvider, CDEKProvider,
CDEKRequestError, CDEKRequestError,
) )
from app.schemas.order import OrderCreateRequest from app.schemas.payment import InitPaymentRequest
class StubAuthClient: class StubAuthClient:
@@ -51,8 +51,10 @@ class SequenceHTTPClient:
return result return result
def _make_order_request(**overrides: object) -> OrderCreateRequest: def _make_order_request(**overrides: object) -> InitPaymentRequest:
payload: dict[str, object] = { payload: dict[str, object] = {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2, "type": 2,
"tariff_code": 535, "tariff_code": 535,
"comment": "Test order", "comment": "Test order",
@@ -89,7 +91,7 @@ def _make_order_request(**overrides: object) -> OrderCreateRequest:
], ],
} }
payload.update(overrides) payload.update(overrides)
return OrderCreateRequest(**payload) return InitPaymentRequest(**payload)
def test_provider_register_order_posts_cdek_contract_payload_and_maps_response() -> None: def test_provider_register_order_posts_cdek_contract_payload_and_maps_response() -> None:
@@ -111,8 +113,7 @@ def test_provider_register_order_posts_cdek_contract_payload_and_maps_response()
result = asyncio.run(provider.register_order(_make_order_request())) result = asyncio.run(provider.register_order(_make_order_request()))
assert result.provider == "cdek" assert result == "cdek-order-uuid"
assert result.order_uuid == "cdek-order-uuid"
assert http_client.calls == [ assert http_client.calls == [
{ {
"method": "POST", "method": "POST",
+170
View File
@@ -0,0 +1,170 @@
import asyncio
import hashlib
from typing import Any
import httpx
import pytest
from app.adapters.tbank.client import TBankAdapter
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentRequestError,
)
class SequenceHTTPClient:
def __init__(self, results: list[Any]) -> None:
self._results = results
self.calls: list[dict[str, Any]] = []
def _next_result(self) -> Any:
return self._results[len(self.calls) - 1]
async def post(
self,
url: str,
*,
json: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> httpx.Response:
self.calls.append(
{
"method": "POST",
"url": url,
"json": json,
"data": data,
"headers": headers,
"timeout": timeout,
}
)
result = self._next_result()
if isinstance(result, Exception):
raise result
return result
def _make_adapter(
http_client: SequenceHTTPClient,
*,
retry_attempts: int = 0,
retry_backoff_seconds: float = 0.2,
sleep_calls: list[float] | None = None,
) -> TBankAdapter:
async def fake_sleep(seconds: float) -> None:
if sleep_calls is not None:
sleep_calls.append(seconds)
return TBankAdapter(
http_client=http_client, # type: ignore[arg-type]
init_url="https://securepay.tinkoff.ru/v2/Init",
terminal_key="TBankTest",
password="test-password",
timeout_seconds=7.5,
retry_attempts=retry_attempts,
retry_backoff_seconds=retry_backoff_seconds,
sleep=fake_sleep,
)
def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None:
response = httpx.Response(
200,
json={"Success": True, "PaymentURL": "https://securepay.test/pay/1"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([response])
adapter = _make_adapter(http_client)
result = asyncio.run(
adapter.create_payment_link(
order_uuid="order-uuid-1",
amount_kopecks=125000,
)
)
expected_token = hashlib.sha256(
"125000order-uuid-1test-passwordTBankTest".encode("utf-8")
).hexdigest()
assert result == "https://securepay.test/pay/1"
assert http_client.calls == [
{
"method": "POST",
"url": "https://securepay.tinkoff.ru/v2/Init",
"json": {
"TerminalKey": "TBankTest",
"Amount": 125000,
"OrderId": "order-uuid-1",
"Token": expected_token,
},
"data": None,
"headers": {
"Accept": "application/json",
"Content-Type": "application/json",
},
"timeout": 7.5,
}
]
def test_create_payment_link_maps_4xx_to_request_error() -> None:
response = httpx.Response(
400,
json={"Success": False, "Message": "bad request"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([response])
adapter = _make_adapter(http_client)
with pytest.raises(TBankPaymentRequestError, match="status 400"):
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
def test_create_payment_link_maps_unsuccessful_payload_to_request_error() -> None:
response = httpx.Response(
200,
json={"Success": False, "Message": "bad request"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([response])
adapter = _make_adapter(http_client)
with pytest.raises(TBankPaymentRequestError):
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
def test_create_payment_link_maps_transport_errors_to_client_error() -> None:
request = httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init")
http_client = SequenceHTTPClient([httpx.ConnectError("down", request=request)])
adapter = _make_adapter(http_client)
with pytest.raises(TBankPaymentAdapterError, match="retry attempts"):
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
def test_create_payment_link_retries_5xx_and_raises_client_error() -> None:
first_response = httpx.Response(
503,
json={"Message": "temporary failure"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
second_response = httpx.Response(
503,
json={"Message": "temporary failure"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([first_response, second_response])
sleep_calls: list[float] = []
adapter = _make_adapter(
http_client,
retry_attempts=1,
retry_backoff_seconds=0.25,
sleep_calls=sleep_calls,
)
with pytest.raises(TBankPaymentAdapterError, match="retriable status 503"):
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
assert len(http_client.calls) == 2
assert sleep_calls == [0.25]
@@ -15,6 +15,12 @@ adapter:
cdek_client_id: "yaml-id" cdek_client_id: "yaml-id"
cdek_client_secret: "yaml-secret" cdek_client_secret: "yaml-secret"
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "yaml-terminal-key"
password: "yaml-password"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -23,6 +23,12 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -12,6 +12,12 @@ repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -23,6 +23,12 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
observability: observability:
enabled: false enabled: false
service_name: "missing-address-suggestions-service" service_name: "missing-address-suggestions-service"
@@ -23,6 +23,12 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -23,6 +23,12 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -22,6 +22,12 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
@@ -15,6 +15,15 @@ adapter:
cdek_client_id: "test-id" cdek_client_id: "test-id"
cdek_client_secret: "test-secret" cdek_client_secret: "test-secret"
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "override-terminal-key"
password: "override-password"
timeout_seconds: 4.25
retry_attempts: 1
retry_backoff_seconds: 0.05
address_suggestions: address_suggestions:
country_to_provider: country_to_provider:
RU: "dadata" RU: "dadata"
+12
View File
@@ -126,6 +126,12 @@ def test_configuration_sections_are_loaded_from_yaml_file(
assert settings.adapter.cdek_retry_backoff_seconds == 0.2 assert settings.adapter.cdek_retry_backoff_seconds == 0.2
assert settings.adapter.cdek_timeout_seconds == 10.0 assert settings.adapter.cdek_timeout_seconds == 10.0
assert settings.adapter.cdek_cache_ttl_seconds == 900 assert settings.adapter.cdek_cache_ttl_seconds == 900
assert settings.tbank_payment.init_url == "https://securepay.tinkoff.ru/v2/Init"
assert settings.tbank_payment.auth.terminal_key == "test-terminal-key"
assert settings.tbank_payment.auth.password == "test-password"
assert settings.tbank_payment.timeout_seconds == 10.0
assert settings.tbank_payment.retry_attempts == 2
assert settings.tbank_payment.retry_backoff_seconds == 0.2
assert settings.address_suggestions.country_to_provider == _expected_country_mapping() assert settings.address_suggestions.country_to_provider == _expected_country_mapping()
assert ( assert (
settings.address_suggestions.dadata.url settings.address_suggestions.dadata.url
@@ -192,6 +198,7 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) -
assert first is second assert first is second
assert first.service.provider_timeout_seconds == 10.0 assert first.service.provider_timeout_seconds == 10.0
assert first.business_logic.provider_price_multiplier == Decimal("1.0") assert first.business_logic.provider_price_multiplier == Decimal("1.0")
assert first.tbank_payment.auth.terminal_key == "test-terminal-key"
assert first.address_suggestions.country_to_provider["RU"] == "dadata" assert first.address_suggestions.country_to_provider["RU"] == "dadata"
assert first.observability.service_name == "g2s-aggregator-test" assert first.observability.service_name == "g2s-aggregator-test"
get_settings.cache_clear() get_settings.cache_clear()
@@ -212,6 +219,11 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
assert settings.controller.api_prefix == "/from-config-test-yaml" assert settings.controller.api_prefix == "/from-config-test-yaml"
assert settings.business_logic.provider_price_multiplier == Decimal("1.25") assert settings.business_logic.provider_price_multiplier == Decimal("1.25")
assert settings.adapter.cdek_client_id == "test-id" assert settings.adapter.cdek_client_id == "test-id"
assert settings.tbank_payment.auth.terminal_key == "override-terminal-key"
assert settings.tbank_payment.auth.password == "override-password"
assert settings.tbank_payment.timeout_seconds == 4.25
assert settings.tbank_payment.retry_attempts == 1
assert settings.tbank_payment.retry_backoff_seconds == 0.05
assert settings.address_suggestions.country_to_provider == { assert settings.address_suggestions.country_to_provider == {
"RU": "dadata", "RU": "dadata",
"AM": "yandex_geosuggest", "AM": "yandex_geosuggest",
@@ -4,10 +4,10 @@ import httpx
from app.controllers.v1.delivery import get_aggregator_service from app.controllers.v1.delivery import get_aggregator_service
from app.main import create_app from app.main import create_app
from app.schemas.order import OrderCreateRequest, OrderCreateResponse from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.services.aggregator import ( from app.services.aggregator import (
AggregatorServiceError, InitPaymentUnavailableError,
InvalidOrderCreateRequestError, InvalidInitPaymentRequestError,
) )
@@ -15,9 +15,9 @@ class StubAggregatorService:
def __init__(self, *, response: object, error: Exception | None = None) -> None: def __init__(self, *, response: object, error: Exception | None = None) -> None:
self._response = response self._response = response
self._error = error self._error = error
self.calls: list[OrderCreateRequest] = [] self.calls: list[InitPaymentRequest] = []
async def create_order(self, request: OrderCreateRequest) -> object: async def init_payment(self, request: InitPaymentRequest) -> object:
self.calls.append(request) self.calls.append(request)
if self._error is not None: if self._error is not None:
raise self._error raise self._error
@@ -33,9 +33,11 @@ def _install_service_override(app, service: StubAggregatorService) -> None:
def _valid_payload() -> dict[str, object]: def _valid_payload() -> dict[str, object]:
return { return {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2, "type": 2,
"tariff_code": 535, "tariff_code": 535,
"comment": "Test order", "comment": "Test payment",
"sender": { "sender": {
"name": "Petr Petrov", "name": "Petr Petrov",
"email": "sender@example.com", "email": "sender@example.com",
@@ -70,8 +72,8 @@ def _valid_payload() -> dict[str, object]:
} }
def test_post_delivery_order_returns_response_and_delegates_to_service() -> None: def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
expected_response = OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1") expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response) service = StubAggregatorService(response=expected_response)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
@@ -82,16 +84,19 @@ def test_post_delivery_order_returns_response_and_delegates_to_service() -> None
transport=transport, transport=transport,
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post("/api/v1/delivery/order", json=_valid_payload()) return await client.post(
"/api/v1/delivery/init-payment",
json=_valid_payload(),
)
response = asyncio.run(run_request()) response = asyncio.run(run_request())
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json") assert response.json() == expected_response.model_dump(mode="json")
assert service.calls == [OrderCreateRequest(**_valid_payload())] assert service.calls == [InitPaymentRequest(**_valid_payload())]
def test_post_delivery_order_rejects_invalid_payload() -> None: def test_post_init_payment_rejects_invalid_payload() -> None:
service = StubAggregatorService(response=None) service = StubAggregatorService(response=None)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
@@ -104,7 +109,10 @@ def test_post_delivery_order_rejects_invalid_payload() -> None:
transport=transport, transport=transport,
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload) return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request()) response = asyncio.run(run_request())
@@ -112,7 +120,79 @@ def test_post_delivery_order_rejects_invalid_payload() -> None:
assert service.calls == [] assert service.calls == []
def test_post_delivery_order_rejects_sender_company_field() -> None: def test_post_init_payment_rejects_missing_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
del invalid_payload["price"]
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/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_non_positive_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["price"] = 0
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/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_non_integer_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["price"] = 125000.5
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/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_sender_company_field() -> None:
service = StubAggregatorService(response=None) service = StubAggregatorService(response=None)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
@@ -128,7 +208,10 @@ def test_post_delivery_order_rejects_sender_company_field() -> None:
transport=transport, transport=transport,
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload) return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request()) response = asyncio.run(run_request())
@@ -136,7 +219,7 @@ def test_post_delivery_order_rejects_sender_company_field() -> None:
assert service.calls == [] assert service.calls == []
def test_post_delivery_order_rejects_sender_phones_field() -> None: def test_post_init_payment_rejects_sender_phones_field() -> None:
service = StubAggregatorService(response=None) service = StubAggregatorService(response=None)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
@@ -152,7 +235,10 @@ def test_post_delivery_order_rejects_sender_phones_field() -> None:
transport=transport, transport=transport,
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload) return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request()) response = asyncio.run(run_request())
@@ -160,7 +246,7 @@ def test_post_delivery_order_rejects_sender_phones_field() -> None:
assert service.calls == [] assert service.calls == []
def test_post_delivery_order_rejects_recipient_company_field() -> None: def test_post_init_payment_rejects_recipient_company_field() -> None:
service = StubAggregatorService(response=None) service = StubAggregatorService(response=None)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
@@ -176,7 +262,10 @@ def test_post_delivery_order_rejects_recipient_company_field() -> None:
transport=transport, transport=transport,
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload) return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request()) response = asyncio.run(run_request())
@@ -184,7 +273,7 @@ def test_post_delivery_order_rejects_recipient_company_field() -> None:
assert service.calls == [] assert service.calls == []
def test_post_delivery_order_rejects_recipient_phones_field() -> None: def test_post_init_payment_rejects_recipient_phones_field() -> None:
service = StubAggregatorService(response=None) service = StubAggregatorService(response=None)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
@@ -200,7 +289,10 @@ def test_post_delivery_order_rejects_recipient_phones_field() -> None:
transport=transport, transport=transport,
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload) return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request()) response = asyncio.run(run_request())
@@ -208,8 +300,8 @@ def test_post_delivery_order_rejects_recipient_phones_field() -> None:
assert service.calls == [] assert service.calls == []
def test_post_delivery_order_accepts_request_without_services() -> None: def test_post_init_payment_accepts_request_without_services() -> None:
expected_response = OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1") expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response) service = StubAggregatorService(response=expected_response)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
@@ -222,19 +314,19 @@ def test_post_delivery_order_accepts_request_without_services() -> None:
transport=transport, transport=transport,
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post("/api/v1/delivery/order", json=payload) return await client.post("/api/v1/delivery/init-payment", json=payload)
response = asyncio.run(run_request()) response = asyncio.run(run_request())
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json") assert response.json() == expected_response.model_dump(mode="json")
assert service.calls == [OrderCreateRequest(**payload)] assert service.calls == [InitPaymentRequest(**payload)]
def test_post_delivery_order_maps_invalid_request_to_400() -> None: def test_post_init_payment_maps_invalid_request_to_400() -> None:
service = StubAggregatorService( service = StubAggregatorService(
response=None, response=None,
error=InvalidOrderCreateRequestError("invalid payload"), error=InvalidInitPaymentRequestError("invalid payload"),
) )
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
@@ -245,23 +337,26 @@ def test_post_delivery_order_maps_invalid_request_to_400() -> None:
transport=transport, transport=transport,
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post("/api/v1/delivery/order", json=_valid_payload()) return await client.post(
"/api/v1/delivery/init-payment",
json=_valid_payload(),
)
response = asyncio.run(run_request()) response = asyncio.run(run_request())
assert response.status_code == 400 assert response.status_code == 400
assert response.json() == { assert response.json() == {
"detail": { "detail": {
"code": "invalid_order_create_request", "code": "invalid_init_payment_request",
"message": "Order request contains invalid or unsupported CDEK data.", "message": "Payment request contains invalid or unsupported TBank data.",
} }
} }
def test_post_delivery_order_maps_service_exception_to_503() -> None: def test_post_init_payment_maps_service_exception_to_503() -> None:
service = StubAggregatorService( service = StubAggregatorService(
response=None, response=None,
error=AggregatorServiceError("service unavailable"), error=InitPaymentUnavailableError("service unavailable"),
) )
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
@@ -272,14 +367,36 @@ def test_post_delivery_order_maps_service_exception_to_503() -> None:
transport=transport, transport=transport,
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post("/api/v1/delivery/order", json=_valid_payload()) return await client.post(
"/api/v1/delivery/init-payment",
json=_valid_payload(),
)
response = asyncio.run(run_request()) response = asyncio.run(run_request())
assert response.status_code == 503 assert response.status_code == 503
assert response.json() == { assert response.json() == {
"detail": { "detail": {
"code": "order_creation_unavailable", "code": "init_payment_unavailable",
"message": "CDEK order creation is temporarily unavailable.", "message": "Payment initialization is temporarily unavailable.",
} }
} }
def test_delivery_order_endpoint_is_removed() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
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/order", json=_valid_payload())
response = asyncio.run(run_request())
assert response.status_code == 404
assert service.calls == []
+6
View File
@@ -179,6 +179,12 @@ adapter:
cdek_timeout_seconds: 10.0 cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900 cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
observability: observability:
enabled: false enabled: false
service_name: "repository-test-service" service_name: "repository-test-service"
+129
View File
@@ -0,0 +1,129 @@
import asyncio
import pytest
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentRequestError,
)
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.services.aggregator import (
AggregatorService,
InitPaymentUnavailableError,
InvalidInitPaymentRequestError,
)
class StubPaymentAdapter:
def __init__(
self,
*,
response: str | None = None,
error: Exception | None = None,
) -> None:
self._response = response
self._error = error
self.calls: list[tuple[str, int]] = []
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
self.calls.append((order_uuid, amount_kopecks))
if self._error is not None:
raise self._error
if self._response is None:
raise RuntimeError("Stub payment adapter has no response configured.")
return self._response
def _make_init_payment_request(**overrides: object) -> InitPaymentRequest:
payload: dict[str, object] = {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test payment",
"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_init_payment_delegates_to_adapter_and_returns_payment_url() -> None:
request = _make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
service = AggregatorService(providers=[], payment_adapter=adapter)
result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
assert adapter.calls == [("order-uuid-1", 125000)]
def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None:
request = _make_init_payment_request()
adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload"))
service = AggregatorService(providers=[], payment_adapter=adapter)
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
assert adapter.calls == [("order-uuid-1", 125000)]
def test_init_payment_delegates_request_with_services_none_without_new_logic() -> None:
request = _make_init_payment_request(services=None)
adapter = StubPaymentAdapter(response="https://pay.test/payment/2")
service = AggregatorService(providers=[], payment_adapter=adapter)
result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/2")
assert adapter.calls == [("order-uuid-1", 125000)]
assert request.services is None
def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
request = _make_init_payment_request()
adapter = StubPaymentAdapter(error=TBankPaymentAdapterError("transport down"))
service = AggregatorService(providers=[], payment_adapter=adapter)
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(request))
assert adapter.calls == [("order-uuid-1", 125000)]
def test_init_payment_without_configured_adapter_raises_unavailable_error() -> None:
service = AggregatorService(providers=[])
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(_make_init_payment_request()))
-128
View File
@@ -1,128 +0,0 @@
import asyncio
import pytest
from app.adapters.delivery_providers.base import ProviderRequestError
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
from app.services.aggregator import (
AggregatorService,
InvalidOrderCreateRequestError,
OrderCreationUnavailableError,
)
class StubOrderAdapter:
def __init__(
self,
*,
response: OrderCreateResponse | None = None,
error: Exception | None = None,
) -> None:
self._response = response
self._error = error
self.calls: list[OrderCreateRequest] = []
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
self.calls.append(request)
if self._error is not None:
raise self._error
if self._response is None:
raise RuntimeError("Stub order adapter has no response configured.")
return self._response
def _make_order_request(**overrides: object) -> OrderCreateRequest:
payload: dict[str, object] = {
"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 OrderCreateRequest(**payload)
def test_create_order_delegates_to_adapter_and_returns_created_order() -> None:
request = _make_order_request()
adapter = StubOrderAdapter(
response=OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
)
service = AggregatorService(providers=[], order_adapter=adapter)
result = asyncio.run(service.create_order(request))
assert result == OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
assert adapter.calls == [request]
def test_create_order_maps_provider_request_errors_to_invalid_order_error() -> None:
request = _make_order_request()
adapter = StubOrderAdapter(error=ProviderRequestError("bad payload"))
service = AggregatorService(providers=[], order_adapter=adapter)
with pytest.raises(InvalidOrderCreateRequestError):
asyncio.run(service.create_order(request))
assert adapter.calls == [request]
def test_create_order_delegates_request_with_services_none_without_new_logic() -> None:
request = _make_order_request(services=None)
adapter = StubOrderAdapter(
response=OrderCreateResponse(provider="cdek", order_uuid="order-uuid-2")
)
service = AggregatorService(providers=[], order_adapter=adapter)
result = asyncio.run(service.create_order(request))
assert result == OrderCreateResponse(provider="cdek", order_uuid="order-uuid-2")
assert adapter.calls == [request]
assert adapter.calls[0].services is None
def test_create_order_maps_transport_failures_to_unavailable_error() -> None:
request = _make_order_request()
adapter = StubOrderAdapter(error=RuntimeError("transport down"))
service = AggregatorService(providers=[], order_adapter=adapter)
with pytest.raises(OrderCreationUnavailableError):
asyncio.run(service.create_order(request))
assert adapter.calls == [request]
def test_create_order_without_configured_adapter_raises_unavailable_error() -> None:
service = AggregatorService(providers=[])
with pytest.raises(OrderCreationUnavailableError):
asyncio.run(service.create_order(_make_order_request()))