Files
g2s-aggregator/app/adapters/tbank/client.py
T

258 lines
8.4 KiB
Python

"""TBank payment adapter."""
import asyncio
from collections.abc import Awaitable, Callable
import hashlib
import hmac
from typing import Any
import httpx
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentNotificationTokenError,
TBankPaymentRequestError,
)
from app.config import TBankPaymentConfig
from app.schemas.payment import TBankPaymentNotification
class TBankAdapter:
def __init__(
self,
http_client: httpx.AsyncClient,
*,
init_url: str,
notification_url: str,
success_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._notification_url = notification_url
self._success_url = success_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,
notification_url=config.notification_url,
success_url=config.success_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 _build_tbank_request_error(
"TBank payment init request was rejected.",
status_code=response.status_code,
payload=_response_json_or_none(response),
)
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 verify_payment_notification(
self,
notification: TBankPaymentNotification,
) -> None:
payload = notification.model_dump(mode="python")
expected_token = _build_tbank_token(payload, password=self._password)
if not hmac.compare_digest(expected_token, notification.Token):
raise TBankPaymentNotificationTokenError(
"TBank payment notification token is invalid."
)
def _build_payload(self, *, order_uuid: str, amount_kopecks: int) -> dict[str, Any]:
if not order_uuid.strip():
raise TBankPaymentRequestError("TBank payment order id must be non-empty.")
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,
"NotificationURL": self._notification_url,
"SuccessURL": self._success_url,
}
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 _build_tbank_request_error(
"TBank payment init request was rejected.",
payload=payload,
)
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(
_stringify_token_value(token_payload[key]) for key in sorted(token_payload)
)
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
def _stringify_token_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _response_json_or_none(response: httpx.Response) -> object | None:
try:
return response.json()
except (TypeError, ValueError):
return None
def _build_tbank_request_error(
message: str,
*,
status_code: int | None = None,
payload: object | None = None,
) -> TBankPaymentRequestError:
error_code = _payload_text_value(payload, "ErrorCode")
provider_message = _payload_text_value(payload, "Message")
details = _payload_text_value(payload, "Details")
return TBankPaymentRequestError(
_format_tbank_request_error_message(
message,
status_code=status_code,
error_code=error_code,
provider_message=provider_message,
details=details,
),
status_code=status_code,
error_code=error_code,
provider_message=provider_message,
details=details,
)
def _payload_text_value(payload: object | None, key: str) -> str | None:
if not isinstance(payload, dict):
return None
value = payload.get(key)
if value is None:
return None
text = str(value).strip()
if not text:
return None
return text
def _format_tbank_request_error_message(
message: str,
*,
status_code: int | None,
error_code: str | None,
provider_message: str | None,
details: str | None,
) -> str:
fields: list[str] = []
if status_code is not None:
fields.append(f"status_code={status_code}")
if error_code is not None:
fields.append(f"error_code={error_code}")
if provider_message is not None:
fields.append(f"message={provider_message}")
if details is not None:
fields.append(f"details={details}")
if not fields:
return message
return f"{message} {' '.join(fields)}"