From 626f020f731e93acdd39dc25a7872ad428157c7f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 21:33:08 -0400 Subject: [PATCH 1/2] Charge income tax on statutory paternity pay ITEPA 2003 s. 660(1) Table A lists statutory paternity pay as a taxable UK benefit with statutory sick and maternity pay, but employment_benefits added only the latter two, so taxable_employment_income left SPP out. Housing Benefit and Council Tax Reduction deduct all of a person's income tax and NI but counted only SSP and SMP as income, although every scheme's earnings definition includes SPP (HB Regs 2006 reg 35(1)(i), SPC reg 35(1)(i), NI equivalents; CTR SI 2012/2885 Sch 1 para 18(1)(j), SI 2012/2886, WSI 2013/3029, SSI 2012/319, SSI 2021/249). Count SPP there too, so the tax on it does not raise those awards. Fixes #2367 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2367.fixed.md | 1 + .../housing_benefit_applicable_income.yaml | 24 ++++++++ .../housing_benefit_net_earnings.yaml | 35 +++++++++++ ..._or_temporary_accommodation_disregard.yaml | 21 +++++++ .../income_tax/bases/employment_benefits.yaml | 59 +++++++++++++++++++ .../council_tax_reduction_statutory_pay.yaml | 56 ++++++++++++++++++ .../housing_benefit_applicable_income.py | 6 ++ .../housing_benefit_net_earnings.py | 13 ++-- ...ed_or_temporary_accommodation_disregard.py | 2 +- ...council_tax_reduction_applicable_income.py | 9 +++ .../household/benefits/employment_benefits.py | 18 +++++- 11 files changed, 236 insertions(+), 8 deletions(-) create mode 100644 changelog.d/2367.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/employment_benefits.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_statutory_pay.yaml 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 f5218d1b0b..cf37b19b02 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 @@ -127,3 +127,27 @@ housing_benefit_applicable_income_childcare_element: 0 output: housing_benefit_applicable_income: 0 + +- 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/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/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 b97d9ac316..393d0e236e 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 @@ -64,8 +64,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", ] INCOME_COMPONENTS = [ 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 8f8d97ac9a..480c9f0f9f 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 @@ -9,9 +9,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 half of pension " "contributions, floored at zero. For an employee the regulations " "deduct the income tax deducted from the earnings (regulation 36(3); " @@ -48,9 +48,10 @@ def formula(benunit, period, parameters): person = benunit.members employment_income = person("employment_income", period) self_employment_income = person("self_employment_income", period) - # Statutory sick and maternity pay are earnings (reg 35(1)(i); pension - # age reg 35(1)(h)). The model taxes them as employment income - # (employment_benefits) but leaves them out of total_income. + # Statutory sick, maternity and paternity pay are earnings (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 = ( max_(employment_income, 0) + max_(self_employment_income, 0) + statutory_pay 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/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 55575e1987..8ce100067c 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 @@ -86,8 +86,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", ] income_components = [ 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", + ] From b9922f5377b73c9774b5752def8116e41f99ab94 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 22:21:51 -0400 Subject: [PATCH 2/2] Add property tests for income tax on statutory paternity pay Hypothesis properties over generated singles and couples across the four nations: income tax never falls as statutory paternity pay rises, and rises by at most a pound per pound without Marriage Allowance; statutory paternity pay is taxed and means-tested as the same amount of statutory sick pay, and taxed as wages where wages and profit cover pension contributions; and employment_benefits and taxable_employment_income add up. Four of the five fail against main's variables. Refs #2367 Co-Authored-By: Claude Opus 5.5 --- ...ory_paternity_pay_income_tax_properties.py | 378 ++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py 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