""" Schema round-trip tests for tax.py Pydantic models. Verifies that each schema accepts valid data, rejects invalid data, and that the nested TaxReportResponse correctly validates the shape returned by build_tax_report(). """ import sys, os sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import uuid from datetime import date from decimal import Decimal import pytest from pydantic import ValidationError from app.schemas.tax import ( ManualDisposalCreate, ManualDisposalUpdate, P60Entry, PayslipCreate, PayslipUpdate, TaxProfileCreate, TaxRateConfigUpdate, TaxReportResponse, ) # --------------------------------------------------------------------------- # TaxRateConfigUpdate # --------------------------------------------------------------------------- class TestTaxRateConfigUpdate: def test_partial_update_accepted(self): u = TaxRateConfigUpdate(cgt={"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24}) assert u.cgt["exempt"] == 3000 assert u.income_tax is None def test_all_none_valid(self): u = TaxRateConfigUpdate() assert u.income_tax is None assert u.ni is None # --------------------------------------------------------------------------- # TaxProfileCreate # --------------------------------------------------------------------------- class TestTaxProfileCreate: def test_defaults(self): p = TaxProfileCreate() assert p.tax_code == "1257L" assert p.is_cumulative is True assert p.employer_name is None def test_custom_values(self): p = TaxProfileCreate(tax_code="BR", employer_name="Acme Ltd", is_cumulative=False) assert p.tax_code == "BR" assert p.employer_name == "Acme Ltd" def test_tax_code_too_long(self): with pytest.raises(ValidationError): TaxProfileCreate(tax_code="X" * 21) # --------------------------------------------------------------------------- # PayslipCreate # --------------------------------------------------------------------------- class TestPayslipCreate: def test_valid(self): p = PayslipCreate( period_month=4, period_year=2024, gross_pay=Decimal("3000.00"), income_tax_withheld=Decimal("286.00"), ni_withheld=Decimal("220.00"), net_pay=Decimal("2494.00"), ) assert p.period_month == 4 assert p.gross_pay == Decimal("3000.00") def test_invalid_month(self): with pytest.raises(ValidationError): PayslipCreate( period_month=13, period_year=2024, gross_pay=Decimal("3000"), income_tax_withheld=Decimal("286"), ni_withheld=Decimal("220"), net_pay=Decimal("2494"), ) def test_negative_gross_rejected(self): with pytest.raises(ValidationError): PayslipCreate( period_month=4, period_year=2024, gross_pay=Decimal("-1"), income_tax_withheld=Decimal("0"), ni_withheld=Decimal("0"), net_pay=Decimal("0"), ) def test_p60_no_month(self): p = PayslipCreate( period_month=None, period_year=2024, gross_pay=Decimal("36000"), income_tax_withheld=Decimal("4686"), ni_withheld=Decimal("2394"), net_pay=Decimal("28920"), ) assert p.period_month is None # --------------------------------------------------------------------------- # P60Entry # --------------------------------------------------------------------------- class TestP60Entry: def test_valid(self): e = P60Entry( gross_pay=Decimal("36000"), income_tax_withheld=Decimal("4686"), ni_withheld=Decimal("2394"), net_pay=Decimal("28920"), ) assert e.gross_pay == Decimal("36000") def test_negative_rejected(self): with pytest.raises(ValidationError): P60Entry( gross_pay=Decimal("-1"), income_tax_withheld=Decimal("0"), ni_withheld=Decimal("0"), net_pay=Decimal("0"), ) # --------------------------------------------------------------------------- # ManualDisposalCreate # --------------------------------------------------------------------------- class TestManualDisposalCreate: def test_valid(self): d = ManualDisposalCreate( disposal_date=date(2025, 1, 15), asset_description="Rental property", proceeds=Decimal("250000"), cost_basis=Decimal("200000"), ) assert d.proceeds == Decimal("250000") assert d.notes is None def test_empty_description_rejected(self): with pytest.raises(ValidationError): ManualDisposalCreate( disposal_date=date(2025, 1, 15), asset_description="", proceeds=Decimal("1000"), cost_basis=Decimal("500"), ) def test_negative_proceeds_rejected(self): with pytest.raises(ValidationError): ManualDisposalCreate( disposal_date=date(2025, 1, 15), asset_description="Something", proceeds=Decimal("-1"), cost_basis=Decimal("500"), ) # --------------------------------------------------------------------------- # TaxReportResponse — validates the full nested report shape # --------------------------------------------------------------------------- SAMPLE_REPORT = { "tax_year": 2025, "tax_year_display": "2024/25", "profile": { "id": str(uuid.uuid4()), "tax_year": 2025, "tax_code": "1257L", "employer_name": "Acme Ltd", "is_cumulative": True, "created_at": "2025-01-01T00:00:00+00:00", "updated_at": "2025-01-01T00:00:00+00:00", }, "income": { "gross_income": "45000.00", "income_tax_withheld": "6486.00", "ni_withheld": "2634.00", "payslips": [], }, "income_tax": { "personal_allowance": "12570.00", "taxable_income": "32430.00", "liability": "6486.00", "band_breakdown": [{"rate": 0.20, "taxable": 32430.0, "tax": 6486.0}], "withheld": "6486.00", "owed": "0.00", }, "ni": { "liability": "2634.00", "band_breakdown": [{"rate": 0.08, "taxable": 32430.0, "tax": 2594.4}], "withheld": "2634.00", "owed": "0.00", }, "cgt": { "gross_gain": "0.00", "exempt": "0.00", "taxable_gain": "0.00", "liability": "0.00", "band_breakdown": [], "investment_disposals": [], "manual_disposals": [], "total_gain": "0.00", }, "dividends": { "gross_dividends": "0.00", "allowance": "0.00", "taxable_dividends": "0.00", "liability": "0.00", "band_breakdown": [], "dividend_transactions": [], }, "summary": { "total_liability": "9120.00", "total_withheld": "9120.00", "net_owed": "0.00", "overpaid": False, }, } class TestTaxReportResponse: def test_valid_report_parses(self): r = TaxReportResponse(**SAMPLE_REPORT) assert r.tax_year == 2025 assert r.tax_year_display == "2024/25" assert r.summary.net_owed == "0.00" assert r.summary.overpaid is False def test_no_profile(self): report = {**SAMPLE_REPORT, "profile": None} r = TaxReportResponse(**report) assert r.profile is None def test_missing_summary_field_rejected(self): bad_summary = {**SAMPLE_REPORT["summary"]} del bad_summary["overpaid"] with pytest.raises(ValidationError): TaxReportResponse(**{**SAMPLE_REPORT, "summary": bad_summary}) def test_missing_income_field_rejected(self): bad_income = {**SAMPLE_REPORT["income"]} del bad_income["gross_income"] with pytest.raises(ValidationError): TaxReportResponse(**{**SAMPLE_REPORT, "income": bad_income})