Добавлен адаптер к 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.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.response import DeliveryPrice
log = logging.getLogger(__name__)
@@ -94,7 +94,7 @@ class CDEKClient:
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)
for attempt in range(self._retry_attempts + 1):
try:
@@ -241,5 +241,5 @@ class CDEKProvider(DeliveryProvider):
raw_payload = await self._client.get_raw_price(request)
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)
@@ -2,11 +2,10 @@
from typing import Any
from app.schemas.order import (
OrderCreateRequest,
OrderCreateResponse,
OrderPackage,
OrderParty,
from app.schemas.payment import (
InitPaymentRequest,
PaymentPackage,
PaymentParty,
)
@@ -14,7 +13,7 @@ class CDEKOrderMappingError(ValueError):
"""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] = {
"type": request.type,
"tariff_code": request.tariff_code,
@@ -33,7 +32,7 @@ def map_cdek_order_request(request: OrderCreateRequest) -> dict[str, Any]:
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")
if not isinstance(entity, dict):
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:
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 {
"name": party.name,
"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["weight"] = package.weight * 1000
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)
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):
url: str = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
api_key: str = ""
@@ -107,6 +120,7 @@ class Settings(BaseSettings):
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
adapter: AdapterConfig = Field(default_factory=AdapterConfig)
tbank_payment: TBankPaymentConfig
address_suggestions: AddressSuggestionsConfig = Field(
default_factory=AddressSuggestionsConfig
)
@@ -138,6 +152,7 @@ class _RequiredYamlSections(BaseModel):
business_logic: dict[str, Any]
repository: dict[str, Any]
adapter: dict[str, Any]
tbank_payment: dict[str, Any]
address_suggestions: dict[str, Any]
observability: dict[str, Any]
+28 -14
View File
@@ -8,10 +8,11 @@ from app.adapters.address_suggestions.yandex_geosuggest import (
YandexGeosuggestAddressSuggestionProvider,
)
from app.adapters.delivery_providers.cdek import CDEKProvider
from app.adapters.tbank import TBankAdapter
from app.config import Settings
from app.controllers.http_client import build_controller_http_client
from app.repositories.cache.redis_cache import PriceCache
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import AddressSuggestion, DeliveryPrice
from app.services.aggregator import (
@@ -20,7 +21,8 @@ from app.services.aggregator import (
AggregatorServiceError,
InvalidAddressSuggestRequestError,
InvalidDeliveryRequestError,
InvalidOrderCreateRequestError,
InvalidInitPaymentRequestError,
InitPaymentUnavailableError,
UnsupportedAddressSuggestionCountryError,
)
@@ -38,6 +40,10 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client=http_client,
adapter_config=settings.adapter,
)
payment_adapter = TBankAdapter.from_config(
http_client=http_client,
config=settings.tbank_payment,
)
dadata_provider = DadataAddressSuggestionProvider.from_config(
http_client=http_client,
config=settings.address_suggestions.dadata,
@@ -55,7 +61,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
service = AggregatorService(
providers=providers,
cache=cache,
order_adapter=cdek_provider,
payment_adapter=payment_adapter,
address_suggestion_providers=(
dadata_provider,
yandex_geosuggest_provider,
@@ -146,28 +152,36 @@ async def suggest_addresses(
@router.post(
"/order",
response_model=OrderCreateResponse,
"/init-payment",
response_model=InitPaymentResponse,
)
async def create_delivery_order(
order_request: OrderCreateRequest,
async def init_payment(
payment_request: InitPaymentRequest,
service: AggregatorService = Depends(get_aggregator_service),
) -> OrderCreateResponse:
) -> InitPaymentResponse:
try:
return await service.create_order(order_request)
except InvalidOrderCreateRequestError as exc:
return await service.init_payment(payment_request)
except InvalidInitPaymentRequestError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "invalid_order_create_request",
"message": "Order request contains invalid or unsupported CDEK data.",
"code": "invalid_init_payment_request",
"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
except AggregatorServiceError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "order_creation_unavailable",
"message": "CDEK order creation is temporarily unavailable.",
"code": "init_payment_unavailable",
"message": "Payment initialization is temporarily unavailable.",
},
) 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 pydantic import BaseModel, ConfigDict, Field, model_validator
class OrderPhone(BaseModel):
class PaymentPhone(BaseModel):
number: str = Field(min_length=1)
class OrderParty(BaseModel):
class PaymentParty(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1)
email: str = Field(min_length=1)
phone: OrderPhone
phone: PaymentPhone
@model_validator(mode="before")
@classmethod
@@ -24,18 +24,18 @@ class OrderParty(BaseModel):
return value
class OrderLocation(BaseModel):
class DeliveryLocation(BaseModel):
address: str = Field(min_length=1)
city: str = Field(min_length=1)
country_code: str = Field(min_length=2, max_length=2)
class OrderService(BaseModel):
class PaymentService(BaseModel):
code: str = Field(min_length=1)
parameter: str = Field(min_length=1)
class OrderPackage(BaseModel):
class PaymentPackage(BaseModel):
number: str = Field(min_length=1)
weight: int = Field(gt=0)
length: int = Field(gt=0)
@@ -44,18 +44,19 @@ class OrderPackage(BaseModel):
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]
tariff_code: Literal[535]
comment: str | None = None
sender: OrderParty
recipient: OrderParty
from_location: OrderLocation
to_location: OrderLocation
services: list[OrderService] | None = Field(default=None, min_length=1)
packages: list[OrderPackage] = Field(min_length=1)
sender: PaymentParty
recipient: PaymentParty
from_location: DeliveryLocation
to_location: DeliveryLocation
services: list[PaymentService] | None = Field(default=None, min_length=1)
packages: list[PaymentPackage] = Field(min_length=1)
class OrderCreateResponse(BaseModel):
provider: str = Field(min_length=1)
order_uuid: str = Field(min_length=1)
class InitPaymentResponse(BaseModel):
payment_url: str = Field(min_length=1)
+32 -28
View File
@@ -13,6 +13,10 @@ from app.adapters.address_suggestions.base import (
AddressSuggestionRequestError,
)
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentRequestError,
)
from app.domain.price import (
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
DEFAULT_WEIGHT_ROUND_SCALE,
@@ -20,7 +24,7 @@ from app.domain.price import (
filter_and_sort_prices,
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.response import AddressSuggestion, DeliveryPrice
@@ -45,12 +49,12 @@ class AddressSuggestionsUnavailableError(AggregatorServiceError):
"""Raised when address suggestions cannot be completed."""
class InvalidOrderCreateRequestError(AggregatorServiceError):
"""Raised when provider rejects order creation payload as invalid."""
class InvalidInitPaymentRequestError(AggregatorServiceError):
"""Raised when provider rejects payment init payload as invalid."""
class OrderCreationUnavailableError(AggregatorServiceError):
"""Raised when order creation cannot be completed."""
class InitPaymentUnavailableError(AggregatorServiceError):
"""Raised when payment initialization cannot be completed."""
class PriceCacheProtocol(Protocol):
@@ -59,6 +63,10 @@ class PriceCacheProtocol(Protocol):
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):
def __call__(
self,
@@ -69,18 +77,12 @@ class FilterAndSortPricesFn(Protocol):
) -> list[object]: ...
class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(
self, request: OrderCreateRequest
) -> OrderCreateResponse: ...
class AggregatorService:
def __init__(
self,
providers: Sequence[DeliveryProvider],
cache: PriceCacheProtocol | None = None,
order_adapter: OrderRegistrationAdapterProtocol | None = None,
payment_adapter: PaymentAdapterProtocol | None = None,
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
*,
@@ -90,7 +92,7 @@ class AggregatorService:
) -> None:
self._providers = tuple(providers)
self._cache = cache
self._order_adapter = order_adapter
self._payment_adapter = payment_adapter
self._weight_round_scale = weight_round_scale
self._provider_price_multiplier = provider_price_multiplier
self._filter_and_sort_prices = filter_and_sort_prices_fn
@@ -168,27 +170,29 @@ class AggregatorService:
return [self._coerce_address_suggestion(item) for item in suggestions]
async def create_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
if self._order_adapter is None:
raise OrderCreationUnavailableError(
"Order registration adapter is not configured."
)
async def init_payment(self, request: InitPaymentRequest) -> InitPaymentResponse:
if self._payment_adapter is None:
raise InitPaymentUnavailableError("Payment adapter is not configured.")
try:
created_order = await self._order_adapter.register_order(request)
except ProviderRequestError as exc:
raise InvalidOrderCreateRequestError(
"Order request is invalid for the configured provider."
payment_url = await self._payment_adapter.create_payment_link(
order_uuid=request.order_uuid,
amount_kopecks=request.price,
)
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
except Exception as exc:
raise OrderCreationUnavailableError(
"Order creation is temporarily unavailable."
raise InitPaymentUnavailableError(
"Payment initialization is temporarily unavailable."
) from exc
return OrderCreateResponse.model_validate(
created_order,
from_attributes=True,
)
return InitPaymentResponse(payment_url=payment_url)
async def _get_provider_prices(
self,