- Recurring service: auto-detects direct debits/subscriptions from CSV imports using frequency analysis; manual toggle in transaction detail drawer - Subscriptions page (/subscriptions): groups recurring payments with monthly cost equivalents, next-payment badges, and re-scan trigger - UK Tax page (/tax): payslips/P60 entry, income tax + NI + CGT + dividend tax calculations, configurable rate tables per tax year (pre-seeded 2024/25 and 2025/26), editable in-app so Budget changes need no rebuild - Migration 0006: tax_rate_configs, tax_profiles, payslips, manual_cgt_disposals with RLS; seeds 2025/2026 rate configs for existing users - Chart tooltip fix: all Recharts tooltips now use TOOLTIP_STYLE constant so they render correctly across all dark/light themes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
262 lines
8.1 KiB
Python
262 lines
8.1 KiB
Python
"""
|
|
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})
|