"""Подпись `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)