"""Строгий типизированный разбор нотификации Т-Банк — ПОСЛЕ проверки подписи. Вызывать `parse_notification()` только когда `token.verify_notification_token(...)` уже вернул `True`. Разбор здесь НЕ проверяет подпись повторно — он только превращает уже доверенный (по подписи) `dict` в типизированный объект, чтобы сырой `dict` не утекал дальше в бизнес-логику (статус-машину платежа, запись в БД). ПОЧЕМУ строгий разбор — самостоятельный слой защиты, а не формальность: алгоритм подписи Т-Банка конкатенирует значения полей БЕЗ разделителя между ними (см. `token.py`, docstring модуля, шаг 4). Из-за этого символы могут "перекладываться" между лексикографически соседними ключами так, что итоговая строка для SHA-256 не меняется, хотя значения полей — меняются. Проверено живым расчётом на официальном эталонном векторе: `Amount=1111, CardId="000000"` даёт тот же Token, что и `Amount=11, CardId="11000000"` (доп. `1` "перетекла" из `Amount` в начало `CardId`, потому что `Amount` < `CardId` лексикографически и обе стоят подряд в конкатенации). Значит подпись сама по себе НЕ гарантирует, что банк прислал именно ту сумму, которую записал у себя платёжный сервис — это СВОЙСТВО алгоритма банка, менять его нельзя (мы не управляем форматом Token, который реально пришлёт банк на проде). КОНТРАКТ ДЛЯ PR-D (публичная ручка нотификации) — единственная реальная защита от описанного выше перекладывания: `amount_kopecks` из `parse_notification()` ОБЯЗАН быть сверен с уже сохранённым `payments.amount_kopecks` в БД (запись, созданная на `init_payment()`, найденная по `order_id`/`payment_id` из этой же нотификации) ДО того, как нотификация будет принята как валидное событие. Если сумма из нотификации не совпадает с суммой в БД — это либо подделанная нотификация (перекладывание символов дало другой `OrderId`/`Amount`-ключ и подпись всё равно сошлась), либо рассинхронизация, но НЕ штатный кейс — то и другое должно быть отказом, а не «примерно похоже, примем». """ from __future__ import annotations from dataclasses import dataclass from typing import Any class NotificationParseError(ValueError): """Поле нотификации не соответствует ожидаемому типу — отказ, не догадка.""" @dataclass(frozen=True, slots=True) class TBankNotification: """Типизированное тело нотификации Т-Банка ПОСЛЕ успешной проверки подписи. `amount_kopecks` здесь — то, что ПРИСЛАЛ банк в текущем HTTP-запросе, а НЕ подтверждённый источник истины сам по себе. См. docstring модуля — сверка с `payments.amount_kopecks` в БД обязательна на вызывающей стороне. """ success: bool status: str order_id: str payment_id: str terminal_key: str amount_kopecks: int def parse_notification(payload: dict[str, Any]) -> TBankNotification: """Строгий разбор `payload` в `TBankNotification`. Вызывать ТОЛЬКО после `token.verify_notification_token(payload, password) is True` — эта функция подпись не проверяет. Правила (без исключений, без «примерно разберём»): - `Success` — только настоящий `bool` (не строка `"true"`, не `1`); - `Amount` — только `int`; `bool` — подкласс `int` в Python (`isinstance(True, int) is True`), поэтому проверяется и отсекается ДО проверки на `int`, иначе `Success`-подобное поле молча прошло бы как сумма; - `Status`, `OrderId`, `PaymentId`, `TerminalKey` — только непустой `str`. Любое несоответствие — `NotificationParseError` с указанием поля, ожидаемого типа и того, что реально пришло. """ if not isinstance(payload, dict): raise NotificationParseError(f"payload должен быть dict, получено {type(payload).__name__}") return TBankNotification( success=_require_strict_bool(payload, "Success"), status=_require_nonempty_str(payload, "Status"), order_id=_require_nonempty_str(payload, "OrderId"), payment_id=_require_nonempty_str(payload, "PaymentId"), terminal_key=_require_nonempty_str(payload, "TerminalKey"), amount_kopecks=_require_strict_int(payload, "Amount"), ) def _require_strict_bool(payload: dict[str, Any], key: str) -> bool: value = payload.get(key) if not isinstance(value, bool): raise NotificationParseError( f"{key} должен быть bool, получено {type(value).__name__}={value!r}" ) return value def _require_strict_int(payload: dict[str, Any], key: str) -> int: value = payload.get(key) # bool — подкласс int в Python: проверяем и отсекаем ДО isinstance(value, int), # иначе True/False молча прошли бы как Amount=1/Amount=0. if isinstance(value, bool) or not isinstance(value, int): raise NotificationParseError( f"{key} должен быть int (не bool/str/float), получено {type(value).__name__}={value!r}" ) return value def _require_nonempty_str(payload: dict[str, Any], key: str) -> str: value = payload.get(key) if not isinstance(value, str) or not value: raise NotificationParseError( f"{key} должен быть непустой str, получено {type(value).__name__}={value!r}" ) return value