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

143 lines
6.3 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.

"""Сборка объекта `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