diff --git a/changelog.d/2367.fixed.md b/changelog.d/2367.fixed.md new file mode 100644 index 0000000000..4f15a5b0d9 --- /dev/null +++ b/changelog.d/2367.fixed.md @@ -0,0 +1 @@ +- Charged income tax on statutory paternity pay, which ITEPA 2003 s. 660 Table A lists as taxable with statutory sick and maternity pay, and counted it as earnings in Housing Benefit and Council Tax Reduction income, so the tax on it does not raise those awards. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml index d0370fcfbc..eb2aa87c0e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml @@ -263,3 +263,27 @@ housing_benefit: 6_780.29 housing_benefit_applicable_income: 9_740 housing_benefit_net_earnings: 10_000 + +- name: Statutory paternity pay counts as income and the income tax on it comes off + # SI 2006/213 reg 35(1)(i): statutory paternity pay is earnings. ITEPA 2003 + # s. 660 taxes it, and reg 36(3) deducts the income tax and Class 1 NI on + # the earnings. 2026: two weeks at £194.32 is 388.64. + # Income tax 0.2 x (20,388.64 - 12,570) = 1,563.728. + # NI 0.08 x (20,388.64 - 12,569.96) = 625.4944. + # Sch 4 para 10 disregards £5 a week of a single claimant's earnings: 260. + # 20,388.64 - 1,563.728 - 625.4944 - 260 = 17,939.4176. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + employment_income: 20_000 + statutory_paternity_pay: 388.64 + benunits: + benunit: + members: [person] + would_claim_uc: false # not on Universal Credit, which passports the means test + output: + housing_benefit_applicable_income_disregard: 260 + housing_benefit_applicable_income: 17_939.42 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_net_earnings.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_net_earnings.yaml index 81e4733ddc..095a30e5ac 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_net_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_net_earnings.yaml @@ -204,3 +204,38 @@ # attributed to them. No NI above State Pension age. # 10,000 - 743 = 9,257. (Dividing by 15,000 would attribute 990.67.) housing_benefit_net_earnings: 9_257 + +- name: Statutory paternity pay is earnings, and the income tax on it is deducted + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + employment_income: 20_000 + statutory_paternity_pay: 388.64 # two weeks at £194.32 + benunits: + benunit: + members: [person] + output: + # SI 2006/213 reg 35(1)(i). ITEPA 2003 s. 660 taxes the payment. + # Income tax 0.2 x (20,388.64 - 12,570) = 1,563.728. + # NI 0.08 x (20,388.64 - 12,569.96) = 625.4944. + # 20,388.64 - 1,563.728 - 625.4944 = 18,199.4176. + housing_benefit_net_earnings: 18_199.42 + +- name: Statutory paternity pay is earnings at pension age + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + statutory_paternity_pay: 388.64 + benunits: + benunit: + members: [person] + output: + # SI 2006/214 reg 35(1)(i). Within the personal allowance, and no NI + # above State Pension age. + housing_benefit_net_earnings: 388.64 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.yaml index d6027e8a89..7a241de8df 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.yaml @@ -535,6 +535,27 @@ housing_benefit_specified_or_temporary_accommodation_disregard: 4_041.96 housing_benefit_applicable_income_disregard: 4_301.96 # (£5 + £77.73) × 52 +- name: Statutory paternity pay alone makes the claimant an employed earner + period: 2027 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + statutory_paternity_pay: 388.64 # earnings under reg 35(1)(i) + benunits: + benunit: + would_claim_uc: false # not on Universal Credit, which passports the means test + members: [person] + in_specified_or_temporary_accommodation: true + output: + housing_benefit_net_earnings: 388.64 + # Para 18(1)(b) is met, so the full amount applies... + housing_benefit_specified_or_temporary_accommodation_disregard: 4_041.96 + # ...but the disregards, (£5 + £77.73) x 52 = 4,301.96, come off net + # earnings of 388.64. + housing_benefit_applicable_income_disregard: 388.64 + - name: A self-employment loss is still self-employment, but leaves no earnings to disregard period: 2027 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml index c7c8c2d29f..4c16e8fa45 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_statutory_pay.yaml @@ -8,7 +8,7 @@ # # Before this was fixed the payments were left out of earned income while the # National Insurance on them, and the income tax on sick and maternity pay, -# were still deducted (the model charges no income tax on paternity pay). Each +# were still deducted (before the paternity-pay income-tax fix). Each # pound of sick or maternity pay above the thresholds then raised the award by # 0.55 x (20% + 8%) = 15.4p; each pound of paternity pay by 0.55 x 8% = 4.4p. # @@ -252,3 +252,35 @@ household_net_income: 5_278.80 hbai_household_net_income: 5_278.80 gov_spending: 5_278.80 + +- name: Taxable statutory paternity pay reduces Universal Credit like wages + # UC Regs 2013 reg. 55(4)(c) and (5): count SPP before deducting its tax. + # Without SPP this family gets 11,308.50, so the award falls by 792. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 20_000 + statutory_paternity_pay: 2_000 + child: + age: 5 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + # 22,000 - 1,886 income tax - 754.40 Class 1 NI = 19,359.60. + income_tax: [1_886, 0] + uc_income_tax_on_earnings: [1_886, 0] + uc_national_insurance_on_earnings: [754.40, 0] + uc_individual_earned_income: [19_359.60, 0] + # Work allowance 427 x 12 = 5,124; taper 55%. + uc_earned_income: 14_235.60 + uc_maximum_amount: 18_346.08 + universal_credit: 10_516.50 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/employment_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/employment_benefits.yaml new file mode 100644 index 0000000000..5645dd81c4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/employment_benefits.yaml @@ -0,0 +1,59 @@ +# ITEPA 2003 s. 660(1) Table A lists statutory sick pay, statutory maternity +# pay and statutory paternity pay as taxable UK benefits. 2026-27: statutory +# paternity pay £194.32 a week (SI 2002/2818 reg 2(a), as substituted by SI +# 2026/148 art 10(1)(a)), so two weeks is £388.64. Personal allowance +# £12,570, basic rate 20% to £50,270 and higher rate 40% above it +# (gov.uk/income-tax-rates). No region is set, so the rest of UK rates apply. + +- name: Statutory sick, maternity and paternity pay are employment benefits + period: 2026 + absolute_error_margin: 0.01 + input: + statutory_sick_pay: 1_232.50 # 10 weeks at £123.25 + statutory_maternity_pay: 3_000 + statutory_paternity_pay: 388.64 + output: + employment_benefits: 4_621.14 # 1,232.50 + 3,000 + 388.64 + +- name: Two weeks of statutory paternity pay are taxed with wages + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + statutory_paternity_pay: 388.64 + output: + taxable_employment_income: 30_388.64 + # 0.2 x (30,388.64 - 12,570) = 3,563.728: 3,486 on the wages and 77.728, + # 20% of 388.64, on the statutory paternity pay. + income_tax: 3_563.73 + +- name: Statutory paternity pay above the basic rate limit is taxed at the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 60_000 + statutory_paternity_pay: 388.64 + output: + # 0.2 x (50,270 - 12,570) + 0.4 x (60,388.64 - 50,270) + # = 7,540 + 4,047.456 = 11,587.456. + income_tax: 11_587.46 + +- name: Statutory paternity pay within the personal allowance bears no income tax + period: 2026 + absolute_error_margin: 0.01 + input: + statutory_paternity_pay: 388.64 + output: + taxable_employment_income: 388.64 + income_tax: 0 + +- name: Statutory paternity pay is taxed as the same amount of statutory sick pay is + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 30_000 + statutory_sick_pay: 388.64 + output: + # The same as two weeks of statutory paternity pay on these wages. + taxable_employment_income: 30_388.64 + income_tax: 3_563.73 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_statutory_pay.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_statutory_pay.yaml new file mode 100644 index 0000000000..8cf09e26b8 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_statutory_pay.yaml @@ -0,0 +1,56 @@ +# Statutory paternity pay in Council Tax Reduction income. Every scheme counts +# statutory sick, maternity and paternity pay as earnings: SI 2012/2885 Sch 1 +# para 18(1)(h) to (j) and the default scheme, SI 2012/2886 Sch para 51(1)(j); +# WSI 2013/3029 Sch 1 para 12(1)(h) and (i) and Sch 6 para 14(1)(j); SSI +# 2012/319 reg 32(1)(h) and (i); SSI 2021/249 regs 49(4) and 50(2)(j). ITEPA +# 2003 s. 660 taxes them, and the schemes take the income tax and Class 1 NI +# deducted from earnings off them (for example SI 2012/2885 Sch 1 para +# 19(2)(a)). The model deducts all of a person's income tax and NI, and +# counts no working-age earnings disregard. +# +# 2026: statutory paternity pay £194.32 a week (SI 2002/2818 reg 2(a)), so +# two weeks is 388.64. Personal allowance 12,570 and basic rate 20%; NI +# primary threshold 241.73 x 52 = 12,569.96 and main rate 8%. + +- name: Statutory paternity pay counts as working-age Council Tax Reduction income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + employment_income: 20_000 + statutory_paternity_pay: 388.64 + benunits: + benunit: + members: [person] + would_claim_uc: false + households: + household: + members: [person] + country: ENGLAND + output: + # Income tax 0.2 x (20,388.64 - 12,570) = 1,563.728. + # NI 0.08 x (20,388.64 - 12,569.96) = 625.4944. + # 20,388.64 - 1,563.728 - 625.4944 = 18,199.4176. + council_tax_reduction_applicable_income: 18_199.42 + +- name: Statutory paternity pay counts as the same amount of statutory sick pay does + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + employment_income: 20_000 + statutory_sick_pay: 388.64 + benunits: + benunit: + members: [person] + would_claim_uc: false + households: + household: + members: [person] + country: ENGLAND + output: + council_tax_reduction_applicable_income: 18_199.42 diff --git a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py new file mode 100644 index 0000000000..034802a3ff --- /dev/null +++ b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py @@ -0,0 +1,378 @@ +"""Property-based tests that income tax falls on statutory paternity pay. + +ITEPA 2003 s. 660(1) Table A lists statutory paternity pay as a taxable UK +benefit, with statutory sick pay and statutory maternity pay. The model taxes +all three as employment income (employment_benefits, added to +taxable_employment_income) and charges Class 1 NI on all three +(ni_class_1_income). Housing Benefit and Council Tax Reduction count all three +as earnings (SI 2006/213 reg 35(1)(i); SI 2012/2885 Sch 1 para 18(1)(h) to +(j) and the equivalents) and deduct the tax on them. + +Invariants, for any generated population of single people and couples, with +or without children, across the four UK nations, in which one adult receives +statutory paternity pay: + +1. Monotonic: raising the recipient's statutory paternity pay, with + everything else fixed, never lowers the recipient's income tax or the + family's total income tax. A Child Benefit opt-out can lower the family's + tax as income rises (the High Income Child Benefit Charge stops when the + benefit stops), for wages too, so the generated families do not opt out. +2. Bounds: for families that do not claim Marriage Allowance, a pound more + of statutory paternity pay raises the family's income tax by at most a + pound. Income Tax Act 2007 s. 55B(2)(b) withdraws the whole Marriage + Allowance reduction once the recipient is liable above the basic rate, so + one pound can cost more than a pound there; wages do the same. +3. Same as statutory sick pay: statutory paternity pay of s gives every + member the same income tax, National Insurance, adjusted net income and + student loan repayment, and the family the same Housing Benefit and + Council Tax Reduction income and Universal Credit, as statutory sick pay + of s. Household net income is not compared: household_benefits counts + statutory sick pay and not statutory paternity pay. +4. Same as wages: statutory paternity pay of s on top of wages of e gives the + same income tax and National Insurance as wages of e + s, where the + recipient's wages and trading profit cover their pension contributions. + pension_contributions_relief caps relief at employment and self-employment + income, which leaves statutory pay out, so a recipient whose contributions + exceed those gets less relief on statutory pay than on wages. +5. Accounting: employment_benefits is statutory sick, maternity and + paternity pay, and taxable_employment_income is wages plus those less + employment deductions, floored at nil. + +Every member's is_claimant_or_partner is set from its generated role, and +every attribute is set for every member: setting an input for anyone makes it +an input for everyone. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +STATUTORY_PAY = [ + "statutory_sick_pay", + "statutory_maternity_pay", + "statutory_paternity_pay", +] +# Wages and payments on and around the 2026-27 thresholds: the personal +# allowance and primary threshold (12,570), the basic rate limit (50,270), the +# High Income Child Benefit Charge (60,000 to 80,000) and the personal +# allowance taper (100,000 to 125,140). +WAGES = [0, 0, 6_000, 12_570, 24_000, 50_000, 50_270, 62_000, 99_800, 126_000] +# Two weeks of statutory paternity pay at the 2026-27 rate is 388.64. +PAY = [0, 0, 1, 388.64, 2_000, 12_570, 30_000] +# Float32 amounts of tens of thousands of pounds are exact to about a penny. +TOLERANCE = 0.05 + +# The four versions of each family that a simulation holds: the recipient's +# statutory paternity pay at the lower and higher amount, the higher amount +# as statutory sick pay instead, and the higher amount as wages instead. +LOW, HIGH, AS_SICK_PAY, AS_WAGES = range(4) +VERSIONS = [LOW, HIGH, AS_SICK_PAY, AS_WAGES] + +# Variables that statutory paternity pay and statutory sick pay of the same +# amount must leave the same (invariant 3). +PERSON_VARIABLES_AS_SICK_PAY = [ + "employment_benefits", + "taxable_employment_income", + "adjusted_net_income", + "income_tax", + "national_insurance", + "student_loan_repayment", +] +FAMILY_VARIABLES_AS_SICK_PAY = [ + "housing_benefit_net_earnings", + "housing_benefit_applicable_income", + "council_tax_reduction_applicable_income", + "uc_earned_income", + "universal_credit", +] + + +@st.composite +def adults(draw, marriage_allowance): + return dict( + age=draw(st.sampled_from([24, 33, 45, 62, 70])), + wages=float(draw(st.sampled_from(WAGES))), + profit=float(draw(st.sampled_from([0, 0, 0, 9_000, 30_000, -4_000]))), + pension_income=float(draw(st.sampled_from([0, 0, 6_000, 20_000]))), + savings=float(draw(st.sampled_from([0, 0, 2_000, 15_000]))), + dividends=float(draw(st.sampled_from([0, 0, 1_000, 20_000]))), + pension_contributions=float(draw(st.sampled_from([0, 0, 2_000, 15_000]))), + student_loan_plan=draw(st.sampled_from(["NONE", "NONE", "PLAN_2"])), + claims_marriage_allowance=marriage_allowance and draw(st.booleans()), + ) + + +@st.composite +def families(draw, marriage_allowance=True): + members = draw(st.lists(adults(marriage_allowance), min_size=1, max_size=2)) + amounts = st.one_of(st.sampled_from(PAY), st.integers(0, 40_000)) + low, high = sorted([draw(amounts), draw(amounts)]) + return dict( + adults=members, + married=len(members) == 2 and draw(st.booleans()), + child_ages=draw(st.lists(st.sampled_from([0, 4, 11]), max_size=3)), + region=draw( + st.sampled_from( + ["NORTH_EAST", "LONDON", "SCOTLAND", "WALES", "NORTHERN_IRELAND"] + ) + ), + tenure=draw(st.sampled_from(["RENT_FROM_COUNCIL", "RENT_PRIVATELY"])), + rent=float(draw(st.sampled_from([0, 7_200, 18_000]))), + would_claim_uc=draw(st.booleans()), + recipient=draw(st.integers(0, len(members) - 1)), + low=float(low), + high=float(high), + ) + + +def populations(**kwargs): + # Several families share one simulation, which costs little more than + # one family does. + return st.lists(families(**kwargs), min_size=4, max_size=10) + + +def member_inputs(family, index, version): + """Inputs for one member of one version of a family.""" + is_adult = index < len(family["adults"]) + is_recipient = index == family["recipient"] + paternity_pay = {LOW: family["low"], HIGH: family["high"]}.get(version, 0.0) + sick_pay = family["high"] if version == AS_SICK_PAY else 0.0 + extra_wages = family["high"] if version == AS_WAGES else 0.0 + if is_adult: + adult = family["adults"][index] + inputs = { + "age": adult["age"], + "is_claimant_or_partner": True, + "marital_status": "MARRIED" if family["married"] else "SINGLE", + "employment_income": adult["wages"] + extra_wages * is_recipient, + "self_employment_income": adult["profit"], + "private_pension_income": adult["pension_income"], + "savings_interest_income": adult["savings"], + "dividend_income": adult["dividends"], + "employee_pension_contributions": adult["pension_contributions"], + "student_loan_plan": adult["student_loan_plan"], + "would_claim_marriage_allowance": adult["claims_marriage_allowance"], + } + else: + inputs = { + "age": family["child_ages"][index - len(family["adults"])], + "is_claimant_or_partner": False, + "marital_status": "SINGLE", + "employment_income": 0.0, + "self_employment_income": 0.0, + "private_pension_income": 0.0, + "savings_interest_income": 0.0, + "dividend_income": 0.0, + "employee_pension_contributions": 0.0, + "student_loan_plan": "NONE", + "would_claim_marriage_allowance": False, + } + inputs["statutory_paternity_pay"] = paternity_pay * is_recipient + inputs["statutory_sick_pay"] = sick_pay * is_recipient + inputs["statutory_maternity_pay"] = 0.0 + return {name: {YEAR: value} for name, value in inputs.items()} + + +class Results: + """One simulation holding every family once per version.""" + + def __init__(self, population): + people, benunits, households = {}, {}, {} + self.first_member = {} + for i, family in enumerate(population): + size = len(family["adults"]) + len(family["child_ages"]) + for version in VERSIONS: + self.first_member[i, version] = len(people) + names = [f"person_{i}_{version}_{j}" for j in range(size)] + for j, name in enumerate(names): + people[name] = member_inputs(family, j, version) + benunits[f"benunit_{i}_{version}"] = { + "members": names, + "would_claim_uc": {YEAR: family["would_claim_uc"]}, + "child_benefit_opts_out": {YEAR: False}, + } + households[f"household_{i}_{version}"] = { + "members": names, + "region": {YEAR: family["region"]}, + "tenure_type": {YEAR: family["tenure"]}, + "rent": {YEAR: family["rent"]}, + } + self.population = population + self.people = len(people) + self.simulation = Simulation( + situation=dict(people=people, benunits=benunits, households=households) + ) + self._cache = {} + + def _values(self, variable): + if variable not in self._cache: + self._cache[variable] = np.array( + self.simulation.calculate(variable, YEAR), dtype=float + ) + return self._cache[variable] + + def families(self, variable, version): + """A benefit unit variable for one version of every family.""" + values = self._values(variable) + return values.reshape(len(self.population), len(VERSIONS))[:, version] + + def members(self, variable, i, version): + """A person variable for every member of one version of a family.""" + family = self.population[i] + size = len(family["adults"]) + len(family["child_ages"]) + start = self.first_member[i, version] + values = self._values(variable) + assert values.size == self.people, f"{variable} is not a person variable" + return values[start : start + size] + + def recipient(self, variable, i, version): + return self.members(variable, i, version)[self.population[i]["recipient"]] + + def family_total(self, variable, i, version): + return float(self.members(variable, i, version).sum()) + + +def claims_marriage_allowance(family): + return family["married"] and any( + adult["claims_marriage_allowance"] for adult in family["adults"] + ) + + +@PROPERTY_SETTINGS +@given(population=populations()) +def test_income_tax_never_falls_as_statutory_paternity_pay_rises(population): + results = Results(population) + for i, family in enumerate(population): + # Invariant 1. + recipient_gain = results.recipient("income_tax", i, HIGH) - results.recipient( + "income_tax", i, LOW + ) + assert recipient_gain >= -TOLERANCE, family + family_gain = results.family_total( + "income_tax", i, HIGH + ) - results.family_total("income_tax", i, LOW) + assert family_gain >= -TOLERANCE, family + # Invariant 2. + if not claims_marriage_allowance(family): + assert family_gain <= family["high"] - family["low"] + TOLERANCE, family + + +@PROPERTY_SETTINGS +@given(population=populations()) +def test_statutory_paternity_pay_is_treated_as_statutory_sick_pay(population): + results = Results(population) + for i, family in enumerate(population): + # Invariant 3, for every member. + for variable in PERSON_VARIABLES_AS_SICK_PAY: + np.testing.assert_allclose( + results.members(variable, i, HIGH), + results.members(variable, i, AS_SICK_PAY), + atol=TOLERANCE, + err_msg=f"{variable}: {family}", + ) + for variable in FAMILY_VARIABLES_AS_SICK_PAY: + np.testing.assert_allclose( + results.families(variable, HIGH), + results.families(variable, AS_SICK_PAY), + atol=TOLERANCE, + err_msg=variable, + ) + + +def covers_pension_contributions(family): + recipient = family["adults"][family["recipient"]] + return ( + recipient["wages"] + recipient["profit"] >= recipient["pension_contributions"] + ) + + +@PROPERTY_SETTINGS +@given(population=populations()) +def test_statutory_paternity_pay_is_taxed_as_wages(population): + results = Results(population) + for i, family in enumerate(population): + if not covers_pension_contributions(family): + continue + # Invariant 4, for every member: the payment moves Marriage Allowance + # and the High Income Child Benefit Charge as wages do. + for variable in ["income_tax", "national_insurance", "adjusted_net_income"]: + np.testing.assert_allclose( + results.members(variable, i, HIGH), + results.members(variable, i, AS_WAGES), + atol=TOLERANCE, + err_msg=f"{variable}: {family}", + ) + + +@PROPERTY_SETTINGS +@given(population=populations()) +def test_statutory_pay_adds_up_to_taxable_employment_income(population): + results = Results(population) + for i, family in enumerate(population): + for version in VERSIONS: + # Invariant 5. + statutory_pay = sum( + results.members(variable, i, version) for variable in STATUTORY_PAY + ) + np.testing.assert_allclose( + results.members("employment_benefits", i, version), + statutory_pay, + atol=TOLERANCE, + err_msg=str(family), + ) + np.testing.assert_allclose( + results.members("taxable_employment_income", i, version), + np.maximum( + 0, + results.members("employment_income", i, version) + + statutory_pay + - results.members("employment_deductions", i, version), + ), + atol=TOLERANCE, + err_msg=str(family), + ) + + +def test_statutory_paternity_pay_on_the_basic_rate_bears_20_percent(): + # Two weeks of statutory paternity pay at the 2026-27 rate, £194.32 a + # week (SI 2002/2818 reg 2(a)), on top of wages of £30,000 in England: + # 20% of £388.64 is £77.728, as on the same amount of wages or sick pay. + family = dict( + adults=[ + dict( + age=33, + wages=30_000.0, + profit=0.0, + pension_income=0.0, + savings=0.0, + dividends=0.0, + pension_contributions=0.0, + student_loan_plan="NONE", + claims_marriage_allowance=False, + ) + ], + married=False, + child_ages=[0], + region="NORTH_EAST", + tenure="RENT_PRIVATELY", + rent=0.0, + would_claim_uc=False, + recipient=0, + low=0.0, + high=388.64, + ) + results = Results([family]) + tax = {v: results.recipient("income_tax", 0, v) for v in VERSIONS} + assert abs(tax[LOW] - 0.2 * (30_000 - 12_570)) < 0.01 + assert abs(tax[HIGH] - tax[LOW] - 77.728) < 0.01 + assert abs(tax[AS_SICK_PAY] - tax[HIGH]) < 0.01 + assert abs(tax[AS_WAGES] - tax[HIGH]) < 0.01 diff --git a/policyengine_uk/tests/test_uc_statutory_pay_properties.py b/policyengine_uk/tests/test_uc_statutory_pay_properties.py index 3eb8ae1fd7..b7d0bc4e07 100644 --- a/policyengine_uk/tests/test_uc_statutory_pay_properties.py +++ b/policyengine_uk/tests/test_uc_statutory_pay_properties.py @@ -7,8 +7,8 @@ pension contributions, income tax and primary Class 1 contributions off "the amount of general earnings or benefits specified in paragraphs (2) to (4)". The model has variables for the first three payments. It charges National -Insurance on all three (ni_class_1_income) and income tax on sick and maternity -pay (employment_benefits in taxable_employment_income), as it does on wages. +Insurance (ni_class_1_income) and income tax (employment_benefits in +taxable_employment_income) on all three, as it does on wages. Before this was fixed the payments were left out of earned income while the National Insurance on them, and the income tax on sick and maternity pay, were @@ -45,13 +45,12 @@ earnings exemption uses actual UC net earned income before the minimum income floor, which reg. 82(4) excludes. -2. Same as wages: statutory sick or maternity pay of s on top of wages of e - gives the same income tax, National Insurance, earned income and award - before and after the benefit cap as wages of e + s. This holds for Marriage Allowance - claimants too. Two differences in the model's income tax are left out - (counts_as_wages): it charges no income tax on statutory paternity pay, and - it caps pension contribution relief at employment and self-employment - income without statutory pay. +2. Same as wages: statutory sick, maternity or paternity pay of s on top of + wages of e gives the same income tax, National Insurance, earned income + and award before and after the benefit cap as wages of e + s. This holds + for Marriage Allowance claimants too. One difference in the model's income + tax is left out (counts_as_wages): it caps pension contribution relief at + employment and self-employment income without statutory pay. 3. Accounting: a person's earned income before the minimum income floor is their wages, statutory pay and trading profit (a loss counts as nil) less @@ -104,7 +103,11 @@ "statutory_paternity_pay", ] # The model charges income tax on these, as on wages (employment_benefits). -TAXED_AS_WAGES = ["statutory_sick_pay", "statutory_maternity_pay"] +TAXED_AS_WAGES = [ + "statutory_sick_pay", + "statutory_maternity_pay", + "statutory_paternity_pay", +] BENEFITS = list(dict.fromkeys(HOUSEHOLD_BENEFIT_VARIABLES)) # Wages and payments on and around the 2026-27 thresholds: the personal # allowance and primary threshold (12,570), the basic rate limit (50,270) and @@ -135,7 +138,9 @@ def adults(draw, marriage_allowance): @st.composite -def families(draw, marriage_allowance=False, dependant_recipient=False): +def families( + draw, marriage_allowance=False, dependant_recipient=False, payments=PAYMENTS +): members = draw(st.lists(adults(marriage_allowance), min_size=1, max_size=2)) child_ages = draw(st.lists(st.sampled_from([1, 6, 13]), max_size=3)) if dependant_recipient: @@ -158,7 +163,7 @@ def families(draw, marriage_allowance=False, dependant_recipient=False): tenure=draw(st.sampled_from(["RENT_FROM_COUNCIL", "RENT_PRIVATELY"])), rent=float(draw(st.sampled_from([0, 7_200, 18_000, 36_000]))), recipient=recipient, - payment=draw(st.sampled_from(PAYMENTS)), + payment=draw(st.sampled_from(payments)), low=float(low), high=float(high), ) @@ -381,7 +386,6 @@ def test_each_payment_is_household_income_and_government_spending(): def counts_as_wages(family): """Whether the model taxes the family's payment exactly as it taxes wages. - It charges no income tax on statutory paternity pay. And pension_contributions_relief caps relief at employment and self-employment income, which leaves statutory pay out: a recipient whose wages and profit are below their pension contributions gets less relief on statutory pay @@ -540,6 +544,44 @@ def family(adults, child_ages=(), rent=0.0, region="NORTH_EAST", childcare=0.0): } +@PROPERTY_SETTINGS +@given(population=populations(payments=["statutory_paternity_pay"])) +def test_uc_does_not_increase_as_statutory_paternity_pay_rises(population): + # Keep one taxable, positive-award case in every generated population, + # so an all-zero set of awards cannot satisfy the property vacuously. + pinned = dict( + ARCHETYPES["lone parent on 20,000"], + payment="statutory_paternity_pay", + low=0.0, + high=388.64, + ) + # No Marriage Allowance is drawn. With no childcare costs, a payment + # starting from nil cannot open a new childcare element either. + population = [pinned] + [dict(family, childcare=0.0) for family in population] + results = Results(population) + before_cap = { + version: results.families("universal_credit_pre_benefit_cap", version) + for version in (LOW, HIGH) + } + award = { + version: results.families("universal_credit", version) + for version in (LOW, HIGH) + } + exempt = { + version: results.families("is_uc_benefit_cap_exempt", version) + for version in (LOW, HIGH) + } + assert np.all(before_cap[HIGH] <= before_cap[LOW] + TOLERANCE), population + assert np.all(exempt[HIGH] >= exempt[LOW]), population + newly_exempt = (exempt[LOW] == 0) & (exempt[HIGH] != 0) + assert np.all(newly_exempt | (award[HIGH] <= award[LOW] + TOLERANCE)), population + assert award[LOW][0] > 0 and award[HIGH][0] > 0 + assert award[HIGH][0] < award[LOW][0] - TOLERANCE + assert results.recipient("income_tax", 0, HIGH) > results.recipient( + "income_tax", 0, LOW + ) + + def along_paths(bases, payments, variables): """Each variable along PATH, for each base family and payment. diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py index 5ac9560f4d..a2897924d8 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py @@ -5,7 +5,7 @@ def person_net_earnings(person, period, pension_contribution_deduction_rate): """A person's net earnings under the legacy means tests' earnings rules. Employment income plus self-employment profit (each floored at zero) plus - statutory sick and maternity pay, less the income tax attributed to them, + statutory sick, maternity and paternity pay, less the income tax attributed to them, Class 1, 2 and 4 National Insurance and the scheme's share of pension contributions, floored at zero. Housing Benefit (housing_benefit_net_earnings) and the Council Tax Reduction pensioner @@ -15,8 +15,8 @@ def person_net_earnings(person, period, pension_contribution_deduction_rate): """ employment_income = person("employment_income", period) self_employment_income = person("self_employment_income", period) - # Statutory sick and maternity pay are earnings (HB Regs 2006 reg - # 35(1)(i); pension age reg 35(1)(h)). The model taxes them as employment income + # Statutory sick, maternity and paternity pay are earnings (HB Regs 2006 reg + # 35(1)(i); pension age reg 35(1)(h) and (i)). The model taxes them as employment income # (employment_benefits) but leaves them out of total_income. statutory_pay = person("employment_benefits", period) earnings = ( diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index 96398e5041..765fef4a09 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -63,8 +63,14 @@ def formula(benunit, period, parameters): "jsa_contrib", "state_pension", "maternity_allowance", + # Statutory sick, maternity and paternity pay are earnings: SI + # 2006/213 reg 35(1)(i); SI 2006/214 reg 35(1)(h) and (i); NI SR + # 2006/405 reg 32(1)(i); NI SR 2006/406 reg 33(1)(h) and (i). The + # income tax and National Insurance deducted below include the + # tax on them (employment_benefits, ni_class_1_income). "statutory_sick_pay", "statutory_maternity_pay", + "statutory_paternity_pay", "ssmg", # Counted by both regimes: HB (SPC) Regs 2006 reg 29(1)(j), whose # exceptions touching these are the IIDB increases under SSCBA diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py index d060fcf3c0..ff9ef65b05 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py @@ -12,9 +12,9 @@ class housing_benefit_net_earnings(Variable): "Net earnings of the claimant and partner, from which the Housing " "Benefit earnings disregards are taken. For each person: employment " "income plus self-employment profit (each floored at zero, as a loss " - "in one employment is not set against another) plus statutory sick " - "and maternity pay (regulation 35(1)(i); pension age regulation " - "35(1)(h)), less income tax, " + "in one employment is not set against another) plus statutory sick, " + "maternity and paternity pay (regulation 35(1)(i); pension age " + "regulation 35(1)(h) and (i)), less income tax, " "Class 1, 2 and 4 National Insurance, and Housing Benefit's share of " "pension contributions (pension_contribution_deduction_rate, one-half " "in law), floored at zero. For an employee the regulations " diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py index 401d1c6f55..d396f77193 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_specified_or_temporary_accommodation_disregard.py @@ -37,7 +37,7 @@ def formula(benunit, period, parameters): person = benunit.members # Para 18(1)(b): the claimant or partner is an employed earner or a # self-employed earner. Anyone with earnings is, including statutory - # sick or maternity pay from a continuing employment and a + # sick, maternity or paternity pay from a continuing employment and a # self-employment loss. The amount is capped at net earnings in # housing_benefit_applicable_income_disregard, so a family without # net earnings gets nothing either way. diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py index a17fc4c791..95a0d853dc 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -57,7 +57,7 @@ def formula(person, period, parameters): # employed earnings, and reg. 55(5) takes the deductions off "the # amount of general earnings or benefits specified in paragraphs (2) # to (4)". National Insurance falls on all three (ni_class_1_income) - # and income tax on sick and maternity pay (employment_benefits in + # and income tax on all three (employment_benefits in # taxable_employment_income), and both are deducted below. Leaving # the payments out of the earnings deducted the tax and National # Insurance on them from other earnings, so the award rose as the diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 321375bd61..caa38f3972 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -113,8 +113,17 @@ def formula(benunit, period, parameters): "jsa_contrib", "state_pension", "maternity_allowance", + # Statutory sick, maternity and paternity pay are earnings: SI + # 2012/2885 Sch 1 para 18(1)(h) to (j) and the default scheme, SI + # 2012/2886 Sch paras 41(1)(h) and (i) and 51(1)(j); WSI 2013/3029 + # Sch 1 para 12(1)(h) and (i) and Sch 6 para 14(1)(j); SSI + # 2012/319 reg 32(1)(h) and (i); SSI 2021/249 regs 49(4)(a) to + # (c) and 50(2)(j). The income tax and National Insurance + # deducted below include the tax on them (employment_benefits, + # ni_class_1_income). "statutory_sick_pay", "statutory_maternity_pay", + "statutory_paternity_pay", "ssmg", ] # Every scheme the model runs counts these three benefits. At pension diff --git a/policyengine_uk/variables/household/benefits/employment_benefits.py b/policyengine_uk/variables/household/benefits/employment_benefits.py index 279a31333d..af286252c3 100644 --- a/policyengine_uk/variables/household/benefits/employment_benefits.py +++ b/policyengine_uk/variables/household/benefits/employment_benefits.py @@ -5,7 +5,23 @@ class employment_benefits(Variable): value_type = float entity = Person label = "Employment benefits" + documentation = ( + "Statutory sick, maternity and paternity pay. ITEPA 2003 s. 660 " + "Table A lists each as a taxable social security benefit; employers " + "pay them through payroll, and taxable_employment_income taxes them " + "with wages. Table A also lists statutory adoption, shared parental, " + "parental bereavement and neonatal care pay; the model has no " + "variables for those." + ) definition_period = YEAR unit = GBP + reference = dict( + title="Income Tax (Earnings and Pensions) Act 2003, s. 660, Table A", + href="https://www.legislation.gov.uk/ukpga/2003/1/section/660", + ) - adds = ["statutory_sick_pay", "statutory_maternity_pay"] + adds = [ + "statutory_sick_pay", + "statutory_maternity_pay", + "statutory_paternity_pay", + ]