gendesign/tradein-mvp/backend/tests/test_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

208 lines
8.1 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.

"""Тесты `app.services.payments.receipt` — сборка Receipt (ФФД 1.05, услуга)."""
from __future__ import annotations
import pytest
from app.services.payments.receipt import (
ReceiptBuildError,
ReceiptItem,
build_receipt,
receipt_total_kopecks,
)
def _item(price_kopecks: int = 99000, quantity: int = 1, tax: str = "none") -> ReceiptItem:
return ReceiptItem(
name="Отчёт об оценке квартиры (электронный)",
price_kopecks=price_kopecks,
quantity=quantity,
tax=tax, # type: ignore[arg-type]
)
# ── happy path ────────────────────────────────────────────────────────────────
def test_build_receipt_happy_path_with_email() -> None:
receipt = build_receipt(items=[_item()], taxation="usn_income", email="buyer@example.com")
assert receipt["Taxation"] == "usn_income"
assert receipt["Email"] == "buyer@example.com"
assert "Phone" not in receipt
assert len(receipt["Items"]) == 1
item = receipt["Items"][0]
assert item["Name"] == "Отчёт об оценке квартиры (электронный)"
assert item["Price"] == 99000
assert item["Quantity"] == 1
assert item["Amount"] == 99000
assert item["Tax"] == "none"
assert item["PaymentMethod"] == "full_payment"
assert item["PaymentObject"] == "service"
def test_build_receipt_happy_path_with_phone_only() -> None:
receipt = build_receipt(items=[_item()], taxation="osn", phone="+79990000000")
assert receipt["Phone"] == "+79990000000"
assert "Email" not in receipt
def test_build_receipt_accepts_both_email_and_phone() -> None:
receipt = build_receipt(
items=[_item()], taxation="osn", email="buyer@example.com", phone="+79990000000"
)
assert receipt["Email"] == "buyer@example.com"
assert receipt["Phone"] == "+79990000000"
def test_amount_is_price_times_quantity() -> None:
item = _item(price_kopecks=10000, quantity=3)
assert item.amount_kopecks == 30000
payload = item.to_payload()
assert payload["Amount"] == 30000
# ── Email/Phone обязательность ──────────────────────────────────────────────
def test_build_receipt_requires_email_or_phone() -> None:
with pytest.raises(ReceiptBuildError, match=r"Email.*Phone|Phone.*Email"):
build_receipt(items=[_item()], taxation="osn")
def test_build_receipt_rejects_blank_email_and_phone() -> None:
with pytest.raises(ReceiptBuildError):
build_receipt(items=[_item()], taxation="osn", email=" ", phone="")
# ── Items[].Name длина ───────────────────────────────────────────────────────
def test_item_name_exactly_128_chars_is_ok() -> None:
item = ReceiptItem(name="A" * 128, price_kopecks=1000)
payload = item.to_payload()
assert payload["Name"] == "A" * 128
def test_item_name_over_128_chars_rejected() -> None:
item = ReceiptItem(name="A" * 129, price_kopecks=1000)
with pytest.raises(ReceiptBuildError, match="128"):
item.to_payload()
def test_item_name_empty_rejected() -> None:
item = ReceiptItem(name="", price_kopecks=1000)
with pytest.raises(ReceiptBuildError):
item.to_payload()
# ── Price / Quantity валидация ──────────────────────────────────────────────
def test_item_zero_price_rejected() -> None:
item = ReceiptItem(name="X", price_kopecks=0)
with pytest.raises(ReceiptBuildError):
item.to_payload()
def test_item_negative_price_rejected() -> None:
item = ReceiptItem(name="X", price_kopecks=-100)
with pytest.raises(ReceiptBuildError):
item.to_payload()
def test_item_zero_quantity_rejected() -> None:
item = ReceiptItem(name="X", price_kopecks=100, quantity=0)
with pytest.raises(ReceiptBuildError):
item.to_payload()
# ── Taxation / Tax enum ──────────────────────────────────────────────────────
@pytest.mark.parametrize("taxation", ["osn", "usn_income", "usn_income_outcome", "esn", "patent"])
def test_all_documented_taxation_values_accepted(taxation: str) -> None:
build_receipt(items=[_item()], taxation=taxation, email="a@test.ru") # type: ignore[arg-type]
def test_unknown_taxation_rejected() -> None:
with pytest.raises(ReceiptBuildError):
build_receipt(
items=[_item()],
taxation="usn",
email="a@test.ru", # type: ignore[arg-type]
)
@pytest.mark.parametrize(
"tax_rate",
["none", "vat0", "vat5", "vat7", "vat10", "vat22", "vat105", "vat107", "vat110", "vat122"],
)
def test_all_documented_2026_tax_rates_accepted(tax_rate: str) -> None:
"""Актуальный список 2026 года — все значения проходят валидацию."""
item = _item(tax=tax_rate)
payload = item.to_payload()
assert payload["Tax"] == tax_rate
@pytest.mark.parametrize("removed_rate", ["vat20", "vat120"])
def test_removed_vat20_vat120_rates_rejected(removed_rate: str) -> None:
"""vat20/vat120 сняты с актуального списка 2026 — не должны проходить."""
item = _item(tax=removed_rate)
with pytest.raises(ReceiptBuildError, match="vat20/vat120"):
item.to_payload()
# ── Items[] границы ──────────────────────────────────────────────────────────
def test_build_receipt_rejects_empty_items() -> None:
with pytest.raises(ReceiptBuildError):
build_receipt(items=[], taxation="osn", email="a@test.ru")
def test_build_receipt_rejects_more_than_100_items() -> None:
items = [_item() for _ in range(101)]
with pytest.raises(ReceiptBuildError, match="100"):
build_receipt(items=items, taxation="osn", email="a@test.ru")
def test_build_receipt_accepts_exactly_100_items() -> None:
items = [_item() for _ in range(100)]
receipt = build_receipt(items=items, taxation="osn", email="a@test.ru")
assert len(receipt["Items"]) == 100
# ── инвариант: сумма Items[].Amount == сумме заказа ─────────────────────────
def test_receipt_total_matches_order_amount_invariant() -> None:
"""Ключевой инвариант задачи: сумма Items[].Amount == общей сумме заказа."""
order_amount_kopecks = 148500
items = [
ReceiptItem(name="Отчёт об оценке", price_kopecks=99000, quantity=1, tax="none"),
ReceiptItem(name="Персональный оффер", price_kopecks=49500, quantity=1, tax="none"),
]
receipt = build_receipt(items=items, taxation="usn_income", email="a@test.ru")
assert receipt_total_kopecks(receipt) == order_amount_kopecks
def test_receipt_total_multi_quantity_item() -> None:
items = [ReceiptItem(name="Оценка", price_kopecks=5000, quantity=4, tax="vat22")]
receipt = build_receipt(items=items, taxation="osn", email="a@test.ru")
assert receipt_total_kopecks(receipt) == 20000
def test_receipt_total_kopecks_empty_items_key_returns_zero() -> None:
assert receipt_total_kopecks({"Taxation": "osn"}) == 0
def test_receipt_total_mismatch_detected_by_caller() -> None:
"""Демонстрирует, как вызывающая сторона обязана сверять сумму с Init.Amount."""
items = [ReceiptItem(name="Оценка", price_kopecks=10000, quantity=1, tax="none")]
receipt = build_receipt(items=items, taxation="osn", email="a@test.ru")
wrong_init_amount_kopecks = 99999
assert receipt_total_kopecks(receipt) != wrong_init_amount_kopecks