Add recurring transaction detection, subscriptions page, and UK tax reporting
- 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>
This commit is contained in:
parent
0b326cbd87
commit
afb5e99bb2
48 changed files with 6238 additions and 39 deletions
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
9
backend/tests/conftest.py
Normal file
9
backend/tests/conftest.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""
|
||||
Conftest for backend tests.
|
||||
Pure calculation tests (test_tax_calculations.py) import from tax_calculations.py
|
||||
which has no external dependencies, so no stubs are needed.
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
311
backend/tests/test_tax_calculations.py
Normal file
311
backend/tests/test_tax_calculations.py
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
"""
|
||||
Unit tests for the pure tax calculation functions.
|
||||
|
||||
Reference figures verified against HMRC's tax calculator and HMRC guidance.
|
||||
All tests use the 2025/26 frozen rate structure (same as seed data).
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
import pytest
|
||||
|
||||
from app.services.tax_calculations import (
|
||||
tax_year_for_date,
|
||||
parse_tax_code,
|
||||
calculate_income_tax,
|
||||
calculate_ni,
|
||||
calculate_cgt,
|
||||
calculate_dividend_tax,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared rate fixture (mirrors the seeded data)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RATES = {
|
||||
"income_tax": {
|
||||
"bands": [
|
||||
{"from": 0, "to": 12570, "rate": 0.00},
|
||||
{"from": 12570, "to": 50270, "rate": 0.20},
|
||||
{"from": 50270, "to": 125140, "rate": 0.40},
|
||||
{"from": 125140, "to": None, "rate": 0.45},
|
||||
]
|
||||
},
|
||||
"ni": {
|
||||
"bands": [
|
||||
{"from": 0, "to": 12570, "rate": 0.00},
|
||||
{"from": 12570, "to": 50270, "rate": 0.08},
|
||||
{"from": 50270, "to": None, "rate": 0.02},
|
||||
]
|
||||
},
|
||||
"cgt": {"exempt": 3000, "basic_rate": 0.18, "higher_rate": 0.24},
|
||||
"dividend": {
|
||||
"allowance": 500,
|
||||
"basic_rate": 0.0875,
|
||||
"higher_rate": 0.3375,
|
||||
"additional_rate": 0.3935,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tax_year_for_date
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTaxYearForDate:
|
||||
def test_before_april_6(self):
|
||||
assert tax_year_for_date(date(2025, 4, 5)) == 2025
|
||||
|
||||
def test_on_april_6(self):
|
||||
assert tax_year_for_date(date(2025, 4, 6)) == 2026
|
||||
|
||||
def test_mid_year(self):
|
||||
assert tax_year_for_date(date(2025, 10, 1)) == 2026
|
||||
|
||||
def test_january(self):
|
||||
assert tax_year_for_date(date(2026, 1, 15)) == 2026
|
||||
|
||||
def test_april_5_boundary(self):
|
||||
# 5 April 2024 → tax_year 2024
|
||||
assert tax_year_for_date(date(2024, 4, 5)) == 2024
|
||||
|
||||
def test_april_6_boundary(self):
|
||||
# 6 April 2024 → tax_year 2025
|
||||
assert tax_year_for_date(date(2024, 4, 6)) == 2025
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_tax_code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseTaxCode:
|
||||
def test_standard_1257l(self):
|
||||
r = parse_tax_code("1257L")
|
||||
assert r["allowance"] == Decimal("12570")
|
||||
assert r["rate_override"] is None
|
||||
assert r["k_code"] is False
|
||||
assert r["no_tax"] is False
|
||||
|
||||
def test_standard_1257m(self):
|
||||
assert parse_tax_code("1257M")["allowance"] == Decimal("12570")
|
||||
|
||||
def test_standard_1257n(self):
|
||||
assert parse_tax_code("1257N")["allowance"] == Decimal("12570")
|
||||
|
||||
def test_br(self):
|
||||
r = parse_tax_code("BR")
|
||||
assert r["allowance"] == Decimal("0")
|
||||
assert r["rate_override"] == Decimal("0.20")
|
||||
|
||||
def test_d0(self):
|
||||
r = parse_tax_code("D0")
|
||||
assert r["rate_override"] == Decimal("0.40")
|
||||
|
||||
def test_d1(self):
|
||||
r = parse_tax_code("D1")
|
||||
assert r["rate_override"] == Decimal("0.45")
|
||||
|
||||
def test_nt(self):
|
||||
r = parse_tax_code("NT")
|
||||
assert r["no_tax"] is True
|
||||
|
||||
def test_0t(self):
|
||||
r = parse_tax_code("0T")
|
||||
assert r["allowance"] == Decimal("0")
|
||||
assert r["rate_override"] is None
|
||||
|
||||
def test_k_code(self):
|
||||
r = parse_tax_code("K100")
|
||||
assert r["allowance"] == Decimal("-1000")
|
||||
assert r["k_code"] is True
|
||||
|
||||
def test_k_code_large(self):
|
||||
r = parse_tax_code("K497")
|
||||
assert r["allowance"] == Decimal("-4970")
|
||||
|
||||
def test_w1_suffix_stripped(self):
|
||||
r = parse_tax_code("1257L W1")
|
||||
assert r["allowance"] == Decimal("12570")
|
||||
|
||||
def test_m1_suffix_stripped(self):
|
||||
r = parse_tax_code("1257L/M1")
|
||||
assert r["allowance"] == Decimal("12570")
|
||||
|
||||
def test_lowercase(self):
|
||||
r = parse_tax_code("1257l")
|
||||
assert r["allowance"] == Decimal("12570")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_income_tax
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalculateIncomeTax:
|
||||
def test_below_personal_allowance(self):
|
||||
r = calculate_income_tax(Decimal("10000"), "1257L", RATES)
|
||||
assert r["liability"] == Decimal("0")
|
||||
assert r["taxable_income"] == Decimal("0")
|
||||
assert r["personal_allowance"] == Decimal("12570")
|
||||
|
||||
def test_at_personal_allowance_boundary(self):
|
||||
r = calculate_income_tax(Decimal("12570"), "1257L", RATES)
|
||||
assert r["liability"] == Decimal("0")
|
||||
|
||||
def test_basic_rate_salary_30k(self):
|
||||
# £30,000 gross — taxable = £17,430, basic rate 20%
|
||||
r = calculate_income_tax(Decimal("30000"), "1257L", RATES)
|
||||
assert r["taxable_income"] == Decimal("17430")
|
||||
assert r["liability"] == Decimal("3486.00")
|
||||
|
||||
def test_at_higher_rate_threshold(self):
|
||||
# £50,270 gross — exactly at the basic rate upper; taxable = £37,700
|
||||
r = calculate_income_tax(Decimal("50270"), "1257L", RATES)
|
||||
assert r["taxable_income"] == Decimal("37700")
|
||||
# 20% on (50270 - 12570) = 37700
|
||||
expected = (Decimal("37700") * Decimal("0.20")).quantize(Decimal("0.01"))
|
||||
assert r["liability"] == expected
|
||||
|
||||
def test_higher_rate_taxpayer_60k(self):
|
||||
# £60,000 gross, 1257L
|
||||
# 20% on (50270 - 12570) = 37700 → £7,540
|
||||
# 40% on (60000 - 50270) = 9730 → £3,892
|
||||
r = calculate_income_tax(Decimal("60000"), "1257L", RATES)
|
||||
assert r["taxable_income"] == Decimal("47430") # 60000 - 12570
|
||||
expected_basic = (Decimal("37700") * Decimal("0.20")).quantize(Decimal("0.01"))
|
||||
expected_higher = (Decimal("9730") * Decimal("0.40")).quantize(Decimal("0.01"))
|
||||
assert r["liability"] == expected_basic + expected_higher
|
||||
|
||||
def test_personal_allowance_taper_110k(self):
|
||||
# £110,000 — allowance tapered by £5,000 → £7,570
|
||||
r = calculate_income_tax(Decimal("110000"), "1257L", RATES)
|
||||
assert r["personal_allowance"] == Decimal("7570")
|
||||
|
||||
def test_personal_allowance_taper_125140(self):
|
||||
# At £125,140 allowance tapers to zero
|
||||
r = calculate_income_tax(Decimal("125140"), "1257L", RATES)
|
||||
assert r["personal_allowance"] == Decimal("0")
|
||||
|
||||
def test_personal_allowance_above_125140(self):
|
||||
# Above £125,140 allowance stays at zero
|
||||
r = calculate_income_tax(Decimal("150000"), "1257L", RATES)
|
||||
assert r["personal_allowance"] == Decimal("0")
|
||||
|
||||
def test_br_code(self):
|
||||
r = calculate_income_tax(Decimal("30000"), "BR", RATES)
|
||||
assert r["liability"] == (Decimal("30000") * Decimal("0.20")).quantize(Decimal("0.01"))
|
||||
|
||||
def test_d0_code(self):
|
||||
r = calculate_income_tax(Decimal("30000"), "D0", RATES)
|
||||
assert r["liability"] == (Decimal("30000") * Decimal("0.40")).quantize(Decimal("0.01"))
|
||||
|
||||
def test_nt_code(self):
|
||||
r = calculate_income_tax(Decimal("100000"), "NT", RATES)
|
||||
assert r["liability"] == Decimal("0")
|
||||
|
||||
def test_k_code_increases_taxable(self):
|
||||
# K100 = -£1000 allowance → taxable = gross + £1000
|
||||
r = calculate_income_tax(Decimal("30000"), "K100", RATES)
|
||||
assert r["taxable_income"] == Decimal("31000")
|
||||
|
||||
def test_remaining_basic_rate_band_basic_taxpayer(self):
|
||||
# £30k gross → remaining = 50270 - 30000 = 20270
|
||||
r = calculate_income_tax(Decimal("30000"), "1257L", RATES)
|
||||
assert r["remaining_basic_rate_band"] == Decimal("20270")
|
||||
|
||||
def test_remaining_basic_rate_band_higher_taxpayer(self):
|
||||
# £60k gross > £50270 → band exhausted
|
||||
r = calculate_income_tax(Decimal("60000"), "1257L", RATES)
|
||||
assert r["remaining_basic_rate_band"] == Decimal("0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_ni
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalculateNI:
|
||||
def test_below_threshold(self):
|
||||
r = calculate_ni(Decimal("12570"), RATES)
|
||||
assert r["liability"] == Decimal("0")
|
||||
|
||||
def test_basic_rate_salary_30k(self):
|
||||
# NI on (30000 - 12570) = £17,430 at 8% = £1,394.40
|
||||
r = calculate_ni(Decimal("30000"), RATES)
|
||||
assert r["liability"] == Decimal("1394.40")
|
||||
|
||||
def test_above_upper_earnings_limit(self):
|
||||
# NI on (50270 - 12570) = £37,700 at 8% = £3,016 + (60000 - 50270) = £9,730 at 2% = £194.60
|
||||
r = calculate_ni(Decimal("60000"), RATES)
|
||||
assert r["liability"] == Decimal("3016.00") + Decimal("194.60")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_cgt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalculateCGT:
|
||||
def test_below_exempt(self):
|
||||
r = calculate_cgt(Decimal("1000"), Decimal("20000"), RATES)
|
||||
assert r["liability"] == Decimal("0")
|
||||
assert r["taxable_gain"] == Decimal("0")
|
||||
|
||||
def test_exactly_exempt(self):
|
||||
r = calculate_cgt(Decimal("3000"), Decimal("20000"), RATES)
|
||||
assert r["liability"] == Decimal("0")
|
||||
|
||||
def test_basic_rate_taxpayer(self):
|
||||
# Gain £10,000 — exempt £3,000 — taxable £7,000 all at basic rate 18%
|
||||
r = calculate_cgt(Decimal("10000"), Decimal("20000"), RATES)
|
||||
assert r["taxable_gain"] == Decimal("7000")
|
||||
assert r["liability"] == (Decimal("7000") * Decimal("0.18")).quantize(Decimal("0.01"))
|
||||
|
||||
def test_higher_rate_taxpayer(self):
|
||||
# remaining_basic_rate_band = 0 → all at higher rate 24%
|
||||
r = calculate_cgt(Decimal("10000"), Decimal("0"), RATES)
|
||||
assert r["liability"] == (Decimal("7000") * Decimal("0.24")).quantize(Decimal("0.01"))
|
||||
|
||||
def test_split_basic_higher(self):
|
||||
# taxable gain £7,000; remaining_brb £4,000 → £4k at 18%, £3k at 24%
|
||||
r = calculate_cgt(Decimal("10000"), Decimal("4000"), RATES)
|
||||
expected = (
|
||||
(Decimal("4000") * Decimal("0.18")) +
|
||||
(Decimal("3000") * Decimal("0.24"))
|
||||
).quantize(Decimal("0.01"))
|
||||
assert r["liability"] == expected
|
||||
|
||||
def test_negative_gain_no_tax(self):
|
||||
r = calculate_cgt(Decimal("-5000"), Decimal("20000"), RATES)
|
||||
assert r["liability"] == Decimal("0")
|
||||
assert r["taxable_gain"] == Decimal("0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# calculate_dividend_tax
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCalculateDividendTax:
|
||||
def test_within_allowance(self):
|
||||
r = calculate_dividend_tax(Decimal("400"), Decimal("20000"), RATES)
|
||||
assert r["liability"] == Decimal("0")
|
||||
|
||||
def test_exactly_allowance(self):
|
||||
r = calculate_dividend_tax(Decimal("500"), Decimal("20000"), RATES)
|
||||
assert r["liability"] == Decimal("0")
|
||||
|
||||
def test_basic_rate_band(self):
|
||||
# £1,500 dividends — allowance £500 — taxable £1,000 at basic 8.75%
|
||||
r = calculate_dividend_tax(Decimal("1500"), Decimal("20000"), RATES)
|
||||
assert r["taxable_dividends"] == Decimal("1000")
|
||||
assert r["liability"] == (Decimal("1000") * Decimal("0.0875")).quantize(Decimal("0.01"))
|
||||
|
||||
def test_higher_rate_band(self):
|
||||
# Remaining basic = 0 → taxable £1,000 at higher 33.75%
|
||||
r = calculate_dividend_tax(Decimal("1500"), Decimal("0"), RATES)
|
||||
assert r["liability"] == (Decimal("1000") * Decimal("0.3375")).quantize(Decimal("0.01"))
|
||||
|
||||
def test_no_dividends(self):
|
||||
r = calculate_dividend_tax(Decimal("0"), Decimal("20000"), RATES)
|
||||
assert r["liability"] == Decimal("0")
|
||||
262
backend/tests/test_tax_schemas.py
Normal file
262
backend/tests/test_tax_schemas.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"""
|
||||
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})
|
||||
Loading…
Add table
Add a link
Reference in a new issue