"""httpx-клиент Т-Банк эквайринга (Init/GetState/CheckOrder/Confirm/Cancel). Стиль и обработка ошибок — по образцу `app.services.tgbot.client.TelegramClient`: единственные нужные методы, не тянем отдельный SDK ради пяти HTTP-вызовов. Модуль НЕ импортирует `app.core.config` — все параметры (`terminal_key`, `password`, `base_url`) передаются в конструктор явно аргументами. Архитектурное ограничение PR-C (см. `app/services/payments/__init__.py`): параллельный PR-B вводит эти поля в `config.py`, проводку делает PR-D. Docs: https://developer.tbank.ru/eacq/api Ретраи: - Сетевые ошибки (timeout/connect) и HTTP 5xx — экспоненциальный backoff, capped на `_MAX_BACKOFF_S`. - Любая 4xx — НЕ ретраится (запрос некорректен / права не те — повтор транспортного вызова не поможет), сразу `TBankApiError`. - Бизнес-отказ (HTTP 200, но `Success: false` в теле) — тоже НЕ ретраится: это содержательный ответ банка, а не сбой транспорта. БЮДЖЕТ ВРЕМЕНИ (важно для PR-D): worst case одного вызова любого метода — около 74 с (4 попытки × `_DEFAULT_TIMEOUT_S`=15 с = 60 с, плюс backoff между попытками 2+4+8=14 с при `_DEFAULT_MAX_RETRIES`=3). Т-Банк даёт на ответ на нотификацию окно порядка 10 с — этот бюджет в 74 с в него заведомо не укладывается. Значит: исходящий HTTP-вызов к `TBankClient` (в т.ч. `get_state`/`confirm`/`cancel` для сверки/реконсиляции по нотификации) ВНУТРИ обработчика публичной ручки нотификации ЗАПРЕЩЁН — обработчик обязан только валидировать/сохранить событие и ответить `"OK"`, а любая сверка с банком (`GetState`/`CheckOrder`) — асинхронно, вне HTTP-цикла ответа банку. БЕЗОПАСНОСТЬ: `password` и `Token` НИКОГДА не попадают в `logger.*` — логируем только имя метода, HTTP-статус, `ErrorCode`/`Message`/`Details` из ответа банка. """ from __future__ import annotations import asyncio import logging from typing import Any import httpx from app.services.payments.token import sign logger = logging.getLogger(__name__) _DEFAULT_TIMEOUT_S = 15.0 _MAX_BACKOFF_S = 30.0 _DEFAULT_MAX_RETRIES = 3 DEFAULT_BASE_URL = "https://securepay.tinkoff.ru" class TBankApiError(Exception): """T-Bank Acquiring API ответил ошибкой (HTTP-ошибка или `Success: false`).""" def __init__(self, method: str, error_code: str, message: str, details: str = "") -> None: self.method = method self.error_code = error_code self.message = message self.details = details text = f"T-Bank API {method} failed: [{error_code}] {message}" if details: text += f" — {details}" super().__init__(text) def _error_from_body(response: httpx.Response) -> tuple[str, str, str]: """Парсит (ErrorCode, Message, Details) из тела ответа; fallback на HTTP-статус.""" try: data = response.json() except ValueError: return str(response.status_code), (response.text or "")[:200], "" if not isinstance(data, dict): return str(response.status_code), str(data)[:200], "" error_code = str(data.get("ErrorCode", response.status_code)) message = str(data.get("Message", "")) details = str(data.get("Details", "")) return error_code, message, details class TBankClient: """Клиент Т-Банк эквайринга на `httpx.AsyncClient`. Каждый вызов — отдельное короткоживущее соединение (без общего connection-pool между вызовами; частота вызовов в checkout-потоке низкая, держать долгоживущий клиент не нужно — тот же паттерн, что `TelegramClient`). """ def __init__( self, *, terminal_key: str, password: str, base_url: str = DEFAULT_BASE_URL, timeout: float = _DEFAULT_TIMEOUT_S, ) -> None: self._terminal_key = terminal_key self._password = password self._base = f"{base_url.rstrip('/')}/v2" self._timeout = timeout def _signed_payload(self, payload: dict[str, Any]) -> dict[str, Any]: """Добавляет `TerminalKey` + `Token`. Сам `password` в тело не уходит.""" body: dict[str, Any] = {"TerminalKey": self._terminal_key, **payload} body["Token"] = sign(body, self._password) return body async def _request( self, method: str, payload: dict[str, Any], *, max_retries: int = _DEFAULT_MAX_RETRIES, ) -> dict[str, Any]: """POST `method` с подписанным JSON-телом. Ретраит network/5xx, иначе raise сразу.""" body = self._signed_payload(payload) url = f"{self._base}/{method}" attempt = 0 while True: attempt += 1 try: async with httpx.AsyncClient(timeout=self._timeout) as client: response = await client.post(url, json=body) except (httpx.TimeoutException, httpx.NetworkError) as exc: if attempt > max_retries: logger.error( "tbank client: %s — network error после %d попыток: %s", method, attempt, exc, ) raise TBankApiError(method, "network_error", str(exc)) from exc backoff = min(2.0**attempt, _MAX_BACKOFF_S) logger.warning( "tbank client: %s — network error (попытка %d/%d) — retry через %.0fs", method, attempt, max_retries, backoff, ) await asyncio.sleep(backoff) continue if response.status_code >= 500: if attempt > max_retries: error_code, message, details = _error_from_body(response) logger.error( "tbank client: %s — HTTP %d после %d попыток, сдаёмся", method, response.status_code, attempt, ) raise TBankApiError(method, error_code, message, details) backoff = min(2.0**attempt, _MAX_BACKOFF_S) logger.warning( "tbank client: %s — HTTP %d (попытка %d/%d) — retry через %.0fs", method, response.status_code, attempt, max_retries, backoff, ) await asyncio.sleep(backoff) continue if response.status_code >= 400: # 4xx кроме сетевых сценариев выше — запрос некорректен, повтор не поможет. error_code, message, details = _error_from_body(response) raise TBankApiError(method, error_code, message, details) try: data = response.json() except ValueError as exc: raise TBankApiError(method, "invalid_json", str(exc)) from exc if not isinstance(data, dict): raise TBankApiError(method, "invalid_response", "тело ответа — не JSON-объект") if not data.get("Success"): error_code = str(data.get("ErrorCode", response.status_code)) message = str(data.get("Message", "")) details = str(data.get("Details", "")) raise TBankApiError(method, error_code, message, details) return data async def init_payment( self, *, order_id: str, amount_kopecks: int, description: str = "", notification_url: str | None = None, success_url: str | None = None, fail_url: str | None = None, receipt: dict[str, Any] | None = None, pay_type: str | None = None, data: dict[str, str] | None = None, ) -> dict[str, Any]: """`POST /v2/Init` — инициирует платёж, возвращает `PaymentId` + `PaymentURL`. КОНТРАКТ ДЛЯ PR-D (обработка сетевой ошибки вызывающей стороной): после `TBankApiError` от `Init` (в т.ч. `error_code == "network_error"` — таймаут/обрыв) НЕЛЬЗЯ слепо повторять `init_payment()` с тем же `order_id` — неизвестно, дошёл ли исходный запрос до банка до обрыва соединения. Слепой повтор может создать ВТОРОЙ холд на тот же `OrderId`. Разбираться нужно через `check_order(order_id=...)` — он возвращает уже существующие платежи по заказу — и только по его результату решать, нужен ли новый `Init`. """ payload: dict[str, Any] = {"OrderId": order_id, "Amount": amount_kopecks} if description: payload["Description"] = description if notification_url: payload["NotificationURL"] = notification_url if success_url: payload["SuccessURL"] = success_url if fail_url: payload["FailURL"] = fail_url if receipt: payload["Receipt"] = receipt if pay_type: payload["PayType"] = pay_type if data: payload["DATA"] = data return await self._request("Init", payload) async def get_state(self, *, payment_id: str) -> dict[str, Any]: """`POST /v2/GetState` — статус платежа по `PaymentId`.""" return await self._request("GetState", {"PaymentId": payment_id}) async def check_order(self, *, order_id: str) -> dict[str, Any]: """`POST /v2/CheckOrder` — список платежей по `OrderId` (для реконсиляции).""" return await self._request("CheckOrder", {"OrderId": order_id}) async def confirm( self, *, payment_id: str, amount_kopecks: int | None = None, receipt: dict[str, Any] | None = None, ) -> dict[str, Any]: """`POST /v2/Confirm` — подтверждение холда (двухстадийная оплата, `PayType=T`). КОНТРАКТ ДЛЯ PR-D (обработка ошибки вызывающей стороной): после `TBankApiError` от `Confirm` (в т.ч. сетевой таймаут) слепой вызов `cancel()` для того же `payment_id` ЗАПРЕЩЁН. Таймаут/обрыв мог прийти УЖЕ ПОСЛЕ того, как банк фактически подтвердил холд — `Confirm` состоялся на стороне банка, а ответ до клиента не дошёл. В этом случае `cancel()` вернёт клиенту уже захваченные деньги. Правильная последовательность: сначала `get_state(payment_id=...)`, и только по актуальному статусу решать, нужен ли `cancel()`. """ payload: dict[str, Any] = {"PaymentId": payment_id} if amount_kopecks is not None: payload["Amount"] = amount_kopecks if receipt: payload["Receipt"] = receipt return await self._request("Confirm", payload) async def cancel( self, *, payment_id: str, amount_kopecks: int | None = None, receipt: dict[str, Any] | None = None, ) -> dict[str, Any]: """`POST /v2/Cancel` — отмена/возврат (полный, если `amount_kopecks` не передан).""" payload: dict[str, Any] = {"PaymentId": payment_id} if amount_kopecks is not None: payload["Amount"] = amount_kopecks if receipt: payload["Receipt"] = receipt return await self._request("Cancel", payload)