gendesign/tradein-mvp/backend/app/services/payments/tbank_client.py
bot-backend a32ccabd0d
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m3s
Deploy Trade-In / build-backend (push) Successful in 1m9s
Deploy Trade-In / deploy (push) Successful in 1m24s
feat(tradein/payments): подпись Token, клиент Т-Банка и сборка чека (#2733)
PR-C платёжного контура: token.py (sign + verify_notification_token, оба эталонных вектора Т-Банка перепроверены независимо), receipt.py (54-ФЗ ФФД 1.05, целые копейки), tbank_client.py (Init/GetState/CheckOrder/Confirm/Cancel, таймаут 15с, 4xx не ретраится). Слой инертный: 0 импортёров, роутеров нет.
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-08-06 12:36:57 +00:00

249 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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` в теле) — тоже НЕ
ретраится: это содержательный ответ банка, а не сбой транспорта.
БЕЗОПАСНОСТЬ: `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`."""
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`)."""
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)