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
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>
143 lines
6.3 KiB
Python
143 lines
6.3 KiB
Python
"""Сборка объекта `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
|