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
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>
249 lines
10 KiB
Python
249 lines
10 KiB
Python
"""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)
|