"""Тесты `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