From ab4022bf3d4b26d0e7379b82b9fca7f3e1e67f96 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 15:31:28 -0400 Subject: [PATCH 01/10] Apply the Pension Credit earnings disregards (SPC Regs 2002 Sch VI) 20 pounds a week for a lone parent (para 1), a carer satisfying Sch I para 4 (para 3), or a claimant or partner on a listed disability benefit or certified blind (para 4(1)); otherwise 5 pounds single and 10 pounds couple (para 5); at most 20 pounds (para 4A), never more than the earnings. Refs #1954. Co-Authored-By: Claude Opus 5.5 --- changelog.d/pc-earnings-disregard.fixed.md | 1 + .../earnings_disregard/higher/amount.yaml | 10 + .../disability_benefit_unit_benefits.yaml | 19 ++ .../higher/disability_benefits.yaml | 44 +++ .../earnings_disregard/standard.yaml | 20 ++ .../pension_credit_earnings_disregard.yaml | 250 ++++++++++++++++++ .../pension_credit/pension_credit_income.yaml | 6 +- ...on_credit_earnings_disregard_properties.py | 136 ++++++++++ .../pension_credit_earnings_disregard.py | 55 ++++ .../pension_credit/pension_credit_income.py | 4 +- 10 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 changelog.d/pc-earnings-disregard.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/amount.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefit_unit_benefits.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/standard.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml create mode 100644 policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py diff --git a/changelog.d/pc-earnings-disregard.fixed.md b/changelog.d/pc-earnings-disregard.fixed.md new file mode 100644 index 0000000000..8a95e17dbe --- /dev/null +++ b/changelog.d/pc-earnings-disregard.fixed.md @@ -0,0 +1 @@ +Apply the Pension Credit earnings disregards of SPC Regs 2002 Schedule VI: 20 pounds a week for lone parents, carers and claimants or partners on a listed disability benefit or certified blind, otherwise 5 pounds (single) or 10 pounds (couple), capped at earnings. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/amount.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/amount.yaml new file mode 100644 index 0000000000..bc51f73542 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/amount.yaml @@ -0,0 +1,10 @@ +description: Weekly earnings disregarded from Pension Credit income for a lone parent, a carer, or a claimant or partner on a listed disability benefit or certified blind. It is the most that can be disregarded however many conditions are met. +values: + 2003-10-06: 20 +metadata: + unit: currency-GBP + period: week + label: Pension Credit higher earnings disregard + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI paras. 1, 3, 4 and 4A + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefit_unit_benefits.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefit_unit_benefits.yaml new file mode 100644 index 0000000000..8e31a28658 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefit_unit_benefits.yaml @@ -0,0 +1,19 @@ +description: > + Benefit-unit awards whose receipt qualifies the benefit unit for the higher + Pension Credit earnings disregard (Sch. VI para. 4(1)(a)(vii), employment + and support allowance, from 27 October 2008). The model holds income-related + employment and support allowance on the benefit unit, as the claimant's + award; contributory employment and support allowance is a person-level + benefit in `disability_benefits`. +metadata: + label: Pension Credit higher earnings disregard benefit-unit disability benefits + unit: list + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI para. 4(1)(a)(vii) + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI/paragraph/4 + - title: The Employment and Support Allowance (Consequential Provisions) (No. 2) Regulations 2008 reg. 4(10)(a)(ii) + href: https://www.legislation.gov.uk/uksi/2008/1554/regulation/4 +values: + 2003-10-06: [] + 2008-10-27: + - esa_income diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml new file mode 100644 index 0000000000..30a23dc061 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml @@ -0,0 +1,44 @@ +description: > + Person-level benefits whose receipt by the claimant or partner qualifies the + benefit unit for the higher Pension Credit earnings disregard (Sch. VI para. + 4(1)(a)). Income-related employment and support allowance, a benefit-unit + award, is in `disability_benefit_unit_benefits`. + `incapacity_benefit` stands for long-term incapacity benefit (para. + 4(1)(a)(i)); the model does not separate the short-term rates. Not modelled: + the war pensions mobility supplement (v), the working tax credit disability + elements (vi), pension age disability payment (iiia, from 21 October 2024), + Scottish adult disability living allowance (iva, from 21 March 2025) and + adult disability payment (viiia, from 21 March 2022), which have no separate + variables. +metadata: + label: Pension Credit higher earnings disregard disability benefits + unit: list + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI para. 4(1)(a) + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI/paragraph/4 + - title: The Employment and Support Allowance (Consequential Provisions) (No. 2) Regulations 2008 reg. 4(10)(a)(ii) (ESA, from 27 October 2008) + href: https://www.legislation.gov.uk/uksi/2008/1554/regulation/4 + - title: The Personal Independence Payment (Supplementary Provisions and Consequential Amendments) Regulations 2013 Sch. para. 27(8)(b) (PIP, from 8 April 2013) + href: https://www.legislation.gov.uk/uksi/2013/388/schedule/paragraph/27 + - title: The Armed Forces and Reserve Forces Compensation Scheme (Consequential Provisions Subordinate Legislation) Order 2013 Sch. para. 23(8)(b) (AFIP, from 8 April 2013) + href: https://www.legislation.gov.uk/uksi/2013/591/schedule/paragraph/23 +values: + 2003-10-06: + - incapacity_benefit + - sda + - attendance_allowance + - dla + 2008-10-27: + - incapacity_benefit + - sda + - attendance_allowance + - dla + - esa_contrib + 2013-04-08: + - incapacity_benefit + - sda + - attendance_allowance + - dla + - esa_contrib + - pip + - armed_forces_independence_payment diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/standard.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/standard.yaml new file mode 100644 index 0000000000..80ffae79fe --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/standard.yaml @@ -0,0 +1,20 @@ +description: Weekly earnings disregarded from Pension Credit income where neither the claimant nor a partner qualifies for the higher disregard. +SINGLE: + values: + 2003-10-06: 5 + metadata: + label: Pension Credit standard earnings disregard (single) +COUPLE: + values: + 2003-10-06: 10 + metadata: + label: Pension Credit standard earnings disregard (couple) +metadata: + unit: currency-GBP + period: week + propagate_metadata_to_children: true + reference: + - title: The State Pension Credit Regulations 2002 Sch. VI para. 5 + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI/paragraph/5 + - title: The State Pension Credit Regulations 2002 reg. 17(9) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/17 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml new file mode 100644 index 0000000000..2f09ea9468 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -0,0 +1,250 @@ +# Expected values are hand-computed from SPC Regs 2002 Sch VI (paras 1, 3, 4, +# 4A and 5): weekly amounts times 52 weeks, capped at the earnings. + +- name: A single claimant with earnings has 5 pounds a week disregarded (para 5(a)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 + +- name: A couple with earnings has 10 pounds a week disregarded (para 5(b)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + partner: + age: 70 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + pension_credit_earnings_disregard: 10 * 52 + +- name: A claimant on attendance allowance has 20 pounds a week disregarded (para 4(1)(a)(iii)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + attendance_allowance: 3000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A partner's personal independence payment qualifies the couple (para 4(1)(a)(viii)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + partner: + age: 70 + pip_dl: 3000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A carer satisfying Sch I para 4 has 20 pounds a week disregarded (para 3). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + is_carer_for_benefits: true + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A lone parent has 20 pounds a week disregarded (para 1). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + child: + age: 10 + benunits: + benunit: + members: [claimant, child] + households: + household: + members: [claimant, child] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: A claimant certified blind has 20 pounds a week disregarded (para 4(1)(b)). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + is_blind: true + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: 20 pounds is the most disregarded when both partners qualify (para 4A). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + attendance_allowance: 3000 + partner: + age: 70 + employment_income: 2000 + dla_sc: 3000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: The disregard never exceeds the earnings. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 100 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 100 + +- name: Self-employed earnings get the same disregard. + period: 2024 + input: + people: + claimant: + age: 70 + self_employment_income: 2000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 + +- name: Without earnings there is nothing to disregard. + period: 2024 + input: + people: + claimant: + age: 70 + attendance_allowance: 3000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 0 + +- name: A dependant child's disability benefit does not qualify a couple for the higher disregard. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + partner: + age: 70 + child: + age: 10 + dla_sc: 3000 + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + pension_credit_earnings_disregard: 10 * 52 + +- name: Income-related ESA, a benefit-unit award, qualifies for the higher disregard. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + benunits: + benunit: + members: [claimant] + esa_income: 1000 + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: The disregard comes off Pension Credit income and raises the guarantee credit. + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2000 + state_pension: 9000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # minimum guarantee 218.15 a week in 2024-25 (SI 2024/242) + pension_credit_income: 9_000 + 2_000 - 5 * 52 + guarantee_credit: 218.15 * 52 - (9_000 + 2_000 - 5 * 52) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml index b6cf5c9493..3c1b7febc4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml @@ -20,7 +20,11 @@ savings: 11_000 output: pension_credit_deemed_income: 104 - pension_credit_income: 343 + # 1 + 2 + 4 + 8 + 32 + 64 + 128 + 104 = 343, less the Sch VI para 5 + # disregard: 5 pounds a week for a single claimant, capped at the 129 of + # earnings. + pension_credit_earnings_disregard: 129 + pension_credit_income: 214 - name: Interest and dividends are not counted directly in Pension Credit income. period: 2021 diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py new file mode 100644 index 0000000000..2aaa136839 --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -0,0 +1,136 @@ +"""Properties of the Pension Credit earnings disregards (SPC Regs 2002 Sch VI). + +Invariants, over generated pension-age benefit units: + +1. Statute: the disregard equals min(earnings, 52 x w), where w is 20 if the + unit has a lone parent, a carer satisfying Sch I para 4, or a claimant or + partner on a listed disability benefit or certified blind, and otherwise 5 + for a single claimant and 10 for a couple. The expected value is computed + here from the inputs, not read from the model. +2. Bounds: 0 <= disregard <= min(earnings, 20 x 52). +3. Differential: Pension Credit income is the pre-change income (sources less + tax, NI and half of pension contributions, floored at 0, recomputed from + the model's own components) less the disregard, floored at 0. +4. Monotonicity: the guarantee credit never falls when the disregard applies, + i.e. it is at least the guarantee credit without it. +5. The dated benefit lists follow the amending instruments: ESA from 27 + October 2008, PIP and AFIP from 8 April 2013. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.model_api import WEEKS_IN_YEAR + +YEAR = 2024 +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +DISABILITY = [ + "attendance_allowance", + "dla_sc", + "pip_dl", + "incapacity_benefit", + "esa_contrib", +] + + +@st.composite +def units(draw): + couple = draw(st.booleans()) + child = draw(st.booleans()) and not couple + people = {} + names = ["claimant"] + (["partner"] if couple else []) + for n in names: + person = { + "age": draw(st.sampled_from([67, 72, 85])), + "employment_income": draw(st.sampled_from([0, 50, 400, 3_000])), + "self_employment_income": draw(st.sampled_from([0, 0, 1_500])), + "state_pension": draw(st.integers(min_value=0, max_value=12_000)), + "private_pension_income": draw(st.sampled_from([0, 2_000])), + "is_blind": draw(st.sampled_from([False, False, False, True])), + "is_carer_for_benefits": draw(st.sampled_from([False, False, False, True])), + } + benefit = draw(st.sampled_from([None, None] + DISABILITY)) + if benefit: + person[benefit] = 2_000 + people[n] = person + if child: + people["child"] = {"age": 10, "dla_sc": draw(st.sampled_from([0, 2_000]))} + return people + + +def simulate(people): + members = list(people) + dated = { + name: {k: {str(YEAR): v} for k, v in person.items()} + for name, person in people.items() + } + return Simulation( + situation={ + "people": dated, + "benunits": {"b": {"members": members}}, + "households": {"h": {"members": members}}, + } + ) + + +@SETTINGS +@given(units()) +def test_statute_bounds_differential_and_monotonicity(people): + sim = simulate(people) + calc = lambda v: float(sim.calculate(v, YEAR)[0]) + adults = [p for n, p in people.items() if n != "child"] + earnings = sum(p["employment_income"] + p["self_employment_income"] for p in adults) + couple = len(adults) == 2 + disabled = any( + p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) for p in adults + ) + carer = calc("carer_minimum_guarantee_addition") > 0 + lone_parent = "child" in people + weekly = 20 if (disabled or carer or lone_parent) else (10 if couple else 5) + expected = min(earnings, weekly * WEEKS_IN_YEAR) + disregard = calc("pension_credit_earnings_disregard") + assert np.isclose(disregard, expected, atol=0.01) + assert 0 <= disregard <= min(earnings, 20 * WEEKS_IN_YEAR) + 0.01 + + sources = sim.tax_benefit_system.parameters( + f"{YEAR}-06-01" + ).gov.dwp.pension_credit.guarantee_credit.income + total = sum(float(sim.calculate(s, YEAR, map_to="benunit")[0]) for s in sources) + deductions = ( + float(sim.calculate("income_tax", YEAR, map_to="benunit")[0]) + + float(sim.calculate("national_insurance", YEAR, map_to="benunit")[0]) + + 0.5 * float(sim.calculate("pension_contributions", YEAR, map_to="benunit")[0]) + ) + before = max(0.0, total - deductions) + assert np.isclose( + calc("pension_credit_income"), max(0.0, before - disregard), atol=0.01 + ) + gc_without = max(0.0, calc("minimum_guarantee") - before) + assert calc("guarantee_credit") >= gc_without - 0.01 + + +def test_benefit_lists_follow_amending_instruments(): + from policyengine_uk.system import system + + def lists(date): + p = system.parameters(date).gov.dwp.pension_credit.earnings_disregard.higher + return set(p.disability_benefits), set(p.disability_benefit_unit_benefits) + + person, unit = lists("2008-10-26") + assert "esa_contrib" not in person and "esa_income" not in unit + person, unit = lists("2008-10-27") + assert "esa_contrib" in person and "esa_income" in unit + person, _ = lists("2013-04-07") + assert "pip" not in person and "armed_forces_independence_payment" not in person + person, _ = lists("2013-04-08") + assert {"pip", "armed_forces_independence_payment"} <= person + for date in ["2008-10-26", "2013-04-08", "2026-04-06"]: + person, _ = lists(date) + assert {"incapacity_benefit", "sda", "attendance_allowance", "dla"} <= person diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py new file mode 100644 index 0000000000..5b93defa35 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + + +class pension_credit_earnings_disregard(Variable): + label = "Pension Credit earnings disregard" + documentation = ( + "Earnings disregarded from Pension Credit income under Schedule VI to " + "the State Pension Credit Regulations 2002: 20 pounds a week for a " + "lone parent (para. 1), where the claimant or partner is a carer " + "satisfying Sch. I para. 4 (para. 3), or where the claimant or partner " + "receives a listed disability benefit or is certified blind " + "(para. 4(1)); otherwise 5 pounds a week for a single claimant and 10 " + "pounds for a couple (para. 5). 20 pounds is the most disregarded " + "however many conditions are met (para. 4A), and the disregard never " + "exceeds the earnings. Not modelled: the employment-specific 20 pound " + "disregards of paras. 2 to 2B (part-time firefighters, auxiliary " + "coastguards, lifeboat crew, reserve forces) and the transitional " + "protections of para. 4(2) to (4)." + ) + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.pension_credit.earnings_disregard + person = benunit.members + claimant_or_partner = person("is_uc_claimant", period) + on_disability_benefit = ( + add(person, period, p.higher.disability_benefits) > 0 + ) | person("is_blind", period) + unit_disability_benefits = p.higher.disability_benefit_unit_benefits + unit_on_disability_benefit = ( + add(benunit, period, unit_disability_benefits) > 0 + if len(unit_disability_benefits) > 0 + else False + ) + disabled = ( + benunit.any(claimant_or_partner & on_disability_benefit) + | unit_on_disability_benefit + ) + carer = benunit("carer_minimum_guarantee_addition", period) > 0 + lone_parent = benunit("is_lone_parent", period) + relation_type = benunit("relation_type", period) + weekly = where( + lone_parent | carer | disabled, + p.higher.amount, + p.standard[relation_type], + ) + earnings = max_(0, benunit("pension_credit_earnings", period)) + return min_(weekly * WEEKS_IN_YEAR, earnings) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py index 1160800f3a..7a9ec44199 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py @@ -46,4 +46,6 @@ def formula(benunit, period, parameters): ) pen_con_deduction_rate = pc.income.pension_contributions_deduction deductions = tax + pension_contributions * pen_con_deduction_rate - return max_(0, total - deductions) + # Schedule VI earnings disregards (reg. 17(9)). + earnings_disregard = benunit("pension_credit_earnings_disregard", period) + return max_(0, total - deductions - earnings_disregard) From 3d4f12b24f6ed0d421ed940551e66d424d15db51 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:06:22 -0400 Subject: [PATCH 02/10] Cap the Pension Credit earnings disregard at net earnings (review of #2019) Reg 17(9) disregards the Sch VI sums in calculating the claimant's earnings, which are net of income tax and NI (reg 17(10)) and of half of pension contributions (reg 17A(4A)). Capping at gross earnings let a taxpayer's first pounds of earnings raise Pension Credit. Income tax on earnings is the lesser of the person's income tax and the basic rate on their earnings. Also: the carer condition reads the claimant's or partner's is_carer_for_benefits (Sch I para 4), not the carer addition, which counts dependants; is_claimant_or_partner after #1896; property tests compute net earnings independently, include dependants' DLA and caring, and add that earning more never raises Pension Credit; parameter description and test comment fixes. Co-Authored-By: Claude Opus 5.5 --- .../higher/disability_benefits.yaml | 14 ++- .../claimant_partner_income_and_carers.yaml | 8 +- .../pension_credit_earnings_disregard.yaml | 65 +++++++++++ .../pension_credit/pension_credit_income.yaml | 6 +- ...on_credit_earnings_disregard_properties.py | 109 +++++++++++++----- .../pension_credit_earnings_disregard.py | 42 +++++-- 6 files changed, 196 insertions(+), 48 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml index 30a23dc061..e2ea6f3328 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml @@ -5,11 +5,13 @@ description: > award, is in `disability_benefit_unit_benefits`. `incapacity_benefit` stands for long-term incapacity benefit (para. 4(1)(a)(i)); the model does not separate the short-term rates. Not modelled: - the war pensions mobility supplement (v), the working tax credit disability - elements (vi), pension age disability payment (iiia, from 21 October 2024), - Scottish adult disability living allowance (iva, from 21 March 2025) and - adult disability payment (viiia, from 21 March 2022), which have no separate - variables. + the war pensions mobility supplement (v), which has no variable; pension age + disability payment (iiia, from 21 October 2024), Scottish adult disability + living allowance (iva, from 21 March 2025) and adult disability payment + (viiia, from 21 March 2022), which have no separate variables; and the + working tax credit disability elements (vi), left out because in the model + they follow from DLA or PIP, which are listed, and working tax credit ended + in April 2025. metadata: label: Pension Credit higher earnings disregard disability benefits unit: list @@ -20,7 +22,7 @@ metadata: href: https://www.legislation.gov.uk/uksi/2008/1554/regulation/4 - title: The Personal Independence Payment (Supplementary Provisions and Consequential Amendments) Regulations 2013 Sch. para. 27(8)(b) (PIP, from 8 April 2013) href: https://www.legislation.gov.uk/uksi/2013/388/schedule/paragraph/27 - - title: The Armed Forces and Reserve Forces Compensation Scheme (Consequential Provisions Subordinate Legislation) Order 2013 Sch. para. 23(8)(b) (AFIP, from 8 April 2013) + - title: "The Armed Forces and Reserve Forces Compensation Scheme (Consequential Provisions: Subordinate Legislation) Order 2013 Sch. para. 23(8)(b) (AFIP, from 8 April 2013)" href: https://www.legislation.gov.uk/uksi/2013/591/schedule/paragraph/23 values: 2003-10-06: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml index dbf4bb5dbf..991253993f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml @@ -160,9 +160,13 @@ is_claimant_or_partner: [true, true] # SPCA 2002 s.5: gross = 5,000 + 1,000 + 100 + 2,000 + 500 + 200 # + 104 = 8,904; deductions = 100 + 50 + 25 + 50% * 300 = 325. - pension_credit_income: 8_579 + # Sch VI para 3 (both carers): 20 pounds a week, capped at net earnings + # (claimant 1,000 - 100 tax - 100 half contributions = 800; partner + # 500 - 25 NI - 50 tax - 50 = 375; 1,175), so 1,040 is disregarded: + # 8,904 - 325 - 1,040. + pension_credit_income: 7_539 # Reg.9: remove the claimant's 100 JSA and partner's 200 ESA. - savings_credit_income: 8_279 + savings_credit_income: 7_239 # Sch.I para.4 and the 2026 carer addition: 2 * 48.15 * 52 = 5,007.60. carer_minimum_guarantee_addition: 5_007.60 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index 2f09ea9468..1a042c7dba 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -248,3 +248,68 @@ # minimum guarantee 218.15 a week in 2024-25 (SI 2024/242) pension_credit_income: 9_000 + 2_000 - 5 * 52 guarantee_credit: 218.15 * 52 - (9_000 + 2_000 - 5 * 52) + +- name: A taxpayer's disregard is capped at net earnings, so earnings below the disregard leave Pension Credit income unchanged (reg 17(9), 17(10)). + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 3_000 + attendance_allowance: 3_000 + employment_income: 1_040 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # Income tax (16,040 - 12,570) x 20% = 694. Tax on the earnings is the + # lesser of 694 and 20% x 1,040 = 208, so net earnings are 832; the 20 + # pound (attendance allowance) disregard of 1,040 is capped at 832. + pension_credit_earnings_disregard: 832 + # 16,040 - 694 - 832, the same as with no earnings (next case). + pension_credit_income: 14_514 + +- name: The same claimant with no earnings has the same Pension Credit income. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 3_000 + attendance_allowance: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # 15,000 - (15,000 - 12,570) x 20% + pension_credit_earnings_disregard: 0 + pension_credit_income: 14_514 + +- name: A dependant's caring does not qualify a couple for the higher disregard (Sch I para 4 is the claimant's or partner's). + period: 2024 + input: + people: + claimant: + age: 70 + employment_income: 2_000 + partner: + age: 70 + child: + age: 17 + is_carer_for_benefits: true + benunits: + benunit: + members: [claimant, partner, child] + households: + household: + members: [claimant, partner, child] + output: + pension_credit_earnings_disregard: 10 * 52 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml index 3c1b7febc4..b33aeac542 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml @@ -20,9 +20,9 @@ savings: 11_000 output: pension_credit_deemed_income: 104 - # 1 + 2 + 4 + 8 + 32 + 64 + 128 + 104 = 343, less the Sch VI para 5 - # disregard: 5 pounds a week for a single claimant, capped at the 129 of - # earnings. + # 1 + 2 + 4 + 8 + 32 + 64 + 128 + 104 = 343, less the Sch VI disregard: + # the 20 pound para 4(1)(a)(vii) amount (contributory ESA), capped at the + # 129 of net earnings (no tax or NI on them). pension_credit_earnings_disregard: 129 pension_credit_income: 214 diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py index 2aaa136839..5c1e475c86 100644 --- a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -1,19 +1,24 @@ """Properties of the Pension Credit earnings disregards (SPC Regs 2002 Sch VI). -Invariants, over generated pension-age benefit units: - -1. Statute: the disregard equals min(earnings, 52 x w), where w is 20 if the - unit has a lone parent, a carer satisfying Sch I para 4, or a claimant or - partner on a listed disability benefit or certified blind, and otherwise 5 - for a single claimant and 10 for a couple. The expected value is computed - here from the inputs, not read from the model. -2. Bounds: 0 <= disregard <= min(earnings, 20 x 52). +Invariants, over generated benefit units (England, so the basic rate is 20%): + +1. Statute: the disregard equals min(net earnings, 52 x w). Net earnings are + the claimant's and partner's gross earnings less earnings NI, half of + pension contributions and income tax on the earnings (the lesser of their + income tax and 20% of their earnings), recomputed here from the inputs and + the model's tax and NI. w is 20 if the unit is a lone-parent family, or a + claimant or partner is a carer (an input here), receives a listed + disability benefit or is blind; otherwise 5 single, 10 couple. A dependent + child's DLA or caring never qualifies. +2. Bounds: 0 <= disregard <= min(gross earnings, 20 x 52). 3. Differential: Pension Credit income is the pre-change income (sources less - tax, NI and half of pension contributions, floored at 0, recomputed from - the model's own components) less the disregard, floored at 0. -4. Monotonicity: the guarantee credit never falls when the disregard applies, - i.e. it is at least the guarantee credit without it. -5. The dated benefit lists follow the amending instruments: ESA from 27 + tax, NI and half of pension contributions, floored at 0) less the + disregard, floored at 0. +4. Monotonicity: the guarantee credit is never below its value without the + disregard. +5. Earning more never raises Pension Credit entitlement (reg 17(9) applies the + disregard to net earnings). +6. The dated benefit lists follow the amending instruments: ESA from 27 October 2008, PIP and AFIP from 8 April 2013. """ @@ -43,16 +48,16 @@ @st.composite def units(draw): couple = draw(st.booleans()) - child = draw(st.booleans()) and not couple + child = draw(st.booleans()) people = {} names = ["claimant"] + (["partner"] if couple else []) for n in names: person = { "age": draw(st.sampled_from([67, 72, 85])), - "employment_income": draw(st.sampled_from([0, 50, 400, 3_000])), + "employment_income": draw(st.sampled_from([0, 50, 400, 1_040, 3_000])), "self_employment_income": draw(st.sampled_from([0, 0, 1_500])), "state_pension": draw(st.integers(min_value=0, max_value=12_000)), - "private_pension_income": draw(st.sampled_from([0, 2_000])), + "private_pension_income": draw(st.sampled_from([0, 2_000, 6_000])), "is_blind": draw(st.sampled_from([False, False, False, True])), "is_carer_for_benefits": draw(st.sampled_from([False, False, False, True])), } @@ -61,7 +66,12 @@ def units(draw): person[benefit] = 2_000 people[n] = person if child: - people["child"] = {"age": 10, "dla_sc": draw(st.sampled_from([0, 2_000]))} + # A dependent child's DLA or caring never qualifies the unit. + people["child"] = { + "age": 10, + "dla_sc": draw(st.sampled_from([0, 2_000])), + "is_carer_for_benefits": draw(st.booleans()), + } return people @@ -80,24 +90,51 @@ def simulate(people): ) +def unit_value(sim, variable): + return float(sim.calculate(variable, YEAR)[0]) + + +def person_values(sim, variable): + return np.asarray(sim.calculate(variable, YEAR, map_to="person"), dtype=float) + + @SETTINGS @given(units()) def test_statute_bounds_differential_and_monotonicity(people): sim = simulate(people) - calc = lambda v: float(sim.calculate(v, YEAR)[0]) - adults = [p for n, p in people.items() if n != "child"] - earnings = sum(p["employment_income"] + p["self_employment_income"] for p in adults) - couple = len(adults) == 2 + names = list(people) + adults = [n != "child" for n in names] + gross = np.array( + [ + people[n]["employment_income"] + people[n].get("self_employment_income", 0) + if n != "child" + else 0 + for n in names + ], + dtype=float, + ) + ni = sum( + person_values(sim, v) + for v in ["ni_class_1_employee", "ni_class_2", "ni_class_4"] + ) + tax_on_earnings = np.minimum(person_values(sim, "income_tax"), 0.2 * gross) + contributions = 0.5 * person_values(sim, "pension_contributions") + net = np.maximum(0, gross - ni - tax_on_earnings - contributions) + net_earnings = float(net[adults].sum()) + + adult_people = [people[n] for n in names if n != "child"] + couple = len(adult_people) == 2 disabled = any( - p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) for p in adults + p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) + for p in adult_people ) - carer = calc("carer_minimum_guarantee_addition") > 0 - lone_parent = "child" in people + carer = any(p.get("is_carer_for_benefits") for p in adult_people) + lone_parent = "child" in people and not couple weekly = 20 if (disabled or carer or lone_parent) else (10 if couple else 5) - expected = min(earnings, weekly * WEEKS_IN_YEAR) - disregard = calc("pension_credit_earnings_disregard") + expected = min(net_earnings, weekly * WEEKS_IN_YEAR) + disregard = unit_value(sim, "pension_credit_earnings_disregard") assert np.isclose(disregard, expected, atol=0.01) - assert 0 <= disregard <= min(earnings, 20 * WEEKS_IN_YEAR) + 0.01 + assert 0 <= disregard <= min(gross.sum(), 20 * WEEKS_IN_YEAR) + 0.01 sources = sim.tax_benefit_system.parameters( f"{YEAR}-06-01" @@ -110,10 +147,22 @@ def test_statute_bounds_differential_and_monotonicity(people): ) before = max(0.0, total - deductions) assert np.isclose( - calc("pension_credit_income"), max(0.0, before - disregard), atol=0.01 + unit_value(sim, "pension_credit_income"), + max(0.0, before - disregard), + atol=0.01, ) - gc_without = max(0.0, calc("minimum_guarantee") - before) - assert calc("guarantee_credit") >= gc_without - 0.01 + gc_without = max(0.0, unit_value(sim, "minimum_guarantee") - before) + assert unit_value(sim, "guarantee_credit") >= gc_without - 0.01 + + +@SETTINGS +@given(units(), st.sampled_from([1, 50, 260, 1_040, 5_000])) +def test_earning_more_never_raises_pension_credit(people, extra): + more = {name: dict(person) for name, person in people.items()} + more["claimant"]["employment_income"] += extra + before = unit_value(simulate(people), "pension_credit_entitlement") + after = unit_value(simulate(more), "pension_credit_entitlement") + assert after <= before + 0.01 def test_benefit_lists_follow_amending_instruments(): diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py index 5b93defa35..549bd43a6a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py @@ -11,8 +11,14 @@ class pension_credit_earnings_disregard(Variable): "receives a listed disability benefit or is certified blind " "(para. 4(1)); otherwise 5 pounds a week for a single claimant and 10 " "pounds for a couple (para. 5). 20 pounds is the most disregarded " - "however many conditions are met (para. 4A), and the disregard never " - "exceeds the earnings. Not modelled: the employment-specific 20 pound " + "however many conditions are met (para. 4A). Reg. 17(9) disregards the " + "sums 'in calculating the claimant's earnings', which are net of income " + "tax and National Insurance (reg. 17(10)) and of half of pension " + "contributions (reg. 17A(4A)), so the disregard never exceeds the " + "claimant's and partner's net earnings. Income tax on earnings is taken " + "as the lesser of the person's income tax and the basic rate on their " + "earnings, the tax deducted where the personal allowance goes against " + "pension income first. Not modelled: the employment-specific 20 pound " "disregards of paras. 2 to 2B (part-time firefighters, auxiliary " "coastguards, lifeboat crew, reserve forces) and the transitional " "protections of para. 4(2) to (4)." @@ -24,12 +30,15 @@ class pension_credit_earnings_disregard(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17A", ) def formula(benunit, period, parameters): - p = parameters(period).gov.dwp.pension_credit.earnings_disregard + pc = parameters(period).gov.dwp.pension_credit + p = pc.earnings_disregard person = benunit.members - claimant_or_partner = person("is_uc_claimant", period) + claimant_or_partner = person("is_claimant_or_partner", period) + on_disability_benefit = ( add(person, period, p.higher.disability_benefits) > 0 ) | person("is_blind", period) @@ -43,7 +52,10 @@ def formula(benunit, period, parameters): benunit.any(claimant_or_partner & on_disability_benefit) | unit_on_disability_benefit ) - carer = benunit("carer_minimum_guarantee_addition", period) > 0 + # Sch. I para. 4: a claimant or partner entitled to carer's allowance. + carer = benunit.any( + claimant_or_partner & person("is_carer_for_benefits", period) + ) lone_parent = benunit("is_lone_parent", period) relation_type = benunit("relation_type", period) weekly = where( @@ -51,5 +63,21 @@ def formula(benunit, period, parameters): p.higher.amount, p.standard[relation_type], ) - earnings = max_(0, benunit("pension_credit_earnings", period)) - return min_(weekly * WEEKS_IN_YEAR, earnings) + + # Net earnings of the claimant and partner (regs. 17(10), 17A(4A)). + gross = max_(0, add(person, period, pc.guarantee_credit.earnings_sources)) + national_insurance = add( + person, period, ["ni_class_1_employee", "ni_class_2", "ni_class_4"] + ) + basic_rate = parameters(period).gov.hmrc.income_tax.rates.uk.rates[0] + income_tax_on_earnings = min_(person("income_tax", period), basic_rate * gross) + pension_contributions = ( + person("pension_contributions", period) + * pc.income.pension_contributions_deduction + ) + net = max_( + 0, + gross - national_insurance - income_tax_on_earnings - pension_contributions, + ) + net_earnings = benunit.sum(net * claimant_or_partner) + return min_(weekly * WEEKS_IN_YEAR, net_earnings) From 816a4e1ea156f138b054364ce3822e07a3d27a9d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 4 Oct 2026 06:34:20 -0400 Subject: [PATCH 03/10] Tax earnings as the top slice on the person's schedule; carer entitlement (review of #2019) - Income tax on earnings is now the tax on them as the top slice of taxable non-savings income on the person's own Scottish or rest-of-UK schedule, capped at their income tax. The 20% cap let earnings crossing Scottish bands raise Pension Credit (reviewer's couple: +2.89). - The para 3 carer condition reads is_entitled_to_carer_benefit, the carer addition's own Sch I para 4 test; caring hours alone no longer qualify (reviewer's case overpaid 780 a year). - Tests: Scottish couple pair (income flat with and without earnings), hours-only vs entitled carer cases; the property test adds Scottish households, carer entitlement, care hours and pension contributions, and measures tax on earnings independently by rerunning without them. Co-Authored-By: Claude Opus 5.5 --- .../pension_credit_earnings_disregard.yaml | 96 ++++++++++++++++++- ...on_credit_earnings_disregard_properties.py | 62 ++++++++---- .../pension_credit_earnings_disregard.py | 30 ++++-- 3 files changed, 160 insertions(+), 28 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index 1a042c7dba..709c69578e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -78,7 +78,7 @@ claimant: age: 70 employment_income: 2000 - is_carer_for_benefits: true + is_entitled_to_carer_benefit: true benunits: benunit: members: [claimant] @@ -304,7 +304,7 @@ age: 70 child: age: 17 - is_carer_for_benefits: true + is_entitled_to_carer_benefit: true benunits: benunit: members: [claimant, partner, child] @@ -313,3 +313,95 @@ members: [claimant, partner, child] output: pension_credit_earnings_disregard: 10 * 52 + +- name: A Scottish taxpayer's tax on earnings is their top slice on the Scottish schedule, so Pension Credit income does not fall with earnings. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 16_000 + attendance_allowance: 5_000 + employment_income: 1_040 + partner: + age: 70 + attendance_allowance: 5_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: SCOTLAND + output: + # The 1,040 of earnings add 210.89 of income tax (they cross into the + # Scottish intermediate band), so net earnings are 829.11 and the 20 pound + # disregard of 1,040 is capped there; income is as with no earnings (next). + income_tax: [3_016.62, 0] + pension_credit_earnings_disregard: 829.11 + pension_credit_income: 25_194.27 + +- name: The same Scottish couple with no earnings has the same Pension Credit income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 16_000 + attendance_allowance: 5_000 + partner: + age: 70 + attendance_allowance: 5_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: SCOTLAND + output: + income_tax: [2_805.73, 0] + pension_credit_earnings_disregard: 0 + pension_credit_income: 25_194.27 + +- name: Caring hours without entitlement to carer's allowance do not qualify for the 20 pound disregard (Sch VI para 3 needs Sch I para 4). + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + care_hours: 40 + is_entitled_to_carer_benefit: false + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 + +- name: Entitlement to carer's allowance qualifies for the 20 pound disregard. + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + care_hours: 40 + is_entitled_to_carer_benefit: true + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py index 5c1e475c86..c60d3de0f2 100644 --- a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -1,13 +1,15 @@ """Properties of the Pension Credit earnings disregards (SPC Regs 2002 Sch VI). -Invariants, over generated benefit units (England, so the basic rate is 20%): +Invariants, over generated benefit units in England and Scotland: 1. Statute: the disregard equals min(net earnings, 52 x w). Net earnings are the claimant's and partner's gross earnings less earnings NI, half of - pension contributions and income tax on the earnings (the lesser of their - income tax and 20% of their earnings), recomputed here from the inputs and - the model's tax and NI. w is 20 if the unit is a lone-parent family, or a - claimant or partner is a carer (an input here), receives a listed + pension contributions and income tax on the earnings. The tax on a + person's earnings is measured independently, as the fall in their income + tax when the simulation is rerun without their earnings (the earnings as + the top slice, on their own Scottish or rest-of-UK schedule). w is 20 if + the unit is a lone-parent family, or a claimant or partner is entitled to + carer's allowance (an input here), receives a listed disability benefit or is blind; otherwise 5 single, 10 couple. A dependent child's DLA or caring never qualifies. 2. Bounds: 0 <= disregard <= min(gross earnings, 20 x 52). @@ -47,6 +49,7 @@ @st.composite def units(draw): + country = draw(st.sampled_from(["ENGLAND", "SCOTLAND"])) couple = draw(st.booleans()) child = draw(st.booleans()) people = {} @@ -59,7 +62,11 @@ def units(draw): "state_pension": draw(st.integers(min_value=0, max_value=12_000)), "private_pension_income": draw(st.sampled_from([0, 2_000, 6_000])), "is_blind": draw(st.sampled_from([False, False, False, True])), - "is_carer_for_benefits": draw(st.sampled_from([False, False, False, True])), + "is_entitled_to_carer_benefit": draw( + st.sampled_from([False, False, False, True]) + ), + "care_hours": draw(st.sampled_from([0, 0, 40])), + "personal_pension_contributions": draw(st.sampled_from([0, 0, 300])), } benefit = draw(st.sampled_from([None, None] + DISABILITY)) if benefit: @@ -70,12 +77,13 @@ def units(draw): people["child"] = { "age": 10, "dla_sc": draw(st.sampled_from([0, 2_000])), - "is_carer_for_benefits": draw(st.booleans()), + "is_entitled_to_carer_benefit": draw(st.booleans()), } - return people + return {"people": people, "country": country} -def simulate(people): +def simulate(case): + people = case["people"] members = list(people) dated = { name: {k: {str(YEAR): v} for k, v in person.items()} @@ -85,11 +93,20 @@ def simulate(people): situation={ "people": dated, "benunits": {"b": {"members": members}}, - "households": {"h": {"members": members}}, + "households": { + "h": {"members": members, "country": {str(YEAR): case["country"]}} + }, } ) +def without_earnings(case, name): + people = {n: dict(p) for n, p in case["people"].items()} + people[name]["employment_income"] = 0 + people[name]["self_employment_income"] = 0 + return {"people": people, "country": case["country"]} + + def unit_value(sim, variable): return float(sim.calculate(variable, YEAR)[0]) @@ -100,8 +117,9 @@ def person_values(sim, variable): @SETTINGS @given(units()) -def test_statute_bounds_differential_and_monotonicity(people): - sim = simulate(people) +def test_statute_bounds_differential_and_monotonicity(case): + people = case["people"] + sim = simulate(case) names = list(people) adults = [n != "child" for n in names] gross = np.array( @@ -117,7 +135,12 @@ def test_statute_bounds_differential_and_monotonicity(people): person_values(sim, v) for v in ["ni_class_1_employee", "ni_class_2", "ni_class_4"] ) - tax_on_earnings = np.minimum(person_values(sim, "income_tax"), 0.2 * gross) + income_tax = person_values(sim, "income_tax") + tax_on_earnings = np.zeros(len(names)) + for i, name in enumerate(names): + if gross[i] > 0: + rerun = person_values(simulate(without_earnings(case, name)), "income_tax") + tax_on_earnings[i] = max(0.0, income_tax[i] - rerun[i]) contributions = 0.5 * person_values(sim, "pension_contributions") net = np.maximum(0, gross - ni - tax_on_earnings - contributions) net_earnings = float(net[adults].sum()) @@ -128,7 +151,7 @@ def test_statute_bounds_differential_and_monotonicity(people): p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) for p in adult_people ) - carer = any(p.get("is_carer_for_benefits") for p in adult_people) + carer = any(p.get("is_entitled_to_carer_benefit") for p in adult_people) lone_parent = "child" in people and not couple weekly = 20 if (disabled or carer or lone_parent) else (10 if couple else 5) expected = min(net_earnings, weekly * WEEKS_IN_YEAR) @@ -157,11 +180,14 @@ def test_statute_bounds_differential_and_monotonicity(people): @SETTINGS @given(units(), st.sampled_from([1, 50, 260, 1_040, 5_000])) -def test_earning_more_never_raises_pension_credit(people, extra): - more = {name: dict(person) for name, person in people.items()} +def test_earning_more_never_raises_pension_credit(case, extra): + more = {name: dict(person) for name, person in case["people"].items()} more["claimant"]["employment_income"] += extra - before = unit_value(simulate(people), "pension_credit_entitlement") - after = unit_value(simulate(more), "pension_credit_entitlement") + before = unit_value(simulate(case), "pension_credit_entitlement") + after = unit_value( + simulate({"people": more, "country": case["country"]}), + "pension_credit_entitlement", + ) assert after <= before + 0.01 diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py index 549bd43a6a..2e39753ac5 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py @@ -15,10 +15,13 @@ class pension_credit_earnings_disregard(Variable): "sums 'in calculating the claimant's earnings', which are net of income " "tax and National Insurance (reg. 17(10)) and of half of pension " "contributions (reg. 17A(4A)), so the disregard never exceeds the " - "claimant's and partner's net earnings. Income tax on earnings is taken " - "as the lesser of the person's income tax and the basic rate on their " - "earnings, the tax deducted where the personal allowance goes against " - "pension income first. Not modelled: the employment-specific 20 pound " + "claimant's and partner's net earnings. Income tax on earnings is the " + "tax on them as the top slice of the person's taxable non-savings " + "income, on the person's own rate schedule (Scottish or rest of UK), " + "capped at their income tax: the tax deducted from the earnings where " + "the personal allowance goes against pension income first (DMG " + "86038-86039), so earning more never raises Pension Credit. Not " + "modelled: the employment-specific 20 pound " "disregards of paras. 2 to 2B (part-time firefighters, auxiliary " "coastguards, lifeboat crew, reserve forces) and the transitional " "protections of para. 4(2) to (4)." @@ -52,9 +55,10 @@ def formula(benunit, period, parameters): benunit.any(claimant_or_partner & on_disability_benefit) | unit_on_disability_benefit ) - # Sch. I para. 4: a claimant or partner entitled to carer's allowance. + # Sch. I para. 4: a claimant or partner entitled to carer's allowance + # (the carer addition's own test), not caring hours alone. carer = benunit.any( - claimant_or_partner & person("is_carer_for_benefits", period) + claimant_or_partner & person("is_entitled_to_carer_benefit", period) ) lone_parent = benunit("is_lone_parent", period) relation_type = benunit("relation_type", period) @@ -69,8 +73,18 @@ def formula(benunit, period, parameters): national_insurance = add( person, period, ["ni_class_1_employee", "ni_class_2", "ni_class_4"] ) - basic_rate = parameters(period).gov.hmrc.income_tax.rates.uk.rates[0] - income_tax_on_earnings = min_(person("income_tax", period), basic_rate * gross) + # Tax on the earnings as the top slice of taxable non-savings income. + rates = parameters(period).gov.hmrc.income_tax.rates + taxable = person("earned_taxable_income", period) + below_earnings = max_(0, taxable - min_(gross, taxable)) + scottish = person("pays_scottish_income_tax", period) + top_slice_tax = where( + scottish, + rates.scotland.rates.calc(taxable) + - rates.scotland.rates.calc(below_earnings), + rates.uk.calc(taxable) - rates.uk.calc(below_earnings), + ) + income_tax_on_earnings = min_(person("income_tax", period), top_slice_tax) pension_contributions = ( person("pension_contributions", period) * pc.income.pension_contributions_deduction From d91b322237c2d7cd7d39b5434b03a1c77ed87900 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 09:17:14 -0400 Subject: [PATCH 04/10] Measure Pension Credit net earnings as the statute does - Employed earnings (SPC Regs 2002 regs 17(10), 17A): less the income tax they add for the claimant and partner, measured on a branch with nobody's employment income (marginal attribution; methodology ruling d1424), and primary Class 1. - Self-employed earnings (regs 17(11), 17B; CER 1996 regs 13(4), 14): less notional basic-rate tax on profit over personal reliefs, main-rate Class 4 and (before April 2024) Class 2. Pension Credit income replaces the tax and contributions actually payable on the profit with these. - Each source floored at nil (CER reg 13(12)). - Sch VI para 4(1)(a)(vi): working tax credit with a disability element. - HB savings-credit-only route (HB(SPC) Regs reg 27(4)): deduct only the higher HB disregards, since Pension Credit income is now after Sch VI. - Regression cases for savings interactions, pension relief, losses, notional deductions; property tests for statute, flatness under the disregard and monotonicity in either partner's earnings. Co-Authored-By: Claude Opus 5.5 --- changelog.d/pc-earnings-disregard.fixed.md | 2 +- .../higher/disability_benefits.yaml | 8 +- .../housing_benefit_savings_credit_only.yaml | 89 ++++-- .../pension_credit_earnings_disregard.yaml | 272 +++++++++++++++++- .../pension_credit/pension_credit_income.yaml | 8 +- ...on_credit_earnings_disregard_properties.py | 255 +++++++++++----- .../test_pension_credit_income_losses.py | 7 +- ...sing_benefit_savings_credit_only_income.py | 66 +++-- .../pension_credit_earnings_disregard.py | 86 ++++-- .../pension_credit_earnings_income_tax.py | 133 +++++++++ .../pension_credit/pension_credit_income.py | 19 +- ...nsion_credit_self_employment_deductions.py | 67 +++++ 12 files changed, 844 insertions(+), 168 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py create mode 100644 policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py diff --git a/changelog.d/pc-earnings-disregard.fixed.md b/changelog.d/pc-earnings-disregard.fixed.md index 8a95e17dbe..53ad66d2e6 100644 --- a/changelog.d/pc-earnings-disregard.fixed.md +++ b/changelog.d/pc-earnings-disregard.fixed.md @@ -1 +1 @@ -Apply the Pension Credit earnings disregards of SPC Regs 2002 Schedule VI: 20 pounds a week for lone parents, carers and claimants or partners on a listed disability benefit or certified blind, otherwise 5 pounds (single) or 10 pounds (couple), capped at earnings. +Apply the Pension Credit earnings disregards of SPC Regs 2002 Schedule VI: 20 pounds a week for lone parents, carers and claimants or partners on a listed disability benefit (including working tax credit with a disability element) or certified blind, otherwise 5 pounds (single) or 10 pounds (couple), capped at net earnings. Employed earnings are net of the income tax they add; self-employed earnings, in Pension Credit income too, are net of the notional tax and contributions of the Computation of Earnings Regulations 1996 reg. 14. The Housing Benefit savings-credit-only route now deducts only the higher Housing Benefit disregards. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml index e2ea6f3328..6aa826b95b 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/earnings_disregard/higher/disability_benefits.yaml @@ -8,10 +8,10 @@ description: > the war pensions mobility supplement (v), which has no variable; pension age disability payment (iiia, from 21 October 2024), Scottish adult disability living allowance (iva, from 21 March 2025) and adult disability payment - (viiia, from 21 March 2022), which have no separate variables; and the - working tax credit disability elements (vi), left out because in the model - they follow from DLA or PIP, which are listed, and working tax credit ended - in April 2025. + (viiia, from 21 March 2022), which have no separate variables. Working tax + credit with its disability or severe disability element (vi) is a + benefit-unit award, tested in the formula of + `pension_credit_earnings_disregard`. metadata: label: Pension Credit higher earnings disregard disability benefits unit: list diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml index bc8af60aad..4df1327e70 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_savings_credit_only.yaml @@ -141,8 +141,9 @@ pension_credit_assessable_capital: [20_000, 0] # 18,000 + 1,040 = 19,040, above the couple minimum guarantee of 18,889. # The non-dependant's is computed but he cannot have Pension Credit: - # 20,000 - 1,486 income tax - 594.40 National Insurance = 17,919.60. - pension_credit_income: [19_040, 17_919.60] + # 20,000 - 1,486 income tax - 594.40 National Insurance - the 5 pound + # earnings disregard (260) = 17,659.60. + pension_credit_income: [19_040, 17_659.60] guarantee_credit: [0, 0] # 1,045.20 - 0.4 x (19,040 - 18,889) = 1,045.20 - 60.40 = 984.80. savings_credit: [984.80, 0] @@ -169,9 +170,8 @@ members: [person1] # £5 a week for a single claimant with earnings (Sch 4 para 7(a)), # the same as the Pension Credit disregard (SPC Regs 2002 Sch VI - # para 5(a)); 5 x 52 = 260. Pension Credit income in the model - # disregards no earnings, so deducting it gives the Secretary of - # State's figure. + # para 5(a)); 5 x 52 = 260. The Secretary of State's figure is + # already after it, and reg 27(4) deducts only higher amounts. housing_benefit_applicable_income_disregard: 260 households: household: @@ -180,14 +180,15 @@ tenure_type: RENT_FROM_COUNCIL savings: 0 output: - # 13,000 - 86 = 12,914, as in case 1. - pension_credit_income: 12_914 - savings_credit: 718.62 + # 13,000 - 86 - the 5 pound Pension Credit earnings disregard (260). + pension_credit_income: 12_654 + # 933.82 - 0.4 x (12,654 - 12,376) = 933.82 - 111.20 = 822.62. + savings_credit: 822.62 in_receipt_of_savings_credit_only: true - # 12,914 + 718.62 - 260 = 13,372.62. - housing_benefit_applicable_income: 13_372.62 - # 7,800 - 0.65 x (13,372.62 - 13,312) = 7,800 - 39.40 = 7,760.60. - housing_benefit: 7_760.60 + # 12,654 + 822.62 = 13,476.62; nothing more is disregarded. + housing_benefit_applicable_income: 13_476.62 + # 7,800 - 0.65 x (13,476.62 - 13,312) = 7,800 - 107.00 = 7,693.00. + housing_benefit: 7_693 - name: Case 5, savings credit only lone parent in work, childcare charges and the lone parent and additional earnings disregards are deducted. period: 2026 @@ -196,7 +197,7 @@ people: person1: age: 78 - state_pension: 11_000 + state_pension: 12_500 employment_income: 7_000 # 20 hours a week, so remunerative work (reg 6) and the reg 31 # condition for deducting childcare charges are met. @@ -207,13 +208,10 @@ benunits: benunit: members: [person1, person2] - # Reg 27(4)(c)(i): the higher amount disregarded for a lone parent's - # earnings, £25 a week (Sch 4 para 2) against Pension Credit's £20 - # (SPC Regs 2002 Sch VI para 1); and (d): the additional £17.10 (Sch - # 4 para 9(1)) for a lone parent working 16 hours or more whose net - # earnings cover the disregards and childcare. Pension Credit income - # in the model disregards no earnings, so the whole of both is - # deducted: (25 + 17.10) x 52 = 2,189.20, the value #1908 gives. + # The lone parent's £25 a week (Sch 4 para 2) and the additional + # £17.10 (Sch 4 para 9(1)) for a lone parent working 16 hours or more + # whose net earnings cover the disregards and childcare: + # (25 + 17.10) x 52 = 2,189.20, the value #1908 gives. housing_benefit_applicable_income_disregard: 2_189.20 households: household: @@ -222,20 +220,57 @@ tenure_type: RENT_FROM_COUNCIL savings: 0 output: - # (18,000 - 12,570) x 0.2 = 1,086; 18,000 - 1,086 = 16,914. - pension_credit_income: 16_914 + # The lone parent's 20 pound Pension Credit disregard (SPC Regs 2002 Sch + # VI para 1): 20 x 52 = 1,040. + pension_credit_earnings_disregard: 1_040 + # (19,500 - 12,570) x 0.2 = 1,386; 19,500 - 1,386 - 1,040 = 17,074. + pension_credit_income: 17_074 # 12,376 + 3,638.96 = 16,014.96. minimum_guarantee: 16_014.96 guarantee_credit: 0 - # 933.82 - 0.4 x (16,914 - 16,014.96) = 933.82 - 359.62 = 574.20. - savings_credit: 574.20 + # 933.82 - 0.4 x (17,074 - 16,014.96) = 933.82 - 423.62 = 510.20. + savings_credit: 510.20 in_receipt_of_savings_credit_only: true # Reg 27(4)(b): 2,600 of childcare (£50 a week), below the weekly # maximum and the earnings. housing_benefit_applicable_income_childcare_element: 2_600 - # 16,914 + 574.20 - 2,600 - 2,189.20 = 12,699. - housing_benefit_savings_credit_only_income: 12_699 - housing_benefit_applicable_income: 12_699 + # Reg 27(4)(c)(i): the higher amount for a lone parent's earnings, + # 1,300 - 1,040 = 260; (d): the additional 17.10 x 52 = 889.20. + # 17,074 + 510.20 - 2,600 - 260 - 889.20 = 13,835. + housing_benefit_savings_credit_only_income: 13_835 + housing_benefit_applicable_income: 13_835 + +- name: Case 5a, the same lone parent with less State Pension, whose 20 pound Pension Credit disregard brings a guarantee credit and its passport. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 78 + state_pension: 11_000 + employment_income: 7_000 + hours_worked: 1_040 + person2: + age: 8 + childcare_expenses: 2_600 + benunits: + benunit: + members: [person1, person2] + housing_benefit_applicable_income_disregard: 2_189.20 + households: + household: + members: [person1, person2] + rent: 7_800 + tenure_type: RENT_FROM_COUNCIL + savings: 0 + output: + # (18,000 - 12,570) x 0.2 = 1,086; 18,000 - 1,086 - 1,040 = 15,874. + pension_credit_income: 15_874 + # 16,014.96 - 15,874 = 140.96. + guarantee_credit: 140.96 + in_receipt_of_savings_credit_only: false + # The guarantee credit passports the maximum Housing Benefit: the rent. + housing_benefit: 7_800 - name: Case 6, guarantee credit and savings credit together, the award is not savings credit only and the guarantee credit passport applies. period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index 709c69578e..e5d98d6d72 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -266,9 +266,10 @@ household: members: [claimant] output: - # Income tax (16,040 - 12,570) x 20% = 694. Tax on the earnings is the - # lesser of 694 and 20% x 1,040 = 208, so net earnings are 832; the 20 - # pound (attendance allowance) disregard of 1,040 is capped at 832. + # Income tax (16,040 - 12,570) x 20% = 694, against 486 without the + # earnings (next case), so tax on the earnings is 208 and net earnings are + # 832; the 20 pound (attendance allowance) disregard of 1,040 is capped at + # 832. pension_credit_earnings_disregard: 832 # 16,040 - 694 - 832, the same as with no earnings (next case). pension_credit_income: 14_514 @@ -314,7 +315,7 @@ output: pension_credit_earnings_disregard: 10 * 52 -- name: A Scottish taxpayer's tax on earnings is their top slice on the Scottish schedule, so Pension Credit income does not fall with earnings. +- name: A Scottish taxpayer's tax on earnings is the income tax the earnings add on the Scottish schedule, so Pension Credit income does not fall with earnings. period: 2025 absolute_error_margin: 0.01 input: @@ -405,3 +406,266 @@ members: [claimant] output: pension_credit_earnings_disregard: 20 * 52 + +- name: Tax on earnings includes the tax they add on savings income, so Pension Credit income does not fall with earnings. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + savings_interest_income: 12_000 + attendance_allowance: 3_000 + employment_income: 1_040 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # Income tax is 1,294 with the earnings and 1,086 without (next case): the + # earnings take 470 of non-savings income over the personal allowance, + # and take the personal allowance off 570 of savings interest. Tax on the + # earnings is 208, so net earnings are 832 and the 20 pound disregard of + # 1,040 is capped there. Pension Credit counts deemed income from capital + # rather than the interest, so its income is 12,000 + 1,040 - 1,294 - 832. + income_tax: 1_294 + pension_credit_employment_income_tax: 208 + pension_credit_earnings_disregard: 832 + pension_credit_income: 10_914 + +- name: The same claimant with savings and no earnings has the same Pension Credit income. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + savings_interest_income: 12_000 + attendance_allowance: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + income_tax: 1_086 + pension_credit_earnings_disregard: 0 + # 12,000 - 1,086. + pension_credit_income: 10_914 + +- name: Tax on earnings is net of the pension tax relief the earnings unlock. + period: 2024 + input: + people: + claimant: + age: 70 + private_pension_income: 15_000 + employment_income: 400 + personal_pension_contributions: 300 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # The earnings add 400 x 20% = 80 of tax but let the 300 of contributions + # be relieved, worth 60: income tax is 506, against 486 without them + # (next case), so tax on the earnings is 20. Net earnings are 400 - 20 - + # 300 / 2 = 230, under the 5 pound disregard of 260. + income_tax: 506 + pension_credit_employment_income_tax: 20 + pension_credit_earnings_disregard: 230 + # 15,400 - 506 - 150 - 230: more than without the earnings, so earning + # more never raises Pension Credit. + pension_credit_income: 14_514 + +- name: The same claimant with no earnings. + period: 2024 + input: + people: + claimant: + age: 70 + private_pension_income: 15_000 + personal_pension_contributions: 300 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + income_tax: 486 + pension_credit_earnings_disregard: 0 + # 15,000 - 486 - 300 / 2. + pension_credit_income: 14_364 + +- name: A self-employment loss is not set against wages in the disregard (CER 1996 reg 13(12)). + period: 2025 + input: + people: + claimant: + age: 70 + employment_income: 400 + self_employment_income: -8_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # Net earnings are the 400 of wages; the 5 pound disregard is 260. + pension_credit_earnings_disregard: 260 + pension_credit_income: 400 - 260 + +- name: Self-employed earnings under the personal allowance carry no notional tax, and the tax they actually cause is not deducted (CER 1996 reg 14; SPC reg 17(11)). + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + self_employment_income: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # The State Pension uses all but 570 of the personal allowance, so the + # profit causes all of the (15,000 - 12,570) x 20% = 486 of income tax. + income_tax: 486 + pension_credit_self_employment_income_tax: 486 + # Notional tax at the basic rate on the profit less the personal + # allowance alone is nil; Class 4 is not payable over State Pension age. + pension_credit_self_employment_deductions: 0 + pension_credit_earnings_disregard: 260 + # 15,000 - (486 - 486 + 0) - 260. + pension_credit_income: 14_740 + +- name: Profit within the trading allowance is tax-free and fully disregarded up to the 20 pound limit. + period: 2025 + input: + people: + claimant: + age: 70 + state_pension: 12_000 + private_pension_income: 3_000 + attendance_allowance: 3_000 + self_employment_income: 1_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # The 1,000 trading allowance relieves the profit, so income tax is the + # (15,000 - 12,570) x 20% = 486 on the pensions. + income_tax: 486 + pension_credit_self_employment_income_tax: 0 + pension_credit_earnings_disregard: 1_000 + # 16,000 - 486 - 1,000, the same as with no profit: 15,000 - 486. + pension_credit_income: 14_514 + +- name: A Scottish self-employed earner's notional tax is at the Scottish basic rate, with main-rate Class 4 under State Pension age. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + self_employment_income: 20_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: SCOTLAND + output: + # (20,000 - 12,570) x 20% = 1,486, not the 1,457.73 of income tax on the + # Scottish starter, basic and intermediate bands; Class 4 is + # (20,000 - 12,570) x 6% = 445.80. + income_tax: 1_457.73 + pension_credit_self_employment_deductions: 1_486 + 445.80 + +- name: Before April 2024 the notional contributions include Class 2 (CER 1996 reg 14(2)(a)). + period: 2023 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + self_employment_income: 20_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # (20,000 - 12,570) x 20% = 1,486; Class 4 (20,000 - 12,570) x 9% = + # 668.70; Class 2 3.45 x 52 = 179.40. + pension_credit_self_employment_deductions: 1_486 + 668.70 + 179.40 + +- name: The notional tax deducts the blind person's allowance with the personal allowance (ITA 2007 Part 3 Chapter 2). + period: 2024 + input: + people: + claimant: + age: 70 + self_employment_income: 20_000 + blind_persons_allowance: 3_070 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + # (20,000 - 12,570 - 3,070) x 20%. + pension_credit_self_employment_deductions: 872 + +- name: Working tax credit with its disability element qualifies for the 20 pound disregard (Sch VI para 4(1)(a)(vi)). + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + benunits: + benunit: + members: [claimant] + WTC_disabled_element: 3_000 + working_tax_credit: 1_000 + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 20 * 52 + +- name: The disability element counts only with working tax credit in payment. + period: 2024 + input: + people: + claimant: + age: 70 + state_pension: 9_000 + employment_income: 2_000 + benunits: + benunit: + members: [claimant] + WTC_disabled_element: 3_000 + working_tax_credit: 0 + households: + household: + members: [claimant] + output: + pension_credit_earnings_disregard: 5 * 52 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml index 71853707b6..d98e564feb 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_income.yaml @@ -219,6 +219,8 @@ tenure_type: OWNED_OUTRIGHT output: pension_credit_earnings: 10_000 - pension_credit_income: 10_000 - # 238 x 52 = 12,376, less income of 10,000. - pension_credit: 2_376 + # 10,000 less the 5 pound a week earnings disregard (260); the earnings + # are under the personal allowance and the primary threshold. + pension_credit_income: 9_740 + # 238 x 52 = 12,376, less income of 9,740. + pension_credit: 2_636 diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py index c60d3de0f2..cad0a87ef3 100644 --- a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -1,37 +1,53 @@ -"""Properties of the Pension Credit earnings disregards (SPC Regs 2002 Sch VI). +"""Properties of the Pension Credit earnings disregards (SPC Regs 2002 Sch VI) +and of the net earnings they apply to (regs. 17(10), 17(11), 17A and 17B). Invariants, over generated benefit units in England and Scotland: -1. Statute: the disregard equals min(net earnings, 52 x w). Net earnings are - the claimant's and partner's gross earnings less earnings NI, half of - pension contributions and income tax on the earnings. The tax on a - person's earnings is measured independently, as the fall in their income - tax when the simulation is rerun without their earnings (the earnings as - the top slice, on their own Scottish or rest-of-UK schedule). w is 20 if - the unit is a lone-parent family, or a claimant or partner is entitled to - carer's allowance (an input here), receives a listed +1. Statute: the disregard equals min(net earnings, 52 x w), recomputed here + from the inputs and independent simulations: + - each person's own earnings, each source floored at nil, less their + primary Class 1 contributions, half of their pension contributions and, + on self-employed profit, the notional tax and contributions of CER 1996 + reg. 14 (basic rate on the profit less the personal allowance and any + marriage allowance; main-rate Class 4 under State Pension age), floored + at nil; + - less the income tax payable on the claimant's and partner's employed + earnings, measured as the fall in their income tax when the household + is simulated again with nobody's employment income. + w is 20 if the unit is a lone-parent family, or a claimant or partner is + entitled to carer's allowance (an input here), receives a listed disability benefit or is blind; otherwise 5 single, 10 couple. A dependent child's DLA or caring never qualifies. 2. Bounds: 0 <= disregard <= min(gross earnings, 20 x 52). -3. Differential: Pension Credit income is the pre-change income (sources less - tax, NI and half of pension contributions, floored at 0) less the - disregard, floored at 0. +3. Differential: Pension Credit income is the sources less tax, NI and half + of pension contributions, with the tax and contributions actually payable + on self-employed profit replaced by the notional ones, floored at 0, less + the disregard, floored at 0. 4. Monotonicity: the guarantee credit is never below its value without the - disregard. -5. Earning more never raises Pension Credit entitlement (reg 17(9) applies the - disregard to net earnings). -6. The dated benefit lists follow the amending instruments: ESA from 27 + disregard (for units with nobody under State Pension age). +5. Earning more, employed or self-employed, by either partner, never raises + Pension Credit. +6. Under the disregard, Pension Credit income does not change with earnings. +7. The dated benefit lists follow the amending instruments: ESA from 27 October 2008, PIP and AFIP from 8 April 2013. +8. The Scottish basic rate read for the notional tax is 20% in every year, + including those before the starter rate. """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, assume, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation from policyengine_uk.model_api import WEEKS_IN_YEAR YEAR = 2024 +BASIC_RATE = 0.2 +PERSONAL_ALLOWANCE = 12_570 +CLASS_4_MAIN_RATE = 0.06 +LOWER_PROFITS_LIMIT = 12_570 +UPPER_PROFITS_LIMIT = 50_270 +STATE_PENSION_AGE = 66 SETTINGS = settings( max_examples=25, deadline=None, @@ -56,11 +72,16 @@ def units(draw): names = ["claimant"] + (["partner"] if couple else []) for n in names: person = { - "age": draw(st.sampled_from([67, 72, 85])), + "age": draw( + st.sampled_from([67, 72, 85] + ([64] if n == "partner" else [])) + ), "employment_income": draw(st.sampled_from([0, 50, 400, 1_040, 3_000])), - "self_employment_income": draw(st.sampled_from([0, 0, 1_500])), + "self_employment_income": draw( + st.sampled_from([0, 0, 1_500, 15_000, -2_000]) + ), "state_pension": draw(st.integers(min_value=0, max_value=12_000)), "private_pension_income": draw(st.sampled_from([0, 2_000, 6_000])), + "savings_interest_income": draw(st.sampled_from([0, 0, 3_000])), "is_blind": draw(st.sampled_from([False, False, False, True])), "is_entitled_to_carer_benefit": draw( st.sampled_from([False, False, False, True]) @@ -100,10 +121,13 @@ def simulate(case): ) -def without_earnings(case, name): +def without(case, sources): + """The case with nobody's income from each of ``sources``.""" people = {n: dict(p) for n, p in case["people"].items()} - people[name]["employment_income"] = 0 - people[name]["self_employment_income"] = 0 + for person in people.values(): + for source in sources: + if source in person: + person[source] = 0 return {"people": people, "country": case["country"]} @@ -115,58 +139,100 @@ def person_values(sim, variable): return np.asarray(sim.calculate(variable, YEAR, map_to="person"), dtype=float) +def weekly_amount(people): + adult_people = [p for n, p in people.items() if n != "child"] + couple = len(adult_people) == 2 + disabled = any( + p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) + for p in adult_people + ) + carer = any(p.get("is_entitled_to_carer_benefit") for p in adult_people) + lone_parent = "child" in people and not couple + return 20 if (disabled or carer or lone_parent) else (10 if couple else 5) + + +def notional_deductions(person, marriage_allowance): + """CER 1996 reg. 14 on the person's profit, from the inputs: no + generated adult is blind-person's-allowance relieved, born before 1935 + or liable for Class 2 (abolished in 2024-25).""" + profit = max(0, person.get("self_employment_income", 0)) + if profit == 0: + return 0.0 + tax = BASIC_RATE * max(0, profit - PERSONAL_ALLOWANCE - marriage_allowance) + class_4 = 0.0 + if person["age"] < STATE_PENSION_AGE: + class_4 = CLASS_4_MAIN_RATE * min( + max(0, profit - LOWER_PROFITS_LIMIT), + UPPER_PROFITS_LIMIT - LOWER_PROFITS_LIMIT, + ) + return tax + class_4 + + @SETTINGS @given(units()) def test_statute_bounds_differential_and_monotonicity(case): people = case["people"] sim = simulate(case) names = list(people) - adults = [n != "child" for n in names] - gross = np.array( - [ - people[n]["employment_income"] + people[n].get("self_employment_income", 0) - if n != "child" - else 0 - for n in names - ], + adult = np.array([n != "child" for n in names]) + employment = np.array( + [max(0, people[n].get("employment_income", 0)) for n in names], dtype=float + ) + self_employment = np.array( + [max(0, people[n].get("self_employment_income", 0)) for n in names], dtype=float, ) - ni = sum( - person_values(sim, v) - for v in ["ni_class_1_employee", "ni_class_2", "ni_class_4"] + marriage_allowance = person_values(sim, "marriage_allowance") + notional = np.array( + [ + notional_deductions(people[n], marriage_allowance[i]) + for i, n in enumerate(names) + ] + ) + assert np.allclose( + person_values(sim, "pension_credit_self_employment_deductions"), + notional, + atol=0.01, ) - income_tax = person_values(sim, "income_tax") - tax_on_earnings = np.zeros(len(names)) - for i, name in enumerate(names): - if gross[i] > 0: - rerun = person_values(simulate(without_earnings(case, name)), "income_tax") - tax_on_earnings[i] = max(0.0, income_tax[i] - rerun[i]) + class_1 = person_values(sim, "ni_class_1_employee") contributions = 0.5 * person_values(sim, "pension_contributions") - net = np.maximum(0, gross - ni - tax_on_earnings - contributions) - net_earnings = float(net[adults].sum()) + own_net = np.maximum( + 0, employment + self_employment - class_1 - notional - contributions + ) - adult_people = [people[n] for n in names if n != "child"] - couple = len(adult_people) == 2 - disabled = any( - p.get("is_blind") or any(p.get(b, 0) > 0 for b in DISABILITY) - for p in adult_people + income_tax = person_values(sim, "income_tax") + no_employment = person_values( + simulate(without(case, ["employment_income"])), "income_tax" ) - carer = any(p.get("is_entitled_to_carer_benefit") for p in adult_people) - lone_parent = "child" in people and not couple - weekly = 20 if (disabled or carer or lone_parent) else (10 if couple else 5) - expected = min(net_earnings, weekly * WEEKS_IN_YEAR) + no_earnings = person_values( + simulate(without(case, ["employment_income", "self_employment_income"])), + "income_tax", + ) + employment_tax = max(0.0, float((income_tax - no_employment)[adult].sum())) + self_employment_tax = max(0.0, float((no_employment - no_earnings)[adult].sum())) + net_earnings = max(0.0, float(own_net[adult].sum()) - employment_tax) + + expected = min(net_earnings, weekly_amount(people) * WEEKS_IN_YEAR) disregard = unit_value(sim, "pension_credit_earnings_disregard") assert np.isclose(disregard, expected, atol=0.01) - assert 0 <= disregard <= min(gross.sum(), 20 * WEEKS_IN_YEAR) + 0.01 + gross = float((employment + self_employment)[adult].sum()) + assert 0 <= disregard <= min(gross, 20 * WEEKS_IN_YEAR) + 0.01 sources = sim.tax_benefit_system.parameters( f"{YEAR}-06-01" ).gov.dwp.pension_credit.guarantee_credit.income total = sum(float(sim.calculate(s, YEAR, map_to="benunit")[0]) for s in sources) + actual_self_employment = ( + self_employment_tax + + float(person_values(sim, "ni_class_2")[adult].sum()) + + float(person_values(sim, "ni_class_4")[adult].sum()) + ) deductions = ( - float(sim.calculate("income_tax", YEAR, map_to="benunit")[0]) - + float(sim.calculate("national_insurance", YEAR, map_to="benunit")[0]) - + 0.5 * float(sim.calculate("pension_contributions", YEAR, map_to="benunit")[0]) + float(income_tax[adult].sum()) + + float(person_values(sim, "national_insurance")[adult].sum()) + - actual_self_employment + + float(notional[adult].sum()) + + float(contributions[adult].sum()) ) before = max(0.0, total - deductions) assert np.isclose( @@ -174,23 +240,76 @@ def test_statute_bounds_differential_and_monotonicity(case): max(0.0, before - disregard), atol=0.01, ) - gc_without = max(0.0, unit_value(sim, "minimum_guarantee") - before) - assert unit_value(sim, "guarantee_credit") >= gc_without - 0.01 + # Mixed-age couples (a partner under State Pension age) get no guarantee + # credit at all, so the comparison is for units entirely over that age. + if all(p["age"] >= STATE_PENSION_AGE for n, p in people.items() if n != "child"): + gc_without = max(0.0, unit_value(sim, "minimum_guarantee") - before) + assert unit_value(sim, "guarantee_credit") >= gc_without - 0.01 + + +EARNINGS_RISES = st.tuples( + st.sampled_from(["claimant", "partner"]), + st.sampled_from(["employment_income", "self_employment_income"]), + st.sampled_from([1, 50, 260, 1_040, 5_000]), +) + + +def raised(case, rise): + name, source, extra = rise + if name not in case["people"]: + name = "claimant" + people = {n: dict(p) for n, p in case["people"].items()} + people[name][source] = people[name].get(source, 0) + extra + return {"people": people, "country": case["country"]} @SETTINGS -@given(units(), st.sampled_from([1, 50, 260, 1_040, 5_000])) -def test_earning_more_never_raises_pension_credit(case, extra): - more = {name: dict(person) for name, person in case["people"].items()} - more["claimant"]["employment_income"] += extra +@given(units(), EARNINGS_RISES) +def test_earning_more_never_raises_pension_credit(case, rise): before = unit_value(simulate(case), "pension_credit_entitlement") - after = unit_value( - simulate({"people": more, "country": case["country"]}), - "pension_credit_entitlement", - ) + after = unit_value(simulate(raised(case, rise)), "pension_credit_entitlement") assert after <= before + 0.01 +@st.composite +def units_under_the_disregard(draw): + """Units with small earnings and no pension contributions or losses, so + the disregard usually covers the net earnings and no floor on a person's + net earnings binds.""" + case = draw(units()) + for person in case["people"].values(): + person.pop("personal_pension_contributions", None) + if "employment_income" in person: + person["employment_income"] = draw(st.sampled_from([0, 50, 100])) + person["self_employment_income"] = draw(st.sampled_from([0, 0, 120])) + return case + + +@settings(SETTINGS, suppress_health_check=list(HealthCheck)) +@given( + units_under_the_disregard(), + st.tuples( + st.sampled_from(["claimant", "partner"]), + st.sampled_from(["employment_income", "self_employment_income"]), + st.sampled_from([1, 50, 100]), + ), +) +def test_pension_credit_income_is_flat_in_earnings_under_the_disregard(case, rise): + sims = [simulate(case), simulate(raised(case, rise))] + cap = weekly_amount(case["people"]) * WEEKS_IN_YEAR + # Where the disregard covers all the net earnings before and after the + # rise, and Pension Credit income is not floored at nil. + assume( + all( + unit_value(sim, "pension_credit_earnings_disregard") < cap - 0.01 + and unit_value(sim, "pension_credit_income") > 0 + for sim in sims + ) + ) + before, after = (unit_value(sim, "pension_credit_income") for sim in sims) + assert np.isclose(before, after, atol=0.05) + + def test_benefit_lists_follow_amending_instruments(): from policyengine_uk.system import system @@ -209,3 +328,11 @@ def lists(date): for date in ["2008-10-26", "2013-04-08", "2026-04-06"]: person, _ = lists(date) assert {"incapacity_benefit", "sda", "attendance_allowance", "dla"} <= person + + +def test_scottish_basic_rate_band_is_twenty_percent_in_every_year(): + from policyengine_uk.system import system + + bands = system.parameters.gov.hmrc.income_tax.rates.scotland.rates.brackets + for year in range(2016, 2030): + assert bands[1].rate(f"{year}-06-01") == 0.2 diff --git a/policyengine_uk/tests/test_pension_credit_income_losses.py b/policyengine_uk/tests/test_pension_credit_income_losses.py index 94e4b5e535..c6d456d4f2 100644 --- a/policyengine_uk/tests/test_pension_credit_income_losses.py +++ b/policyengine_uk/tests/test_pension_credit_income_losses.py @@ -129,8 +129,9 @@ def test_issue_2255_reproducers(): ) # 20,000 less income tax of 20% of (20,000 - 12,570). assert property_loss == (pytest.approx(18_514, abs=1), 0) - # 238 x 52 = 12,376, less wages of 10,000. + # Wages of 10,000 less the 5 pound a week earnings disregard (SPC Regs + # 2002 Sch VI para 5), 260; 238 x 52 = 12,376, less 9,740. assert trading_loss == ( - pytest.approx(10_000, abs=1), - pytest.approx(2_376, abs=1), + pytest.approx(9_740, abs=1), + pytest.approx(2_636, abs=1), ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py index 74fb57f8e4..09bf1fc13f 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py @@ -13,19 +13,14 @@ class housing_benefit_savings_credit_only_income(Variable): "authority takes the Secretary of State's assessment of net income for " "that award and modifies it only for the listed items. Modelled: the " "savings credit payable is added, and childcare charges and the " - "Housing Benefit earnings disregards are deducted. Pension Credit " - "income in the model disregards no earnings, whereas the Secretary of " - "State's figure is after the Pension Credit disregards (£20 for a lone " - "parent, £5 single or £10 couple otherwise). Deducting the Housing " - "Benefit disregards (£25 for a lone parent, £5 or £10 otherwise, and " - "the additional £17.10) therefore gives that figure less the higher " - "Housing Benefit amounts the regulation allows: £5 more for a lone " - "parent, and the additional disregard. This holds while neither " - "programme's £20 disregards for disabled people, carers and some " - "occupations are modelled; for those earners the Secretary of State's " - "figure is after £20, so the omission changes the income assessed, the " - "savings credit payable and possibly whether the award is savings " - "credit only. Childcare charges are deducted as in the general " + "higher Housing Benefit earnings disregards are deducted: a lone " + "parent's 25 pounds over the amount disregarded for Pension Credit, " + "and the additional 17.10 pounds. The Secretary of State's figure is " + "Pension Credit income, which is already after the Pension Credit " + "earnings disregards (20 pounds for a lone parent, carer, disabled " + "or blind earner, otherwise 5 pounds single or 10 pounds couple), so " + "Housing Benefit's own 5 and 10 pound amounts are not deducted again. " + "Childcare charges are deducted as in the general " "route, without the work condition or the cap at earnings. Housing " "Benefit's own income and tariff income rules do not apply to the " "Secretary of State's figure, which already counts Pension Credit " @@ -63,22 +58,35 @@ def formula(benunit, period, parameters): childcare = benunit( "housing_benefit_applicable_income_childcare_element", period ) - # Reg 27(4)(c)(i) (Sch 4 para 2: £25 for a lone parent, against SPC - # Regs 2002 Sch VI para 1's £20) and (d) (Sch 4 para 9(1), the - # additional £17.10; the para 5A exempt work disregard is not - # modelled). NI: reg 25(4)(c)(i) and (d), Sch 5 paras 2 and 9(1). - # The model's Pension Credit income applies no Sch VI disregard, so - # deducting the whole Housing Benefit disregard also takes off the Sch - # VI amounts already inside the Secretary of State's figure: £20 for a - # lone parent (para 1) and £5 or £10 otherwise (para 5), which match - # Sch 4 paras 2 and 7 but for the lone parent's extra £5. Neither - # schedule's £20 disregards (Sch VI paras 2 to 4; Sch 4 paras 3 to 5) - # are modelled. If Sch 4 paras 3 to 5 are added to the disregard - # variable, this route needs the Sch VI grounds instead, as reg 27(4) - # does not let Housing Benefit's own £20 grounds replace them. - # Reg 27(5): Housing Benefit's own income rules, including tariff - # income from capital, do not apply to the Secretary of State's figure. - disregard = benunit("housing_benefit_applicable_income_disregard", period) + # Reg 27(4)(c)(i) and (d) (NI: reg 25(4)(c)(i) and (d)): the + # Secretary of State's figure is after the SPC Regs 2002 Sch VI + # earnings disregards (pension_credit_earnings_disregard), so only the + # higher amounts Housing Benefit allows come off: a lone parent's + # Sch 4 para 2 disregard (25 pounds) over the amount disregarded for + # Pension Credit, and the additional disregard of Sch 4 para 9(1) + # (the para 5A exempt work disregard is not modelled). Housing + # Benefit's other grounds do not replace the Pension Credit ones. This + # route applies only under the pension-age Regulations, where the + # disregard is the standard amount (Sch 4 paras 2 and 7) and the + # additional amount (para 9), with no specified-accommodation amount. + p = parameters(period).gov.dwp.housing_benefit.means_test.income_disregard + net_earnings = benunit("housing_benefit_net_earnings", period) + lone_parent = benunit("is_lone_parent", period) + weekly_amount = select( + [lone_parent, benunit("is_couple", period)], + [p.lone_parent, p.couple], + default=p.single, + ) + standard = min_(weekly_amount * WEEKS_IN_YEAR, net_earnings) + additional = max_( + 0, benunit("housing_benefit_applicable_income_disregard", period) - standard + ) + higher_lone_parent = where( + lone_parent, + max_(0, standard - benunit("pension_credit_earnings_disregard", period)), + 0, + ) + disregard = higher_lone_parent + additional return max_( 0, pension_credit_income + savings_credit_payable - childcare - disregard, diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py index 2e39753ac5..3bbed54e3b 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_disregard.py @@ -8,23 +8,31 @@ class pension_credit_earnings_disregard(Variable): "the State Pension Credit Regulations 2002: 20 pounds a week for a " "lone parent (para. 1), where the claimant or partner is a carer " "satisfying Sch. I para. 4 (para. 3), or where the claimant or partner " - "receives a listed disability benefit or is certified blind " - "(para. 4(1)); otherwise 5 pounds a week for a single claimant and 10 " + "receives a listed disability benefit (including working tax credit " + "with its disability or severe disability element) or is certified " + "blind (para. 4(1)); otherwise 5 pounds a week for a single claimant and 10 " "pounds for a couple (para. 5). 20 pounds is the most disregarded " "however many conditions are met (para. 4A). Reg. 17(9) disregards the " "sums 'in calculating the claimant's earnings', which are net of income " "tax and National Insurance (reg. 17(10)) and of half of pension " "contributions (reg. 17A(4A)), so the disregard never exceeds the " - "claimant's and partner's net earnings. Income tax on earnings is the " - "tax on them as the top slice of the person's taxable non-savings " - "income, on the person's own rate schedule (Scottish or rest of UK), " - "capped at their income tax: the tax deducted from the earnings where " - "the personal allowance goes against pension income first (DMG " - "86038-86039), so earning more never raises Pension Credit. Not " - "modelled: the employment-specific 20 pound " + "claimant's and partner's net earnings, each source floored at nil. " + "Employed earnings (regs. 17(10) and 17A) are net of primary Class 1 " + "contributions and of the income tax payable on them " + "(pension_credit_employment_income_tax). Self-employed earnings (regs. " + "17(11) and 17B) are net of the notional tax and contributions of the " + "Computation of Earnings Regulations 1996 reg. 14 " + "(pension_credit_self_employment_deductions). Half of pension " + "contributions come off both. Not modelled: the employment-specific " + "20 pound " "disregards of paras. 2 to 2B (part-time firefighters, auxiliary " "coastguards, lifeboat crew, reserve forces) and the transitional " - "protections of para. 4(2) to (4)." + "protections of para. 4(2) to (4), and the continued carer " + "qualification of Sch. I para. 4 after carer's allowance stops (the " + "model has no record of when it stopped). The carer condition reads " + "is_entitled_to_carer_benefit, the carer addition's own test, so the " + "two always agree; the Carer's Allowance earnings limit is applied to " + "that test by PolicyEngine/policyengine-uk#2312." ) entity = BenUnit definition_period = YEAR @@ -34,6 +42,9 @@ class pension_credit_earnings_disregard(Variable): "https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17A", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17B", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/13", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/14", ) def formula(benunit, period, parameters): @@ -51,9 +62,20 @@ def formula(benunit, period, parameters): if len(unit_disability_benefits) > 0 else False ) + # Para. 4(1)(a)(vi): in receipt of working tax credit that includes + # its disability or severe disability element. + wtc_disability_element = ( + add( + benunit, + period, + ["WTC_disabled_element", "WTC_severely_disabled_element"], + ) + > 0 + ) & (benunit("working_tax_credit", period) > 0) disabled = ( benunit.any(claimant_or_partner & on_disability_benefit) | unit_on_disability_benefit + | wtc_disability_element ) # Sch. I para. 4: a claimant or partner entitled to carer's allowance # (the carer addition's own test), not caring hours alone. @@ -68,30 +90,32 @@ def formula(benunit, period, parameters): p.standard[relation_type], ) - # Net earnings of the claimant and partner (regs. 17(10), 17A(4A)). - gross = max_(0, add(person, period, pc.guarantee_credit.earnings_sources)) - national_insurance = add( - person, period, ["ni_class_1_employee", "ni_class_2", "ni_class_4"] - ) - # Tax on the earnings as the top slice of taxable non-savings income. - rates = parameters(period).gov.hmrc.income_tax.rates - taxable = person("earned_taxable_income", period) - below_earnings = max_(0, taxable - min_(gross, taxable)) - scottish = person("pays_scottish_income_tax", period) - top_slice_tax = where( - scottish, - rates.scotland.rates.calc(taxable) - - rates.scotland.rates.calc(below_earnings), - rates.uk.calc(taxable) - rates.uk.calc(below_earnings), - ) - income_tax_on_earnings = min_(person("income_tax", period), top_slice_tax) - pension_contributions = ( + # Net earnings of the claimant and partner, each source floored at + # nil: a loss in one employment is not set against another (CER 1996 + # reg. 13(12), applied by SPC Regs 2002 reg. 17B). + # Employed earners (regs. 17(10) and 17A): less the income tax payable + # on the earnings and the primary Class 1 contributions deducted from + # them. Self-employed earners (regs. 17(11) and 17B): less the + # notional tax and contributions of CER 1996 regs. 13(4) and 14. Both: + # less half of pension contributions (reg. 17A(4A); CER 1996 reg. + # 13(4)(c)). + employment = max_(0, person("employment_income", period)) + self_employment = max_(0, person("self_employment_income", period)) + deductions = add( + person, + period, + ["ni_class_1_employee", "pension_credit_self_employment_deductions"], + ) + ( person("pension_contributions", period) * pc.income.pension_contributions_deduction ) - net = max_( + # Each person's deductions come off their own earnings; the income tax + # on employed earnings is measured for the claimant and partner + # together (pension_credit_employment_income_tax). + own_net = max_(0, employment + self_employment - deductions) + net_earnings = max_( 0, - gross - national_insurance - income_tax_on_earnings - pension_contributions, + benunit.sum(own_net * claimant_or_partner) + - benunit("pension_credit_employment_income_tax", period), ) - net_earnings = benunit.sum(net * claimant_or_partner) return min_(weekly * WEEKS_IN_YEAR, net_earnings) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py new file mode 100644 index 0000000000..565315d514 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py @@ -0,0 +1,133 @@ +from policyengine_uk.model_api import * + +DECISION = ( + "PolicyEngine does not model PAYE codes, so which part of a person's " + "income tax is payable on their earnings is a methodology choice, put to " + "Max on PolicyEngine/policyengine-uk#2019; this provisional choice treats " + "earnings as the top slice of the person's income. " +) + + +def income_tax_with_inputs_zeroed(simulation, period, variables): + """Each person's income tax with every person's value of each of + ``variables`` set to nil, on a branch that is dropped afterwards.""" + name = "pension_credit_without_" + "_and_".join(variables) + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) + try: + # A branch starts with the values already calculated, so drop them + # before replacing the inputs, as marginal_tax_rate does. + system_variables = simulation.tax_benefit_system.variables + for variable in system_variables: + if ( + variable not in simulation.input_variables + and not system_variables[variable].is_input_variable() + ): + branch.delete_arrays(variable) + people = branch.populations["person"] + for variable in variables: + branch.set_input(variable, period, np.zeros(people.count)) + return people("income_tax", period) + finally: + del simulation.branches[name] + + +class pension_credit_income_tax_without_employment_income(Variable): + value_type = float + entity = Person + label = "Income tax without employment income, for Pension Credit" + documentation = ( + "The person's income tax if nobody in the simulation had employment " + "income. " + DECISION + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + income_tax = person("income_tax", period) + if not (person("employment_income", period) != 0).any(): + return income_tax + return income_tax_with_inputs_zeroed( + person.simulation, period, ["employment_income"] + ) + + +class pension_credit_income_tax_without_earnings(Variable): + value_type = float + entity = Person + label = "Income tax without earnings, for Pension Credit" + documentation = ( + "The person's income tax if nobody in the simulation had employment " + "or self-employment income. " + DECISION + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + without_employment = person( + "pension_credit_income_tax_without_employment_income", period + ) + if not (person("self_employment_income", period) != 0).any(): + return without_employment + return income_tax_with_inputs_zeroed( + person.simulation, + period, + ["employment_income", "self_employment_income"], + ) + + +class pension_credit_employment_income_tax(Variable): + value_type = float + entity = BenUnit + label = "Income tax on employed earnings, for Pension Credit" + documentation = ( + "The income tax payable on the claimant's and partner's employed " + "earnings, which SPC Regs 2002 reg. 17(10) disregards in their net " + "amount: the fall in the claimant's and partner's income tax without " + "anyone's employment income, never negative. It includes any tax the " + "earnings add on savings and dividends (a lost starting-rate band or " + "personal savings allowance) and any effect of one partner's earnings " + "on the other's tax (through the marriage allowance), so Pension " + "Credit income does not change with either partner's earnings while " + "the disregard covers them. " + DECISION + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17A", + ) + + def formula(benunit, period, parameters): + person = benunit.members + added = person("income_tax", period) - person( + "pension_credit_income_tax_without_employment_income", period + ) + claimant_or_partner = person("is_claimant_or_partner", period) + return max_(0, benunit.sum(added * claimant_or_partner)) + + +class pension_credit_self_employment_income_tax(Variable): + value_type = float + entity = BenUnit + label = "Income tax actually payable on self-employed profit, for Pension Credit" + documentation = ( + "The income tax the claimant's and partner's self-employment income " + "adds to their income tax, beneath any employment income: the fall in " + "their income tax without anyone's employment income when nobody has " + "self-employment income either, never negative. Pension Credit income " + "replaces it with the notional tax of the Computation of Earnings " + "Regulations 1996 reg. 14 (SPC Regs 2002 reg. 17(11)). " + DECISION + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17" + + def formula(benunit, period, parameters): + person = benunit.members + added = person( + "pension_credit_income_tax_without_employment_income", period + ) - person("pension_credit_income_tax_without_earnings", period) + claimant_or_partner = person("is_claimant_or_partner", period) + return max_(0, benunit.sum(added * claimant_or_partner)) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py index e40999ab55..86e9504e9c 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py @@ -10,6 +10,7 @@ class pension_credit_income(Variable): reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/section/5", "https://www.legislation.gov.uk/ukpga/2002/16/section/15", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17", ) documentation = ( "Income and deductions of the claimant and partner, together with " @@ -46,10 +47,24 @@ def formula(benunit, period, parameters): pension_contributions = benunit.sum( person("pension_contributions", period) * is_claimant_or_partner ) + # Self-employed earnings are net of the notional tax and contributions + # of the Computation of Earnings Regulations 1996 reg. 14, in place of + # those actually payable on the profit (reg. 17(11)). tax = benunit.sum( - add(person, period, ["income_tax", "national_insurance"]) + ( + add( + person, + period, + [ + "income_tax", + "national_insurance", + "pension_credit_self_employment_deductions", + ], + ) + - add(person, period, ["ni_class_2", "ni_class_4"]) + ) * is_claimant_or_partner - ) + ) - benunit("pension_credit_self_employment_income_tax", period) pen_con_deduction_rate = pc.income.pension_contributions_deduction deductions = tax + pension_contributions * pen_con_deduction_rate # Schedule VI earnings disregards (reg. 17(9)). diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py new file mode 100644 index 0000000000..252d784f73 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py @@ -0,0 +1,67 @@ +from policyengine_uk.model_api import * + + +class pension_credit_self_employment_deductions(Variable): + value_type = float + entity = Person + label = "Notional tax and contributions on self-employed profit, for Pension Credit" + documentation = ( + "The income tax and social security contributions deducted from " + "self-employed earnings for Pension Credit (SPC Regs 2002 regs. 17(11) " + "and 17B; Computation of Earnings Regulations 1996 regs. 13(4) and " + "14), in place of those actually payable on the profit: a notional " + "income tax at the basic rate (the Scottish basic rate for a Scottish " + "taxpayer) on the profit less only the personal reliefs of ITA 2007 " + "Part 3 Chapters 2, 3 and 3A, whatever the person's other income; " + "Class 4 contributions at the main rate on the profit between the " + "lower and upper profits limits, where Class 4 is payable at all; and " + "the Class 2 contributions payable, until reg. 14(2)(a) was omitted on " + "6 April 2024." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17B", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/13", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/14", + ) + + def formula(person, period, parameters): + profit = max_(0, person("self_employment_income", period)) + rates = parameters(period).gov.hmrc.income_tax.rates + # The Scottish basic rate is the band after the starter rate; read the + # band itself, since the scale at an instant leaves out the starter + # band before 2018-19. + scottish_bands = parameters.gov.hmrc.income_tax.rates.scotland.rates + scottish_basic_rate = scottish_bands.brackets[1].rate(period.start) + basic_rate = where( + person("pays_scottish_income_tax", period), + scottish_basic_rate, + rates.uk.rates[0], + ) + # Chapter 2 (personal and blind person's allowances) and Chapter 3A + # (marriage allowance, which the model deducts from income) relieve + # income; Chapter 3 (married couple's allowance) reduces the tax. + reliefs = add( + person, + period, + ["personal_allowance", "blind_persons_allowance", "marriage_allowance"], + ) + notional_tax = max_( + 0, + basic_rate * max_(0, profit - reliefs) + - person("married_couples_allowance_deduction", period), + ) + class_4 = parameters(period).gov.hmrc.national_insurance.class_4 + lower = class_4.thresholds.lower_profits_limit + upper = class_4.thresholds.upper_profits_limit + notional_class_4 = where( + person("ni_class_4_liable", period), + class_4.rates.main * min_(max_(0, profit - lower), upper - lower), + 0, + ) + return where( + profit > 0, + notional_tax + notional_class_4 + person("ni_class_2", period), + 0, + ) From 9eb9aafff1923f59446a4ee7a8029d44b4e002ab Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:06:50 -0400 Subject: [PATCH 05/10] Preserve late explicit inputs in Pension Credit tax counterfactuals --- .../tests/test_pension_credit_late_inputs.py | 81 +++++++++++++++++++ .../pension_credit_earnings_income_tax.py | 14 +--- 2 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 policyengine_uk/tests/test_pension_credit_late_inputs.py diff --git a/policyengine_uk/tests/test_pension_credit_late_inputs.py b/policyengine_uk/tests/test_pension_credit_late_inputs.py new file mode 100644 index 0000000000..390c351ee3 --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_late_inputs.py @@ -0,0 +1,81 @@ +"""Explicit tax reliefs survive Pension Credit's earnings counterfactuals.""" + +from copy import deepcopy + +import numpy as np +import pytest + +from policyengine_uk import Simulation + + +@pytest.mark.parametrize("late", [False, True]) +def test_explicit_allowances_keep_couple_award_flat(late): + # Two AA recipients qualify for the £20/week combined disregard. + # The explicit £20,000 allowance leaves £148 pension tax after marriage + # allowance; £1,000 wages adds £200 tax and £800 net earnings, all ignored. + # PC income therefore stays £22,000 - £148 = £21,852, and PC £5,986.20. + situation = { + "people": { + "a": { + "age": {"2026": 70}, + "private_pension_income": {"2026": 22_000}, + "attendance_allowance": {"2026": 3_000}, + }, + "b": {"age": {"2026": 70}, "attendance_allowance": {"2026": 3_000}}, + }, + "benunits": {"b": {"members": ["a", "b"], "would_claim_pc": {"2026": True}}}, + "households": {"h": {"members": ["a", "b"]}}, + } + awards = [] + for wages, expected_tax, expected_disregard in [(0, 0, 0), (1_000, 200, 800)]: + data = deepcopy(situation) + data["people"]["a"]["employment_income"] = {"2026": wages} + if not late: + for name, allowance in [("a", 20_000), ("b", 12_570)]: + data["people"][name]["personal_allowance"] = {"2026": allowance} + sim = Simulation(situation=data) + if late: + sim.set_input("personal_allowance", 2026, [20_000, 12_570]) + assert sim.calculate("is_pension_credit_eligible", 2026)[0] + assert sim.calculate("pension_credit_employment_income_tax", 2026)[ + 0 + ] == pytest.approx(expected_tax) + assert sim.calculate("pension_credit_earnings_disregard", 2026)[ + 0 + ] == pytest.approx(expected_disregard) + assert sim.calculate("pension_credit_income", 2026)[0] == pytest.approx(21_852) + awards.append(sim.calculate("pension_credit", 2026)[0]) + np.testing.assert_array_equal( + sim.calculate("personal_allowance", 2026), [20_000, 12_570] + ) + assert sim.branches == {} + assert awards == pytest.approx([5_986.20, 5_986.20], abs=0.01) + + +def test_late_single_person_allowance_survives_counterfactual(): + # £18,000 pension is below the explicit £20,000 allowance. With £10,000 + # wages, £8,000 is taxed at 20%: the entire £1,600 is caused by employment. + sim = Simulation( + situation={ + "people": { + "a": { + "age": {"2026": 75}, + "private_pension_income": {"2026": 18_000}, + "employment_income": {"2026": 10_000}, + } + }, + "benunits": {"b": {"members": ["a"]}}, + "households": {"h": {"members": ["a"], "country": {"2026": "ENGLAND"}}}, + } + ) + sim.set_input("personal_allowance", 2026, [20_000]) + assert sim.calculate("income_tax", 2026)[0] == pytest.approx(1_600) + assert ( + sim.calculate("pension_credit_income_tax_without_employment_income", 2026)[0] + == 0 + ) + assert sim.calculate("pension_credit_employment_income_tax", 2026)[ + 0 + ] == pytest.approx(1_600) + np.testing.assert_array_equal(sim.calculate("personal_allowance", 2026), [20_000]) + assert sim.branches == {} diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py index 565315d514..cb30beb1d6 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py @@ -3,7 +3,7 @@ DECISION = ( "PolicyEngine does not model PAYE codes, so which part of a person's " "income tax is payable on their earnings is a methodology choice, put to " - "Max on PolicyEngine/policyengine-uk#2019; this provisional choice treats " + "Max on PolicyEngine/policyengine-uk#2019 and settled as option (a): treat " "earnings as the top slice of the person's income. " ) @@ -16,15 +16,9 @@ def income_tax_with_inputs_zeroed(simulation, period, variables): name += "_" branch = simulation.get_branch(name) try: - # A branch starts with the values already calculated, so drop them - # before replacing the inputs, as marginal_tax_rate does. - system_variables = simulation.tax_benefit_system.variables - for variable in system_variables: - if ( - variable not in simulation.input_variables - and not system_variables[variable].is_input_variable() - ): - branch.delete_arrays(variable) + # Preserve every explicit stored input, including formula overrides + # supplied through set_input after the simulation was constructed. + branch.drop_computed_arrays() people = branch.populations["person"] for variable in variables: branch.set_input(variable, period, np.zeros(people.count)) From 7736d5f70d409694cbb385c050b9c7fb064f86b0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:08:26 -0400 Subject: [PATCH 06/10] Fix Pension Credit notional Class 2 profit base --- .../test_pension_credit_notional_class2.py | 110 ++++++++++++++++++ ...nsion_credit_self_employment_deductions.py | 18 ++- 2 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 policyengine_uk/tests/test_pension_credit_notional_class2.py diff --git a/policyengine_uk/tests/test_pension_credit_notional_class2.py b/policyengine_uk/tests/test_pension_credit_notional_class2.py new file mode 100644 index 0000000000..28a9a9bb8e --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_notional_class2.py @@ -0,0 +1,110 @@ +"""PC notional Class 2 uses CER chargeable income, before actual NI reliefs. + +CER 1996 reg. 14(2)(a), (3), at 6 April 2023: +https://www.legislation.gov.uk/uksi/1996/2745/regulation/14/2023-04-06 +SSCBA 1992 s. 11(2), (4), at 6 April 2023: +https://www.legislation.gov.uk/ukpga/1992/4/section/11/2023-04-06 +""" + +import pytest + +from policyengine_uk import Simulation + + +@pytest.mark.parametrize( + "profit, expected_deductions", + [(12_570, 0), (13_000, 304.10)], +) +def test_notional_class2_ignores_actual_trading_allowance(profit, expected_deductions): + simulation = Simulation( + situation={ + "people": { + "adult": { + "age": {2023: 60}, + "self_employment_income": {2023: profit}, + "self_employment_gross_receipts": {2023: profit}, + } + } + } + ) + # Known receipts trigger the £1,000 trading allowance for actual NI. + assert simulation.calculate("ni_class_2", 2023)[0] == 0 + # CER chargeable income is profit before that allowance. At £13,000: + # tax = (£13,000 - £12,570) * 20% = £86; + # Class 4 = (£13,000 - £12,570) * 9% = £38.70; + # Class 2 = 52 * £3.45 = £179.40. Total = £304.10. + # At exactly £12,570 CER 14(2)(a) expressly excludes Class 2, and the + # personal allowance/lower profits limit make tax/Class 4 zero too. + assert simulation.calculate("pension_credit_self_employment_deductions", 2023)[ + 0 + ] == pytest.approx(expected_deductions, abs=0.01) + + +def test_other_trade_loss_keeps_protected_couple_notional_class2(): + results = [] + for loss in (0, 20_000): + simulation = Simulation( + situation={ + "people": { + "claimant": { + "age": {2023: 70}, + "attendance_allowance": {2023: 3_000}, + }, + "partner": { + "age": {2023: 60}, + "dla_sc_category": {2023: "MIDDLE"}, + "self_employment_income": {2023: 20_000}, + "trading_loss": {2023: loss}, + }, + }, + "benunits": { + "couple": { + "members": ["claimant", "partner"], + "has_mixed_age_couple_pension_credit_saving": {2023: True}, + "would_claim_pc": {2023: True}, + } + }, + "households": {"home": {"members": ["claimant", "partner"]}}, + } + ) + assert simulation.calculate("is_pension_credit_eligible", 2023)[0] + actual_class2 = simulation.calculate("ni_class_2", 2023)[1] + assert actual_class2 == pytest.approx(0 if loss else 179.40, abs=0.01) + # CER 13(12) prohibits another employment's loss reducing the + # £20,000 positive profit. Notional deductions are therefore + # (£20,000 - £12,570 PA - £1,260 marriage allowance) * 20% = + # £1,234 tax; plus £668.70 Class 4 and £179.40 Class 2 = £2,082.10. + deductions = simulation.calculate( + "pension_credit_self_employment_deductions", 2023 + )[1] + assert deductions == pytest.approx(2_082.10, abs=0.01) + results.append( + ( + deductions, + simulation.calculate("pension_credit_income", 2023)[0], + simulation.calculate("pension_credit", 2023)[0], + ) + ) + assert results[1] == pytest.approx(results[0], abs=0.01) + + +@pytest.mark.parametrize("year, age", [(2023, 70), (2024, 60)]) +def test_notional_class2_respects_pension_age_and_abolition(year, age): + simulation = Simulation( + situation={ + "people": { + "adult": { + "age": {year: age}, + "self_employment_income": {year: 13_000}, + "self_employment_gross_receipts": {year: 13_000}, + } + } + } + ) + # SSCBA s.11(7)(b) excludes pension-age earners, while SI 2024/377 + # reg.8(7) omits CER 14(2)(a) from 6 April 2024. Neither case has a + # notional Class 2 deduction. Class 4 is £25.80 at 2024's 6% main rate. + expected = 86 if year == 2023 else 86 + 25.80 + assert simulation.calculate("pension_credit_self_employment_deductions", year)[ + 0 + ] == pytest.approx(expected, abs=0.01) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py index 252d784f73..d8be62b50a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.class_2 import class_2_contribution_weeks, class_2_liable class pension_credit_self_employment_deductions(Variable): @@ -12,7 +13,7 @@ class pension_credit_self_employment_deductions(Variable): "14), in place of those actually payable on the profit: a notional " "income tax at the basic rate (the Scottish basic rate for a Scottish " "taxpayer) on the profit less only the personal reliefs of ITA 2007 " - "Part 3 Chapters 2, 3 and 3A, whatever the person's other income; " + "Part 3 Chapters 2, 3 and 3A appropriate to their circumstances; " "Class 4 contributions at the main rate on the profit between the " "lower and upper profits limits, where Class 4 is payable at all; and " "the Class 2 contributions payable, until reg. 14(2)(a) was omitted on " @@ -24,6 +25,8 @@ class pension_credit_self_employment_deductions(Variable): "https://www.legislation.gov.uk/uksi/2002/1792/regulation/17B", "https://www.legislation.gov.uk/uksi/1996/2745/regulation/13", "https://www.legislation.gov.uk/uksi/1996/2745/regulation/14", + "https://www.legislation.gov.uk/uksi/1996/2745/regulation/14/2023-04-06", + "https://www.legislation.gov.uk/ukpga/1992/4/section/11/2023-04-06", ) def formula(person, period, parameters): @@ -60,8 +63,19 @@ def formula(person, period, parameters): class_4.rates.main * min_(max_(0, profit - lower), upper - lower), 0, ) + class_2 = parameters(period).gov.hmrc.national_insurance.class_2 + # CER 14(2)(a), (3), before 6 April 2024: test this employment's + # chargeable income, not actual NI profits after trading allowance + # or other employment losses. SSCBA s.11 supplies the rate and + # age-liability rules. The compulsory rate is nil from 2024-25. + notional_class_2 = ( + person("ni_liable", period) + * class_2_liable(profit, class_2) + * class_2.flat_rate + * class_2_contribution_weeks(period.start.year) + ) return where( profit > 0, - notional_tax + notional_class_4 + person("ni_class_2", period), + notional_tax + notional_class_4 + notional_class_2, 0, ) From 9e23ce7dcb46e052aec439d77a4833c79b5209b2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:10:29 -0400 Subject: [PATCH 07/10] Reuse Pension Credit earnings tax in UC housing branch --- changelog.d/pc-earnings-tax-reuse.changed.md | 1 + .../test_pension_credit_tax_branch_reuse.py | 95 +++++++++++++++++++ .../uc_non_dep_receives_pension_credit.py | 4 + 3 files changed, 100 insertions(+) create mode 100644 changelog.d/pc-earnings-tax-reuse.changed.md create mode 100644 policyengine_uk/tests/test_pension_credit_tax_branch_reuse.py diff --git a/changelog.d/pc-earnings-tax-reuse.changed.md b/changelog.d/pc-earnings-tax-reuse.changed.md new file mode 100644 index 0000000000..601aa2a6d9 --- /dev/null +++ b/changelog.d/pc-earnings-tax-reuse.changed.md @@ -0,0 +1 @@ +- Reuse Pension Credit earnings tax calculations when checking Universal Credit housing contribution exemptions. diff --git a/policyengine_uk/tests/test_pension_credit_tax_branch_reuse.py b/policyengine_uk/tests/test_pension_credit_tax_branch_reuse.py new file mode 100644 index 0000000000..811daf2a9d --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_tax_branch_reuse.py @@ -0,0 +1,95 @@ +"""Share earnings-tax counterfactuals across PC and UC housing calculations.""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.tests.test_uc_non_dep_pension_credit import YEAR, mixed_families + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +def earnings_families(): + situation = mixed_families() + for name, wages, profit in ( + ("tenant", 18_000, 1_000), + ("pc_older", 1_000, 2_000), + ("pc_younger", 0, 100), + ("working_non_dep", 10_000, 0), + ("renter_older", 1_000, 1_000), + ("renter_younger", 0, 0), + ): + situation["people"][name]["employment_income"] = {YEAR: wages} + situation["people"][name]["self_employment_income"] = {YEAR: profit} + return situation + + +def count_counterfactuals(monkeypatch, sim): + variable = sim.tax_benefit_system.variables[ + "pension_credit_income_tax_without_earnings" + ] + namespace = variable.get_formula(YEAR).__globals__ + original = namespace["income_tax_with_inputs_zeroed"] + calls = [] + + def counted(simulation, period, variables): + calls.append((simulation.branch_name, tuple(variables))) + return original(simulation, period, variables) + + monkeypatch.setitem(namespace, "income_tax_with_inputs_zeroed", counted) + return calls + + +@pytest.mark.parametrize("first", ["pension_credit", "household_net_income"]) +def test_full_calculation_reuses_two_earnings_tax_counterfactuals(monkeypatch, first): + sim = Simulation(situation=earnings_families()) + calls = count_counterfactuals(monkeypatch, sim) + sim.calculate(first, YEAR) + sim.calculate("household_net_income", YEAR) + sim.calculate("pension_credit", YEAR) + # Both employment and self-employment are present. The UC housing + # non-dependant check shares the two counterfactuals with ordinary PC. + assert calls == [ + ("default", ("employment_income",)), + ("default", ("employment_income", "self_employment_income")), + ] + assert sim.branches == {} + + +def test_earnings_tax_reuse_preserves_calculation_order_and_changed_inputs(): + pc_first = Simulation(situation=earnings_families()) + income_first = Simulation(situation=earnings_families()) + pc_first.calculate("pension_credit", YEAR) + income_first.calculate("household_net_income", YEAR) + outputs = ( + "household_net_income", + "pension_credit", + "pension_credit_income", + "pension_credit_employment_income_tax", + "pension_credit_self_employment_income_tax", + "uc_non_dep_receives_pension_credit", + "universal_credit", + ) + for name in outputs: + np.testing.assert_allclose( + pc_first.calculate(name, YEAR), + income_first.calculate(name, YEAR), + atol=0.01, + rtol=0, + ) + wages = [18_000, 50_000, 0, 10_000, 1_000, 0] + pc_first.set_input("employment_income", YEAR, wages) + pc_first.drop_computed_arrays() + changed_situation = earnings_families() + changed_situation["people"]["pc_older"]["employment_income"] = {YEAR: 50_000} + fresh = Simulation(situation=changed_situation) + for name in outputs: + np.testing.assert_allclose( + pc_first.calculate(name, YEAR), + fresh.calculate(name, YEAR), + atol=0.01, + rtol=0, + ) + assert not pc_first.calculate("uc_non_dep_receives_pension_credit", YEAR)[1] + assert pc_first.branches == {} diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py index 483b5c7cd1..84dd97e376 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_receives_pension_credit.py @@ -30,6 +30,10 @@ def formula(benunit, period, parameters): # their actual Pension Credit calculation while breaking the cycle # through the renter's UC entitlement and pension-age route. simulation = benunit.simulation + # Income tax is independent of the UC housing element. Calculate + # both earnings counterfactuals on the parent so this branch and + # ordinary Pension Credit share them whichever is requested first. + benunit.members("pension_credit_income_tax_without_earnings", period) name = "uc_non_dep_pension_credit" while name in simulation.branches or name == simulation.branch_name: name += "_" From a10ab0cfe94a089fc080d3519c2a98b37fb21113 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:12:49 -0400 Subject: [PATCH 08/10] Pin protected mixed-age couple Pension Credit award --- .../tests/test_pension_credit_notional_class2.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/policyengine_uk/tests/test_pension_credit_notional_class2.py b/policyengine_uk/tests/test_pension_credit_notional_class2.py index 28a9a9bb8e..9e177477da 100644 --- a/policyengine_uk/tests/test_pension_credit_notional_class2.py +++ b/policyengine_uk/tests/test_pension_credit_notional_class2.py @@ -85,6 +85,12 @@ def test_other_trade_loss_keeps_protected_couple_notional_class2(): simulation.calculate("pension_credit", 2023)[0], ) ) + # SI 2023/316 art.30(2)(a), (d): the couple guarantee plus the higher + # severe-disability addition is (£306.85 + £152.80) * 52 = £23,901.80. + # https://www.legislation.gov.uk/uksi/2023/316/article/30/made + # PC income = £20,000 - £2,082.10 deductions - £1,040 disregard = + # £16,877.90; the award is £23,901.80 - £16,877.90 = £7,023.90. + assert results[0] == pytest.approx((2_082.10, 16_877.90, 7_023.90), abs=0.01) assert results[1] == pytest.approx(results[0], abs=0.01) From efcc491d2c98bf0b18576f523fd941a95164caec Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:14:49 -0400 Subject: [PATCH 09/10] fix: update Pension Credit gross earnings fixtures and held tax inputs --- .../child_amount_tax_credit_award.yaml | 23 ++++----- .../pension_credit_earnings_disregard.yaml | 49 ++++++++++-------- ...dits_pension_credit_passport_properties.py | 31 +++++++----- .../tests/test_trading_loss_properties.py | 50 ++++++++++++++++++- 4 files changed, 105 insertions(+), 48 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml index 2b8e58ff0e..9f9456d020 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/child_amount_tax_credit_award.yaml @@ -39,12 +39,11 @@ # credit (SPCA 2002 s.3(1)(a)). The 2018-19 and 2019-20 couples have no # income, so no savings credit either. The Housing Benefit cases' couple is # aged 80, so savings credit applies to it. The grandchild is 10 (11 in -# 2025-26), born before 6 April 2017. Pension Credit earnings are entered net -# of the SPC Regs Sch VI para 5 disregard (£10 a week for a couple), which -# the model does not apply. £10,000 of savings gives no Pension Credit tariff -# income, and £300 of interest uses up the £300 step-one disregard of tax -# credit income (SI 2002/2006 reg 3). No one pays income tax or National -# Insurance. +# 2025-26), born before 6 April 2017. Employment income is gross: the model +# applies the SPC Regs Sch VI para 5 disregard (£10 a week for a couple) +# once. £10,000 of savings gives no Pension Credit tariff income, and £300 +# of interest uses up the £300 step-one disregard of tax credit income +# (SI 2002/2006 reg 3). No one pays income tax or National Insurance. - name: A Pension Credit couple awarded CTC and WTC gets no child amount # Tax credit income: State Pension 8,000 + interest (300 - 300) + earnings @@ -83,7 +82,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 # 2,600 - 10 x 52 + # Gross wages are 2,600; Sch VI deducts 10 x 52, leaving 2,080. CTC_family_element: 545 households: household: @@ -125,7 +124,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # Gross wages are 2,600; Sch VI deducts 520, leaving 2,080 once. households: household: members: [grandparent_1, grandparent_2, grandchild] @@ -166,7 +165,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # As in the first case, gross wages 2,600 become 2,080 after Sch VI. households: household: members: [grandparent_1, grandparent_2, grandchild] @@ -213,7 +212,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_980 # 3,500 - 10 x 52 + # Gross wages 3,500 become 2,980 after Sch VI, even with no PC paid. households: household: members: [grandparent_1, grandparent_2, grandchild] @@ -254,7 +253,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # Gross wages 2,600 less one Sch VI disregard of 520 give 2,080. has_tax_credit_award: true households: household: @@ -407,7 +406,7 @@ benunit: members: [grandparent_1, grandparent_2, grandchild] would_claim_uc: false - pension_credit_earnings: 2_080 + # Tax-credit closure does not alter gross wages 2,600 less Sch VI 520. standard_minimum_guarantee: 18_023.20 # 346.60 x 52 would_claim_uc_at_legacy_closure: false households: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index e5d98d6d72..f3f11f8954 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -424,15 +424,17 @@ household: members: [claimant] output: - # Income tax is 1,294 with the earnings and 1,086 without (next case): the - # earnings take 470 of non-savings income over the personal allowance, - # and take the personal allowance off 570 of savings interest. Tax on the - # earnings is 208, so net earnings are 832 and the 20 pound disregard of - # 1,040 is capped there. Pension Credit counts deemed income from capital - # rather than the interest, so its income is 12,000 + 1,040 - 1,294 - 832. - income_tax: 1_294 - pension_credit_employment_income_tax: 208 - pension_credit_earnings_disregard: 832 + # Non-savings tax: (12,000 + 1,040 - 12,570) x 20% = 94. + # Those taxable non-savings earnings reduce the savings starting-rate + # band to 5,000 - 470 = 4,530. Savings tax is + # (12,000 - 4,530 - 1,000 PSA) x 20% = 1,294, giving total tax 1,388. + # Without wages tax is 1,086 (next case), so employment tax is 302 and + # net earnings/disregard are 1,040 - 302 = 738, below the 1,040 cap. + # PC counts deemed capital income, not interest: 12,000 + 1,040 - 1,388 + # - 738 = 10,914. HMRC: /apply-tax-free-interest-on-savings/how-much-is-tax-free. + income_tax: 1_388 + pension_credit_employment_income_tax: 302 + pension_credit_earnings_disregard: 738 pension_credit_income: 10_914 - name: The same claimant with savings and no earnings has the same Pension Credit income. @@ -456,7 +458,7 @@ # 12,000 - 1,086. pension_credit_income: 10_914 -- name: Tax on earnings is net of the pension tax relief the earnings unlock. +- name: Pension contribution relief already available without earnings does not reduce tax attributed to earnings. period: 2024 input: people: @@ -472,16 +474,17 @@ household: members: [claimant] output: - # The earnings add 400 x 20% = 80 of tax but let the 300 of contributions - # be relieved, worth 60: income tax is 506, against 486 without them - # (next case), so tax on the earnings is 20. Net earnings are 400 - 20 - - # 300 / 2 = 230, under the 5 pound disregard of 260. + # Relief is available on these 300 of contributions even without wages + # (FA 2004 s.190's 3,600 basic amount, age under 75). With wages tax is + # (15,400 - 12,570 - 300) x 20% = 506; without, it is 426 (next case). + # Employment tax is 80, so net earnings/disregard are + # 400 - 80 - 300 / 2 = 170, under the 5 pound cap of 260. income_tax: 506 - pension_credit_employment_income_tax: 20 - pension_credit_earnings_disregard: 230 - # 15,400 - 506 - 150 - 230: more than without the earnings, so earning - # more never raises Pension Credit. - pension_credit_income: 14_514 + pension_credit_employment_income_tax: 80 + pension_credit_earnings_disregard: 170 + # 15,400 - 506 - 150 - 170 = 14,574; the net-earnings floor means PC + # income is 150 more than without wages (next case). + pension_credit_income: 14_574 - name: The same claimant with no earnings. period: 2024 @@ -498,10 +501,12 @@ household: members: [claimant] output: - income_tax: 486 + # No wages are needed for relief on the 300 contribution below the + # FA 2004 s.190 basic amount: (15,000 - 12,570 - 300) x 20% = 426. + income_tax: 426 pension_credit_earnings_disregard: 0 - # 15,000 - 486 - 300 / 2. - pension_credit_income: 14_364 + # 15,000 - 426 - 300 / 2 = 14,424. + pension_credit_income: 14_424 - name: A self-employment loss is not set against wages in the disregard (CER 1996 reg 13(12)). period: 2025 diff --git a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py index 95cbe06ea7..5125b98dd2 100644 --- a/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py +++ b/policyengine_uk/tests/test_tax_credits_pension_credit_passport_properties.py @@ -146,9 +146,9 @@ def calculate(sim, year, order=OUTPUTS): FAMILIES = st.lists(families(), min_size=1, max_size=6) # The YAML cases, run on every invocation: random draws seldom raise CTC or -# hit the third row. Pension Credit earnings are net of SPC Regs Sch VI -# para 5; the CTC family element is entered at its statutory value (see the -# YAML file). +# hit the third row. Employment income is gross; the model applies SPC Regs +# Sch VI para 5 once. The CTC family element is entered at its statutory +# value (see the YAML file). _PENSIONER = { "age": 70, "employment_income": 2_600, @@ -157,21 +157,27 @@ def calculate(sim, year, order=OUTPUTS): "working_tax_credit_reported": 1, } YAML_FAMILIES = [ - # Guarantee Credit with WTC: passported. + # Gross wages 2,600 - single disregard 5 x 52 = 2,340. With passported + # WTC 2,435, PC is 218.15 x 52 - 6,000 - 2,340 - 2,435 = 568.80. ( [{**_PENSIONER, "state_pension": 6_000}], [], - {"pension_credit_earnings": 2_340}, + {}, {"savings": 10_000}, ), - # Pension Credit only on the income-tested award: the third row. + # The same gross wages leave 2,340 after Sch VI. Passported WTC gives + # PC 11,343.80 - 6,600 - 2,340 - 2,435 < 0, so the passport does not hold; + # income-tested WTC is 2,435 - (9,200 - 7,955) x 41% = 1,924.55, and + # PC remains 11,343.80 - 6,600 - 2,340 - 1,924.55 = 479.25. ( [{**_PENSIONER, "state_pension": 6_600}], [], - {"pension_credit_earnings": 2_340}, + {}, {"savings": 10_000}, ), - # Savings Credit alone, with CTC raised by the passport. + # Gross wages 3,000 - couple disregard 10 x 52 = 2,480; pensions 17,000 + # give PC income 19,480. The 989.976 maximum SC less + # (19,480 - 17,313.40) x 40% gives 123.336, so CTC is passported. ( [ { @@ -185,7 +191,6 @@ def calculate(sim, year, order=OUTPUTS): ], [{"age": 10}], { - "pension_credit_earnings": 2_480, "CTC_family_element": 545, }, {"savings": 10_000}, @@ -202,7 +207,8 @@ def calculate(sim, year, order=OUTPUTS): {"has_mixed_age_couple_pension_credit_saving": False}, {"savings": 10_000}, ), - # The same couple with the saving: Guarantee Credit, passported. + # With the saving, gross wages 2,600 - couple disregard 520 = 2,080. + # PC is 17,313.40 - 6,000 - 2,080 - passported WTC 4,935 = 4,298.40. ( [ {**_PENSIONER, "state_pension": 6_000, "months_since_last_birthday": 9}, @@ -211,7 +217,6 @@ def calculate(sim, year, order=OUTPUTS): [], { "has_mixed_age_couple_pension_credit_saving": True, - "pension_credit_earnings": 2_080, }, {"savings": 10_000}, ), @@ -333,7 +338,9 @@ def test_passport_works_in_traced_and_nested_branch_simulations(): } ], [], - {"pension_credit_earnings": 2_340}, + # Gross wages 2,600 less Sch VI 260 leave 2,340; PC is + # 218.15 x 52 - 6,000 - 2,340 - passported WTC 2,435 = 568.80. + {}, {}, ) for trace in [False, True]: diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 85b09326d0..70d7fee070 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -29,7 +29,9 @@ award equal the same family with those profits cut by the loss. The model's means tests use annual tax deductions, so otherwise the loss reaches them through those deductions. UC's own deductions for tax and - NI on earnings are held alongside Income Tax and NI. + NI on earnings and Pension Credit's tax attribution to employed and + self-employed earnings are held alongside Income Tax and NI, including + the actual Class 2/4 components replaced by notional PC deductions. 5. Tax credits (SI 2002/2006 reg 3(1) Step 4): the claimants' losses (not a child's) come off their applicable income, never below zero, and the award never falls. @@ -610,6 +612,24 @@ def values(sim, variable): @PROPERTY_SETTINGS @example(PINNED_FAMILIES) +@example( + [ + ( + [ + adult(age=18, self_employment_income=17_809.094330166412), + {"age": 0, "trading_loss": 3_692.5341857798658}, + ], + home(1.0, "NORTH_EAST"), + ), + # Actual Class 4 falls to zero with this loss, while CER deductions + # still use the separate positive employment's 30,000 profit. Holding + # total NI also requires holding its actual Class 4 component. + ( + [adult(self_employment_income=30_000, trading_loss=20_000)], + home(0.0), + ), + ] +) @given(st.lists(families(), min_size=4, max_size=10)) def test_means_tests_see_the_loss_only_through_tax(units): with_loss = Simulation(situation=family_situation(units)) @@ -622,10 +642,30 @@ def values(sim, variable): deductions = [ "income_tax", "national_insurance", + # PC replaces these actual components with CER notional deductions; + # hold them consistently with the total national_insurance input. + "ni_class_2", + "ni_class_4", "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", ] tax = {v: values(with_loss, v) for v in deductions} + # SPC Regs reg 17(11) substitutes CER notional deductions for the tax + # actually attributable to profits. Holding income_tax alone also holds + # it in the no-profit counterfactual, incorrectly setting that attribution + # to zero. Preserve both PC tax allocations from the with-loss side. + # In the child-loss example the adult has no loss: actual/notional tax + # is (17,809.09433 - 12,570) x 20% = 1,047.818866, and notional Class 4 + # is 5,239.09433 x 6% = 314.345660. Holding both tax allocations gives + # PC income 17,809.09433 - 1,047.818866 - 314.345660 - 1,040 = 15,406.93 + # on both sides; the child's loss never enters the claimant's earnings. + pc_tax = { + v: values(with_loss, v) + for v in [ + "pension_credit_employment_income_tax", + "pension_credit_self_employment_income_tax", + ] + } loss = values(with_loss, "trading_loss") profits = values(with_loss, "self_employment_income") mif = np.asarray(with_loss.calculate("uc_mif_applies", YEAR)) @@ -644,7 +684,13 @@ def held(k, **extra): situation=family_situation( units, {name: held(k) for k, name in enumerate(names)}, - {b: {"universal_credit": float(uc[k])} for k, b in enumerate(benunits)}, + { + b: { + "universal_credit": float(uc[k]), + **{v: float(pc_tax[v][k]) for v in pc_tax}, + } + for k, b in enumerate(benunits) + }, ) ) for variable in MEANS_TESTED: From d157482c7ce2f41bba844b17396e76a146e3c706 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 06:30:01 -0400 Subject: [PATCH 10/10] test: remove pre-netted Pension Credit child amount inputs --- ...edit_child_amount_tax_credit_properties.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py b/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py index 87929cd78a..1ce961f531 100644 --- a/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py +++ b/policyengine_uk/tests/test_pension_credit_child_amount_tax_credit_properties.py @@ -158,8 +158,9 @@ def schedule_iia_amount(drawn, year): FAMILIES = st.lists(families(), min_size=1, max_size=6) # The YAML households (child_amount_tax_credit_award.yaml), run on every -# invocation. Pension Credit earnings are net of SPC Regs Sch VI para 5, and -# the CTC family element is entered at its statutory £545. The last two pay +# invocation. Wages are gross: the first three families' £2,600 less the +# SPC Regs Sch VI para 5 couple disregard of £10 x 52 gives £2,080 once. +# The CTC family element is entered at its statutory £545. The last two pay # rent, so the order and differential checks reach Housing Benefit; they # enter the Housing Benefit claim, which the model otherwise infers from # reported benefits across the whole simulation. @@ -170,10 +171,12 @@ def schedule_iia_amount(drawn, year): "state_pension": 4_000, "savings_interest_income": 300, } -_INPUTS = {"would_claim_uc": False, "pension_credit_earnings": 2_080} +_INPUTS = {"would_claim_uc": False} _RENTED = {"savings": 18_000, "rent": 12_000, "tenure_type": "RENT_FROM_COUNCIL"} YAML_FAMILIES = [ - # CTC and WTC with Pension Credit: passported, no child amount. + # CTC and WTC with PC: pensions 8,000 + net earnings 2,080 + passported + # WTC 4,935 = income 15,015; GC is 332.95 x 52 - 15,015 = 2,298.40. + # A tax-credit award excludes the 76.79 x 52 = 3,993.08 child amount. ( [ { @@ -187,14 +190,16 @@ def schedule_iia_amount(drawn, year): {**_INPUTS, "CTC_family_element": 545}, {"savings": 10_000}, ), - # No tax credit claim: the child amount. + # No tax credit claim: pensions 8,000 + net earnings 2,080 = 10,080. + # With the child amount, GC is 17,313.40 + 3,993.08 - 10,080 = 11,226.48. ( [_GRANDPARENT, {"age": 70, "state_pension": 4_000}], [{"age": 10}], _INPUTS, {"savings": 10_000}, ), - # WTC alone. + # WTC alone also excludes the child amount; the first family's income + # 15,015 and GC 2,298.40 are unchanged because CTC is not PC income. ( [ {**_GRANDPARENT, "working_tax_credit_reported": 1}, @@ -204,7 +209,11 @@ def schedule_iia_amount(drawn, year): _INPUTS, {"savings": 10_000}, ), - # A WTC award at a nil rate. + # Gross wages 3,500 - Sch VI 520 = 2,980, not a pre-netted earnings + # input. With pensions 17,000, income 19,980 exceeds guarantee 17,313.40. + # WTC is max(0, 4,935 - 41% x (20,500 - 7,955)) = 0, still an award. + # Without that award the child amount gives GC 21,306.48 - 19,980 = + # 1,326.48, satisfying the existing positive-award boundary assertion. ( [ { @@ -216,7 +225,7 @@ def schedule_iia_amount(drawn, year): {"age": 70, "state_pension": 8_000}, ], [{"age": 10}], - {**_INPUTS, "pension_credit_earnings": 2_980}, + _INPUTS, {"savings": 10_000}, ), # A CTC award ends the Guarantee Credit and its Housing Benefit passport.