"""Сборка объекта `Receipt` (54-ФЗ, ФФД 1.05) для чека Т-Банк эквайринга. Продукт продаёт УСЛУГУ (не товар) — везде фиксированы `PaymentObject="service"` и `PaymentMethod="full_payment"` (одномоментная оплата за уже готовую услугу, без предоплат/кредита/частичных расчётов). Схема (`Receipt` в `Init`, ФФД 1.05) — источник, снят живым запросом 2026-08-06: https://developer.tbank.ru/eacq/api/init - `Email` ИЛИ `Phone` — обязательно хотя бы одно (перекрёстный required). - `Taxation` — обязателен: `osn|usn_income|usn_income_outcome|esn|patent`. - `Items[].Name` — <=128 символов, обязателен. - `Items[].Price`/`Quantity`/`Amount` — числа, В КОПЕЙКАХ; `Amount` — это произведение `Price * Quantity` (дословно из API-reference). - `Items[].Tax` — ставка НДС. Актуальный список 2026 (Init API reference): `none|vat0|vat5|vat7|vat10|vat22|vat105|vat107|vat110|vat122`. `vat20`/`vat120` В СПИСКЕ НЕТ — сняты, не использовать (см. recon §6/§11 в `mera-tbank-acquiring-recon.md`, корень репо). ВАЖНО: `Receipt` НЕ участвует в расчёте `Token` (`token.py` отсекает любые вложенные `dict`/`list` из подписи) — это архитектурно гарантировано самой функцией `token.sign`, а не соглашением здесь. """ from __future__ import annotations from dataclasses import dataclass from typing import Any, Literal TaxRate = Literal[ "none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122" ] Taxation = Literal["osn", "usn_income", "usn_income_outcome", "esn", "patent"] _ALLOWED_TAX_RATES: frozenset[str] = frozenset( {"none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"} ) _ALLOWED_TAXATION: frozenset[str] = frozenset( {"osn", "usn_income", "usn_income_outcome", "esn", "patent"} ) _MAX_ITEM_NAME_LEN = 128 _MAX_ITEMS = 100 # "Количество товаров в чеке — не больше 100" (API reference) class ReceiptBuildError(ValueError): """Невалидные данные для сборки Receipt — не пройдёт валидацию Т-Банка.""" @dataclass(frozen=True, slots=True) class ReceiptItem: """Одна позиция чека — услуга. `price_kopecks`/`quantity` — целые копейки/штуки.""" name: str price_kopecks: int quantity: int = 1 tax: TaxRate = "none" @property def amount_kopecks(self) -> int: """Items[].Amount = Price * Quantity (дословно из API reference).""" return self.price_kopecks * self.quantity def to_payload(self) -> dict[str, Any]: if not self.name or len(self.name) > _MAX_ITEM_NAME_LEN: raise ReceiptBuildError( f"Items[].Name должен быть 1..{_MAX_ITEM_NAME_LEN} символов, " f"получено {len(self.name)}" ) if self.price_kopecks <= 0: raise ReceiptBuildError("Items[].Price должен быть > 0 (в копейках)") if self.quantity <= 0: raise ReceiptBuildError("Items[].Quantity должен быть > 0") if self.tax not in _ALLOWED_TAX_RATES: raise ReceiptBuildError( f"Items[].Tax={self.tax!r} не входит в актуальный список Т-Банка " f"({sorted(_ALLOWED_TAX_RATES)}) — vat20/vat120 сняты, не используются" ) return { "Name": self.name, "Price": self.price_kopecks, "Quantity": self.quantity, "Amount": self.amount_kopecks, "Tax": self.tax, "PaymentMethod": "full_payment", "PaymentObject": "service", } def build_receipt( *, items: list[ReceiptItem], taxation: Taxation, email: str | None = None, phone: str | None = None, ) -> dict[str, Any]: """Собирает `Receipt` (ФФД 1.05) для одного заказа (может быть >1 позиции). Инвариант «сумма Items[].Amount == Init.Amount» здесь НЕ проверяется — `Receipt` строится независимо от `Init`-payload заказа. Сверка — на вызывающей стороне (`service.py`, следующий PR) через `receipt_total_kopecks(receipt) == init_amount_kopecks`. См. тест `test_receipt_total_matches_order_amount_invariant` в `tests/test_payments_receipt.py`, который проверяет именно эту сверку. """ if not items: raise ReceiptBuildError("Receipt.Items не может быть пустым") if len(items) > _MAX_ITEMS: raise ReceiptBuildError(f"Receipt.Items — не больше {_MAX_ITEMS} позиций") if taxation not in _ALLOWED_TAXATION: raise ReceiptBuildError( f"Taxation={taxation!r} не входит в допустимый список ({sorted(_ALLOWED_TAXATION)})" ) email_norm = (email or "").strip() or None phone_norm = (phone or "").strip() or None if not email_norm and not phone_norm: raise ReceiptBuildError("Нужно указать Email или Phone (хотя бы одно)") payload: dict[str, Any] = { "Taxation": taxation, "Items": [item.to_payload() for item in items], } if email_norm: payload["Email"] = email_norm if phone_norm: payload["Phone"] = phone_norm return payload def receipt_total_kopecks(receipt: dict[str, Any]) -> int: """Сумма `Items[].Amount` — для сверки вызывающей стороной с `Init.Amount`.""" items = receipt.get("Items") if not isinstance(items, list): return 0 total = 0 for item in items: if isinstance(item, dict): amount = item.get("Amount") if isinstance(amount, int): total += amount return total