Добавлен адаптер к 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()