gendesign/tradein-mvp/backend/app/services/payments/token.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

98 lines
5.4 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.

"""Подпись `Token` запросов Т-Банк эквайринга и проверка подписи нотификаций.
Docs (проверено живым запросом к doc-порталу, 2026-08-06):
- https://developer.tbank.ru/eacq/intro/developer/token — формирование Token.
- https://developer.tbank.ru/eacq/intro/developer/notification
(раздел «Проверить токен уведомлений») — тот же алгоритм для входящих
нотификаций.
Алгоритм (идентичен для исходящего запроса и для проверки нотификации):
1. Берём ТОЛЬКО плоские поля payload: исключаем ключ `Token`, исключаем
`None`, исключаем значения-`dict`/`list` (документация формулирует это
как «кроме параметра Token и вложенных объектов (Data, Receipt)» —
здесь обобщено до правила по ТИПУ значения, а не по имени ключа: любые
вложенные объекты/массивы, будь то `Receipt`, `DATA`, `Data`, `Items`
или `Shops`, отсекаются одинаково, потому что все они не примитивы).
2. `bool` → `"true"`/`"false"` (нижний регистр); `int`/`float` → строка без
экспоненциальной записи; `str` — как есть.
3. Добавляем пару `Password: <пароль_терминала>`.
4. Сортируем пары по имени ключа (лексикографически по строке ключа),
конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку.
5. SHA-256 (UTF-8) от строки, hex-digest в нижнем регистре.
Эталонные векторы (см. `tests/test_payments_token.py`) сняты дословно с
doc-портала — оба подтверждены живым запросом, не выдуманы.
"""
from __future__ import annotations
import hashlib
import hmac
from typing import Any
_EXCLUDED_KEYS = frozenset({"Token"})
def _stringify_value(value: bool | int | float | str) -> str:
"""Приводит плоское значение к строке по правилам Т-Банка.
`bool` проверяем ДО `int`: в Python `bool` — подкласс `int`
(`isinstance(True, int) is True`), поэтому порядок веток важен —
иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`.
"""
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, int):
return str(value)
if isinstance(value, float):
# `format(..., "f")` — фиксированная нотация, Python никогда не
# добавляет экспоненту при presentation type 'f' (в отличие от
# str()/repr(), которые для очень больших/малых float дают "1e+21").
text = format(value, "f")
if "." in text:
text = text.rstrip("0").rstrip(".")
return text
return str(value)
def _flatten_signable_fields(payload: dict[str, Any]) -> dict[str, str]:
"""Плоские поля payload, готовые к конкатенации: без Token/None/dict/list."""
result: dict[str, str] = {}
for key, value in payload.items():
if key in _EXCLUDED_KEYS or value is None:
continue
if isinstance(value, dict | list):
continue
result[key] = _stringify_value(value)
return result
def sign(payload: dict[str, Any], password: str) -> str:
"""Считает `Token` для исходящего запроса (Init/GetState/CheckOrder/...).
`payload` — тело запроса ДО добавления поля `Token` (поле `Password`
самому передавать не нужно — функция добавляет его сама и удаляет
участие любых вложенных объектов автоматически).
"""
fields = _flatten_signable_fields(payload)
fields["Password"] = password
raw = "".join(fields[key] for key in sorted(fields))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def verify_notification_token(payload: dict[str, Any], password: str) -> bool:
"""Проверяет `Token` входящей нотификации: пересчёт + `hmac.compare_digest`.
`payload` — полное тело нотификации, включая присланный `Token` (сам
алгоритм сборки исключает ключ `Token` из подписи — см. `_EXCLUDED_KEYS`).
Возвращает `False`, если в payload нет строкового непустого `Token`
(нечего сравнивать) — вызывающая сторона обязана трактовать это как
отказ в обработке нотификации, а не как «пропустить проверку».
"""
received_token = payload.get("Token")
if not isinstance(received_token, str) or not received_token:
return False
expected_token = sign(payload, password)
return hmac.compare_digest(expected_token, received_token)