All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m10s
279 lines
13 KiB
Python
279 lines
13 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` в теле) — тоже НЕ
|
||
ретраится: это содержательный ответ банка, а не сбой транспорта.
|
||
|
||
БЮДЖЕТ ВРЕМЕНИ (важно для 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)
|