Добавлен адаптер к tbank и формирование ссылки на оплату
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
"""TBank payment adapter package."""
|
||||
|
||||
from app.adapters.tbank.client import TBankAdapter
|
||||
|
||||
__all__ = ["TBankAdapter"]
|
||||
@@ -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."""
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user