From ee82518415509f34d8cccd4c6b8b09d669cfdd9a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 09:52:06 -0400 Subject: [PATCH 01/11] Use each rule's own UC earned income in the benefit cap, targeted childcare and local CTR Fixes #1986. - Benefit cap earnings exception (UC Regs 2013 reg 82): earned income is each person's earnings less their own tax, NI and pension contributions, summed over the unit, without the minimum income floor (reg 82(4)). The threshold is 16 hours a week at the national living wage, x 52 / 12, in whole pounds (reg 6(1A)(za)), x 12; GBP 430 a month before 1 April 2017. - Targeted childcare (SI 2014/2147 reg 1(3)(a)) and the Kingston, Merton, Newham and Westminster CTR schemes (Default Scheme para 37) read earned income before the work allowance, the new uc_earned_income_before_work_allowance, which uc_earned_income now uses. - uc_individual_earned_income_before_mif is copied unchanged from #1973 so the two PRs add identical files. - Adds the missing 2023-24 minimum wage rates (SI 2023/354). Co-Authored-By: Claude Opus 5.5 --- ...ed-income-consumers-1986-nmw-2023.fixed.md | 1 + .../uc-earned-income-consumers-1986.fixed.md | 1 + .../dfe/targeted-childcare-entitlement.ipynb | 2 +- .../programs/gov/dwp/universal-credit.ipynb | 2 +- .../universal_credit/benefit_cap/README.md | 1 + .../benefit_cap/earnings_exception/README.md | 1 + .../earnings_exception/monthly_amount.yaml | 13 + .../earnings_exception/weekly_hours.yaml | 13 + .../gov/hmrc/minimum_wage/apprentice.yaml | 5 + .../gov/hmrc/minimum_wage/non_apprentice.yaml | 25 ++ .../baseline/finance/income/minimum_wage.yaml | 43 ++ ...ia_for_targeted_childcare_entitlement.yaml | 186 +++++++++ .../benefit_cap_earnings_exception.yaml | 306 ++++++++++++++ .../council_tax_reduction.yaml | 152 ++++++- ...t_uc_earned_income_consumers_properties.py | 373 ++++++++++++++++++ ...eria_for_targeted_childcare_entitlement.py | 26 +- .../gov/dwp/benefit_cap_earned_income.py | 26 ++ .../gov/dwp/benefit_cap_earnings_threshold.py | 49 +++ .../gov/dwp/is_benefit_cap_exempt_earnings.py | 68 +--- .../income/uc_earned_income.py | 2 +- .../uc_earned_income_before_work_allowance.py | 29 ++ .../uc_individual_earned_income_before_mif.py | 51 +++ .../council_tax_reduction/_legacy.py | 12 +- 23 files changed, 1323 insertions(+), 64 deletions(-) create mode 100644 changelog.d/uc-earned-income-consumers-1986-nmw-2023.fixed.md create mode 100644 changelog.d/uc-earned-income-consumers-1986.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/monthly_amount.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/weekly_hours.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml create mode 100644 policyengine_uk/tests/test_uc_earned_income_consumers_properties.py create mode 100644 policyengine_uk/variables/gov/dwp/benefit_cap_earned_income.py create mode 100644 policyengine_uk/variables/gov/dwp/benefit_cap_earnings_threshold.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income_before_work_allowance.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py diff --git a/changelog.d/uc-earned-income-consumers-1986-nmw-2023.fixed.md b/changelog.d/uc-earned-income-consumers-1986-nmw-2023.fixed.md new file mode 100644 index 0000000000..0c177d4ab1 --- /dev/null +++ b/changelog.d/uc-earned-income-consumers-1986-nmw-2023.fixed.md @@ -0,0 +1 @@ +Add the 2023-24 National Minimum Wage and National Living Wage rates (SI 2023/354), which were missing, so 2023 used the 2022-23 rates. diff --git a/changelog.d/uc-earned-income-consumers-1986.fixed.md b/changelog.d/uc-earned-income-consumers-1986.fixed.md new file mode 100644 index 0000000000..11300d84c6 --- /dev/null +++ b/changelog.d/uc-earned-income-consumers-1986.fixed.md @@ -0,0 +1 @@ +Use Universal Credit earned income as each rule defines it (#1986): the benefit cap earnings exception now sums each person's earnings net of their own tax, National Insurance and pension contributions, without the minimum income floor, against 16 hours a week at the national living wage in whole pounds a month (UC Regs 2013 regs 82 and 6(1A)(za); £881 a month from April 2026) instead of a hard-coded £10,152 a year; the targeted childcare Universal Credit route and the Kingston upon Thames, Merton, Newham and Westminster council tax reduction schemes now count earned income before the work allowance. diff --git a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb index 3b41a8f8a5..0c1c2cb9d4 100644 --- a/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb +++ b/docs/book/programs/gov/dfe/targeted-childcare-entitlement.ipynb @@ -28,7 +28,7 @@ "\n", "#### Eligibility conditions\n", "Families may also qualify based on income thresholds if they receive:\n", - "* **Universal Credit** with household earned income of **£15,400 or less** per year\n", + "* **Universal Credit** with earned income of **£15,400 or less** per year (combined for a couple), as Universal Credit calculates it: earnings after the person's own tax, National Insurance and pension contributions, and before the work allowance (`uc_earned_income_before_work_allowance`)\n", "* **Tax Credits** (Working Tax Credit or Child Tax Credit) with household income of **£16,190 or less** per year\n", "\n", "#### Geographic scope\n", diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 074f529c60..c8c8254179 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -44,7 +44,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. The maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the benunit's combined earnings above its work allowance (`uc_work_allowance`), and by all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. The maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the benunit's combined earnings above its work allowance (`uc_work_allowance`), and by all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption. The earnings exemption (UC Regulations 2013 reg 82) compares the claimant's or couple's earned income (`benefit_cap_earned_income`: each person's earnings net of their own tax, National Insurance and pension contributions, without the minimum income floor) with 16 hours a week at the national living wage, in whole pounds a month (`benefit_cap_earnings_threshold`: £846 in 2025-26, £881 in 2026-27). Other exemptions include qualifying disability and carer benefits.\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." }, { "cell_type": "code", diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md new file mode 100644 index 0000000000..e48bb342f4 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md @@ -0,0 +1 @@ +# Benefit cap diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md new file mode 100644 index 0000000000..1cd731ff55 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md @@ -0,0 +1 @@ +# Earnings exception diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/monthly_amount.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/monthly_amount.yaml new file mode 100644 index 0000000000..5e4cfed49a --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/monthly_amount.yaml @@ -0,0 +1,13 @@ +description: Fixed monthly earned income at or above which a Universal Credit award is excepted from the benefit cap. The regulations set a sum of money until 1 April 2017 and a number of hours at the national living wage rate since, so this is zero from that date. +values: + 2013-04-29: 430 + 2017-04-01: 0 +metadata: + unit: currency-GBP + period: month + label: Universal Credit benefit cap earnings exception fixed monthly amount + reference: + - title: The Universal Credit Regulations 2013 reg. 82(1)(a), as made + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82/made + - title: The Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017 reg. 2(3)(a) + href: https://www.legislation.gov.uk/uksi/2017/138/regulation/2/made diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/weekly_hours.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/weekly_hours.yaml new file mode 100644 index 0000000000..03dd32e203 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/weekly_hours.yaml @@ -0,0 +1,13 @@ +description: Hours a week at the national living wage rate whose pay, converted to a monthly amount, is the earned income at or above which a Universal Credit award is excepted from the benefit cap. Zero before 1 April 2017, when the regulations set a fixed sum instead. +values: + 2013-04-29: 0 + 2017-04-01: 16 +metadata: + unit: hour + period: week + label: Universal Credit benefit cap earnings exception weekly hours at the national living wage + reference: + - title: The Universal Credit Regulations 2013 reg. 82(1)(a) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82 + - title: The Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017 reg. 2(3)(a) + href: https://www.legislation.gov.uk/uksi/2017/138/regulation/2/made diff --git a/policyengine_uk/parameters/gov/hmrc/minimum_wage/apprentice.yaml b/policyengine_uk/parameters/gov/hmrc/minimum_wage/apprentice.yaml index 3a4aab3154..6109c7cf78 100644 --- a/policyengine_uk/parameters/gov/hmrc/minimum_wage/apprentice.yaml +++ b/policyengine_uk/parameters/gov/hmrc/minimum_wage/apprentice.yaml @@ -11,6 +11,11 @@ values: 2020-04-01: 4.15 2021-04-01: 4.3 2022-04-01: 4.81 + 2023-04-01: + value: 5.28 + reference: + - title: The National Minimum Wage (Amendment) Regulations 2023 + href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 6.4 reference: diff --git a/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml b/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml index e1ab6760d3..f08b5ee9cf 100644 --- a/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml +++ b/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml @@ -15,6 +15,11 @@ brackets: 2020-04-01: 4.55 2021-04-01: 4.62 2022-04-01: 4.81 + 2023-04-01: + value: 5.28 + reference: + - title: The National Minimum Wage (Amendment) Regulations 2023 + href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 6.4 reference: @@ -44,6 +49,11 @@ brackets: 2020-04-01: 6.45 2021-04-01: 6.56 2022-04-01: 6.83 + 2023-04-01: + value: 7.49 + reference: + - title: The National Minimum Wage (Amendment) Regulations 2023 + href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 8.6 reference: @@ -74,6 +84,11 @@ brackets: 2020-04-01: 8.2 2021-04-01: 8.36 2022-04-01: 9.18 + 2023-04-01: + value: 10.18 + reference: + - title: The National Minimum Wage (Amendment) Regulations 2023 + href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 11.44 reference: @@ -103,6 +118,11 @@ brackets: 2020-04-01: 8.2 2021-04-01: 8.91 2022-04-01: 9.5 + 2023-04-01: + value: 10.42 + reference: + - title: The National Minimum Wage (Amendment) Regulations 2023 + href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 11.44 reference: @@ -132,6 +152,11 @@ brackets: 2020-04-01: 8.72 2021-04-01: 8.91 2022-04-01: 9.5 + 2023-04-01: + value: 10.42 + reference: + - title: The National Minimum Wage (Amendment) Regulations 2023 + href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 11.44 reference: diff --git a/policyengine_uk/tests/policy/baseline/finance/income/minimum_wage.yaml b/policyengine_uk/tests/policy/baseline/finance/income/minimum_wage.yaml index a7ef1035e3..2b6622d101 100644 --- a/policyengine_uk/tests/policy/baseline/finance/income/minimum_wage.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/income/minimum_wage.yaml @@ -62,3 +62,46 @@ is_apprentice: true output: minimum_wage: 8 + +- name: 2023-24 national living wage for a person aged 23 or over + # NMW (Amendment) Regulations 2023 (SI 2023/354): reg. 4 rate 10.42. + period: 2023 + absolute_error_margin: 0.001 + input: + age: 30 + output: + minimum_wage: 10.42 + +- name: 2023-24 rate for a person aged 21 or 22 + period: 2023 + absolute_error_margin: 0.001 + input: + age: 21 + output: + minimum_wage: 10.18 + +- name: 2023-24 rate for a person aged 18 to 20 + period: 2023 + absolute_error_margin: 0.001 + input: + age: 19 + output: + minimum_wage: 7.49 + +- name: 2023-24 rate for a person aged under 18 + period: 2023 + absolute_error_margin: 0.001 + input: + age: 17 + output: + minimum_wage: 5.28 + +- name: 2023-24 apprenticeship rate + period: 2023 + absolute_error_margin: 0.001 + input: + age: 19 + is_apprentice: true + output: + minimum_wage: 5.28 + diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml new file mode 100644 index 0000000000..22cda2cac8 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml @@ -0,0 +1,186 @@ +# Targeted (two-year-old) childcare, Universal Credit route. Local Authority +# (Duty to Secure Early Years Provision Free of Charge) Regulations 2014 +# reg. 1(2), definition of "eligible child" (a)(vii), and reg. 1(3)-(4): +# https://www.legislation.gov.uk/uksi/2014/2147/regulation/1 +# +# The parent must be entitled to universal credit with earned income not +# exceeding the applicable amount. Reg. 1(3)(a): "earned income" means income +# for the purposes of Chapter 2 of Part 6 of the UC Regulations 2013, which +# is net of the person's own tax, NI and pension contributions and comes +# before the work allowance (reg. 22, Part 3). Reg. 1(3)(c) reads the limit +# against a couple's combined income. The limit here is 15,400 a year. +# In 2026-27 the personal allowance and the primary threshold are 12,570. + +- name: "#1986 counterexample: earnings of 17,000 exceed the limit" + # Tax (17,000 - 12,570) x 20% = 886; NI (17,000 - 12,570) x 8% = 354.40; + # earned income 15,759.60 > 15,400. After the 5,124 work allowance it + # would be 10,635.60, which is how the model wrongly passed it before. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 17_000 + child: + age: 2 + benunits: + benunit: + members: [parent, child] + would_claim_uc: true + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + uc_earned_income_before_work_allowance: 15_759.60 + uc_earned_income: 10_635.60 + meets_universal_credit_criteria_for_targeted_childcare_entitlement: false + +- name: Earned income just under the limit meets the criterion + # Tax (16,500 - 12,570) x 20% = 786; NI 3,930 x 8% = 314.40; earned income + # 15,399.60 <= 15,400. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 16_500 + child: + age: 2 + benunits: + benunit: + members: [parent, child] + would_claim_uc: true + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + uc_earned_income_before_work_allowance: 15_399.60 + meets_universal_credit_criteria_for_targeted_childcare_entitlement: true + +- name: Earned income just over the limit does not + # Tax 3,931 x 20% = 786.20; NI 3,931 x 8% = 314.48; earned income + # 15,400.32 > 15,400. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 16_501 + child: + age: 2 + benunits: + benunit: + members: [parent, child] + would_claim_uc: true + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + uc_earned_income_before_work_allowance: 15_400.32 + meets_universal_credit_criteria_for_targeted_childcare_entitlement: false + +- name: A couple's combined earned income is tested + # Reg. 1(3)(c): 8,000 + 8,000 = 16,000 > 15,400, although each partner is + # under the limit alone. + period: 2026 + input: + people: + parent1: + age: 30 + employment_income: 8_000 + parent2: + age: 30 + employment_income: 8_000 + child: + age: 2 + benunits: + benunit: + members: [parent1, parent2, child] + would_claim_uc: true + households: + household: + members: [parent1, parent2, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + uc_earned_income_before_work_allowance: 16_000 + meets_universal_credit_criteria_for_targeted_childcare_entitlement: false + +- name: Without pension contributions, earnings of 16,800 exceed the limit + # Tax 4,230 x 20% = 846; NI 4,230 x 8% = 338.40; earned income 15,615.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 16_800 + child: + age: 2 + benunits: + benunit: + members: [parent, child] + would_claim_uc: true + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + uc_earned_income_before_work_allowance: 15_615.60 + meets_universal_credit_criteria_for_targeted_childcare_entitlement: false + +- name: Pension contributions come off earned income for the limit + # Reg. 55(5)(a): the 1,000 contribution brings earned income to at most + # 16,800 - 1,000 - 338.40 - 646 = 14,815.60 (with tax relief at source), + # under 15,400. + period: 2026 + input: + people: + parent: + age: 30 + employment_income: 16_800 + employee_pension_contributions: 1_000 + child: + age: 2 + benunits: + benunit: + members: [parent, child] + would_claim_uc: true + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + meets_universal_credit_criteria_for_targeted_childcare_entitlement: true + +- name: No Universal Credit entitlement, no Universal Credit route + period: 2026 + input: + people: + parent: + age: 30 + employment_income: 5_000 + child: + age: 2 + benunits: + benunit: + members: [parent, child] + would_claim_uc: false + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + meets_universal_credit_criteria_for_targeted_childcare_entitlement: false diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml new file mode 100644 index 0000000000..6107b10432 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml @@ -0,0 +1,306 @@ +# Universal Credit benefit cap earnings exception, UC Regs 2013 reg. 82. +# https://www.legislation.gov.uk/uksi/2013/376/regulation/82 +# +# (1)(a): the cap does not apply where "the claimant's earned income or, if +# the claimant is a member of a couple, the couple's combined earned income" +# is at least the pay for 16 hours a week at the reg. 4 National Minimum +# Wage Regulations rate (the national living wage), times 52 and divided by +# 12. Reg. 6(1A)(za) disregards a fraction of a pound in that amount. +# (4): earned income excludes income treated as earned under reg. 62 (the +# minimum income floor). +# Earned income is net of the person's own income tax, NI and relievable +# pension contributions on their earnings (regs. 55(5), 57(2)); Class 3 is not +# on earnings. In 2025-26 and 2026-27 the personal allowance and the primary +# threshold are both 12,570 a year. + +# --- Threshold: 16 x national living wage x 52 / 12, whole pounds --- + +- name: Before 1 April 2017 the threshold was 430 a month + # Reg. 82(1)(a) as made; SI 2017/138 substituted the hours formula. + period: 2016 + input: + people: + person: + age: 30 + output: + benefit_cap_earnings_threshold: 5_160 # 430 x 12 + +- name: 2017-18 threshold from the 7.50 national living wage + # 7.50 x 16 x 52 / 12 = 520.00. + period: 2017 + input: + people: + person: + age: 30 + output: + benefit_cap_earnings_threshold: 6_240 # 520 x 12 + +- name: 2020-21 threshold rounds 604.59 down to 604 a month + # 8.72 x 16 x 52 / 12 = 604.59. + period: 2020 + input: + people: + person: + age: 30 + output: + benefit_cap_earnings_threshold: 7_248 # 604 x 12 + +- name: 2022-23 threshold rounds 658.67 down to 658 a month + # 9.50 x 16 x 52 / 12 = 658.67. + period: 2022 + input: + people: + person: + age: 30 + output: + benefit_cap_earnings_threshold: 7_896 # 658 x 12 + +- name: 2023-24 threshold from the 10.42 national living wage + # 10.42 x 16 x 52 / 12 = 722.45 (NMW (Amendment) Regulations 2023). + period: 2023 + input: + people: + person: + age: 30 + output: + benefit_cap_earnings_threshold: 8_664 # 722 x 12 + +- name: 2024-25 threshold rounds 793.17 down to 793 a month + # 11.44 x 16 x 52 / 12 = 793.17. + period: 2024 + input: + people: + person: + age: 30 + output: + benefit_cap_earnings_threshold: 9_516 # 793 x 12 + +- name: 2025-26 threshold is 846 a month + # 12.21 x 16 x 52 / 12 = 846.56; GOV.UK gives 846 to 31 March 2026. + period: 2025 + input: + people: + person: + age: 30 + output: + benefit_cap_earnings_threshold: 10_152 # 846 x 12 + +- name: 2026-27 threshold is 881 a month + # 12.71 x 16 x 52 / 12 = 881.23; GOV.UK gives 881 from 1 April 2026. + period: 2026 + input: + people: + person: + age: 30 + output: + benefit_cap_earnings_threshold: 10_572 # 881 x 12 + +- name: The threshold uses the national living wage whatever the claimant's age + # Reg. 82(1)(a) refers to the reg. 4 rate, not the claimant's own rate. + period: 2026 + input: + people: + person: + age: 19 + output: + benefit_cap_earnings_threshold: 10_572 + +# --- Earned income at the threshold --- + +- name: Earnings of 10,573 meet the 2026-27 threshold after rounding + # 10,573 >= 10,572. Without the whole-pound rounding the threshold would be + # 10,574.72 and the exception would not apply. + period: 2026 + input: + people: + person: + age: 30 + employment_income: 10_573 + output: + benefit_cap_earned_income: 10_573 + is_benefit_cap_exempt_earnings: true + +- name: Earnings of 10,571 fall short of the 2026-27 threshold + period: 2026 + input: + people: + person: + age: 30 + employment_income: 10_571 + output: + benefit_cap_earned_income: 10_571 + is_benefit_cap_exempt_earnings: false + +- name: Earned income is net of the person's income tax and NI on the earnings + # Tax (14,000 - 12,570) x 20% = 286; Class 1 (14,000 - 12,570) x 8% = + # 114.40; 14,000 - 286 - 114.40 = 13,599.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + employment_income: 14_000 + output: + benefit_cap_earned_income: 13_599.60 + is_benefit_cap_exempt_earnings: true + +- name: Pension contributions are deducted from earned income + # Reg. 55(5)(a): 11,500 - 1,000 = 10,500 < 10,572. + period: 2026 + input: + people: + person: + age: 30 + employment_income: 11_500 + employee_pension_contributions: 1_000 + output: + benefit_cap_earned_income: 10_500 + is_benefit_cap_exempt_earnings: false + +- name: Voluntary Class 3 contributions are not deducted + period: 2026 + input: + people: + person: + age: 30 + employment_income: 10_600 + ni_class_3: 900 + output: + benefit_cap_earned_income: 10_600 + is_benefit_cap_exempt_earnings: true + +- name: Tax on the person's property income is not deducted from their earnings + # Earnings are the lowest slice of income and sit inside the personal + # allowance, so the tax on earnings is nil. + period: 2026 + input: + people: + person: + age: 30 + employment_income: 10_600 + property_income: 40_000 + output: + benefit_cap_earned_income: 10_600 + is_benefit_cap_exempt_earnings: true + +- name: A partner's tax on property income does not reduce the couple's earned income + period: 2026 + input: + people: + earner: + age: 30 + employment_income: 10_600 + partner: + age: 30 + property_income: 40_000 + benunits: + benunit: + members: [earner, partner] + output: + benefit_cap_earned_income: 10_600 + is_benefit_cap_exempt_earnings: true + +- name: A couple's earned income is combined + # Reg. 82(1)(a): 6,000 + 6,000 = 12,000 >= 10,572. + period: 2026 + input: + people: + person1: + age: 30 + employment_income: 6_000 + person2: + age: 30 + employment_income: 6_000 + benunits: + benunit: + members: [person1, person2] + output: + benefit_cap_earned_income: 12_000 + is_benefit_cap_exempt_earnings: true + +- name: Income treated as earned under the minimum income floor does not count + # Reg. 82(4). Profits of 3,000 are below the Class 4 lower profits limit, so + # earned income is 3,000; the floor (35 x 12.71 x 52 = 23,132.20 gross) + # counts for the award but not for the exception. + period: 2026 + input: + people: + person: + age: 30 + self_employment_income: 3_000 + uc_is_in_startup_period: false + output: + uc_mif_applies: true + benefit_cap_earned_income: 3_000 + is_benefit_cap_exempt_earnings: false + +- name: Earnings of 5,200 in 2016-17 meet the 430 a month threshold + period: 2016 + input: + people: + person: + age: 30 + employment_income: 5_200 + output: + is_benefit_cap_exempt_earnings: true + +- name: Earnings of 8,700 in 2023-24 meet the 722 a month threshold + period: 2023 + input: + people: + person: + age: 30 + employment_income: 8_700 + output: + is_benefit_cap_exempt_earnings: true + +- name: Earnings of 8,600 in 2023-24 fall short of the 722 a month threshold + period: 2023 + input: + people: + person: + age: 30 + employment_income: 8_600 + output: + is_benefit_cap_exempt_earnings: false + +# --- #1986 counterexample --- + +- name: A partner's voluntary Class 3 does not remove the couple's exception + # London couple, four children, private rent 36,000, 2025-26. One partner + # earns 10,300; the other has no earnings and pays 923 of Class 3. Earned + # income is 10,300 >= 846 x 12 = 10,152, so the cap does not apply. Before + # #1986 the model deducted the partner's Class 3 and capped the award. + period: 2025 + input: + people: + earner: + age: 35 + employment_income: 10_300 + partner: + age: 35 + ni_class_3: 923 + child1: + age: 3 + child2: + age: 5 + child3: + age: 8 + child4: + age: 10 + benunits: + benunit: + members: [earner, partner, child1, child2, child3, child4] + would_claim_uc: true + households: + household: + members: [earner, partner, child1, child2, child3, child4] + region: LONDON + tenure_type: RENT_PRIVATELY + rent: 36_000 + output: + benefit_cap_earned_income: 10_300 + benefit_cap_earnings_threshold: 10_152 + is_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml index daaec21e26..6bffa6850d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction.yaml @@ -215,7 +215,7 @@ claims_all_entitled_benefits: true uc_maximum_amount: 5_000 uc_income_reduction: 2_500 - uc_earned_income: 6_000 + uc_earned_income_before_work_allowance: 6_000 uc_unearned_income: 0 households: household: @@ -228,6 +228,43 @@ universal_credit: 2_500 council_tax_reduction: 1_100 +- name: "Merton UC claimant's income counts earnings before the work allowance" + # #1986. Single parent, child aged 6, earning 12,000 (under the personal + # allowance and primary threshold, so earned income is 12,000), council rent, + # council tax 1,800. The award has a housing costs element, so the work + # allowance is 427 x 12 = 5,124 and UC = maximum - 55% x (12,000 - 5,124) = + # maximum - 3,781.80. Income (scheme para. 37) is earned income plus the + # award: 12,000 + maximum - 3,781.80, which exceeds the applicable amount + # (the maximum, para. 28) by 8,218.20. CTR = 1,800 - 20% x 8,218.20 = + # 156.36. Deducting the work allowance from income would have given 1,181.16. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 12_000 + child: + age: 6 + benunits: + benunit: + members: [parent, child] + would_claim_uc: true + claims_all_entitled_benefits: true + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + local_authority: MERTON + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + council_tax: 1_800 + savings: 0 + output: + uc_earned_income_before_work_allowance: 12_000 + council_tax_reduction: 156.36 + - name: Merton UC claimant uses UC-reported capital period: 2026 absolute_error_margin: 0.01 @@ -371,7 +408,7 @@ claims_all_entitled_benefits: true uc_maximum_amount: 5_000 uc_income_reduction: 2_500 - uc_earned_income: 6_000 + uc_earned_income_before_work_allowance: 6_000 uc_unearned_income: 0 households: household: @@ -384,6 +421,43 @@ universal_credit: 2_500 council_tax_reduction: 1_100 +- name: "Kingston upon Thames UC claimant's income counts earnings before the work allowance" + # #1986. Single parent, child aged 6, earning 12,000 (under the personal + # allowance and primary threshold, so earned income is 12,000), council rent, + # council tax 1,800. The award has a housing costs element, so the work + # allowance is 427 x 12 = 5,124 and UC = maximum - 55% x (12,000 - 5,124) = + # maximum - 3,781.80. Income (scheme para. 37) is earned income plus the + # award: 12,000 + maximum - 3,781.80, which exceeds the applicable amount + # (the maximum, para. 28) by 8,218.20. CTR = 1,800 - 20% x 8,218.20 = + # 156.36. Deducting the work allowance from income would have given 1,181.16. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 12_000 + child: + age: 6 + benunits: + benunit: + members: [parent, child] + would_claim_uc: true + claims_all_entitled_benefits: true + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + local_authority: KINGSTON_UPON_THAMES + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + council_tax: 1_800 + savings: 0 + output: + uc_earned_income_before_work_allowance: 12_000 + council_tax_reduction: 156.36 + - name: Kingston upon Thames UC claimant uses UC-reported capital period: 2026 absolute_error_margin: 0.01 @@ -542,6 +616,41 @@ output: council_tax_reduction: 867.92 +- name: Newham UC claimant's income counts earnings before the work allowance + # #1986. Single parent, child aged 6, earning 4,000, council rent, council + # tax 1,800. Earnings are under the 5,124 work allowance, so UC is the + # maximum. Income is 4,000 + maximum, 4,000 over the applicable amount, so + # CTR = 70% x 1,800 - 25% x 4,000 = 260. Deducting the work allowance from + # income would have left no excess and given 1,260. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 4_000 + child: + age: 6 + benunits: + benunit: + members: [parent, child] + would_claim_uc: true + claims_all_entitled_benefits: true + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + local_authority: NEWHAM + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + council_tax: 1_800 + savings: 0 + output: + uc_earned_income_before_work_allowance: 4_000 + uc_earned_income: 0 + council_tax_reduction: 260 + - name: Newham UC claimant uses UC-reported capital against the local limit period: 2026 absolute_error_margin: 0.01 @@ -686,7 +795,7 @@ claims_all_entitled_benefits: true uc_maximum_amount: 5_000 uc_income_reduction: 2_500 - uc_earned_income: 6_000 + uc_earned_income_before_work_allowance: 6_000 uc_unearned_income: 0 households: household: @@ -699,6 +808,43 @@ universal_credit: 2_500 council_tax_reduction: 1_100 +- name: "Westminster UC claimant's income counts earnings before the work allowance" + # #1986. Single parent, child aged 6, earning 12,000 (under the personal + # allowance and primary threshold, so earned income is 12,000), council rent, + # council tax 1,800. The award has a housing costs element, so the work + # allowance is 427 x 12 = 5,124 and UC = maximum - 55% x (12,000 - 5,124) = + # maximum - 3,781.80. Income (scheme para. 37) is earned income plus the + # award: 12,000 + maximum - 3,781.80, which exceeds the applicable amount + # (the maximum, para. 28) by 8,218.20. CTR = 1,800 - 20% x 8,218.20 = + # 156.36. Deducting the work allowance from income would have given 1,181.16. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 12_000 + child: + age: 6 + benunits: + benunit: + members: [parent, child] + would_claim_uc: true + claims_all_entitled_benefits: true + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + local_authority: WESTMINSTER + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + council_tax: 1_800 + savings: 0 + output: + uc_earned_income_before_work_allowance: 12_000 + council_tax_reduction: 156.36 + - name: Westminster UC claimant uses UC-reported capital period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py new file mode 100644 index 0000000000..d6824253bb --- /dev/null +++ b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py @@ -0,0 +1,373 @@ +"""Property-based tests for rules that read Universal Credit earned income. + +Three rules outside the UC award test a UC claimant's earned income: + +- the benefit cap earnings exception (UC Regs 2013 reg. 82(1)(a)): earned + income, without the minimum income floor (reg. 82(4)), against 16 hours a + week at the national living wage, in whole pounds a month (reg. 6(1A)(za)); +- the targeted childcare route for UC families (SI 2014/2147 reg. 1(3)): + earned income as in UC Regs Part 6 Chapter 2, before the work allowance, + against 15,400 a year; +- the Kingston upon Thames, Merton, Newham and Westminster council tax + reduction schemes (Default Scheme para. 37): the Secretary of State's + calculation of income, before the work allowance, plus the award. + +Invariants, for any generated population of single people and couples with +earnings, self-employment, pension contributions, voluntary Class 3 and +taxable unearned income: + +1. The benefit cap exception never depends on income tax or NI that is not + on earnings: adding Class 3 or any unearned income to any adult leaves + benefit_cap_earned_income and is_benefit_cap_exempt_earnings unchanged. +2. Differential against the UC means test: benefit cap earned income equals + the UC earned income before the work allowance when no one's minimum + income floor applies, and never exceeds it. +3. The threshold is 12 x floor(12.71 x 16 x 52 / 12) in 2026-27 and the same + formula on the national living wage parameter in every year, for every + family. +4. Monotone in earnings: the exception is non-decreasing, and the childcare + criterion non-increasing, in any adult's employment income. +5. Differential against the formulas #1986 replaced: the childcare criterion + now implies the old criterion (earned income before the work allowance is + never below earned income after it), and local CTR is never higher. +6. Differential against a reference: for a UC award in the four boroughs, + council_tax_reduction equals the scheme formula recomputed from the + model's own UC maximum amount, award and earned income. + +Invariant 1 holds only while unearned income leaves the person's allowances +alone, as in test_uc_earnings_deductions_properties.py: adjusted net income +stays below the personal allowance taper, no one is old enough for the +married couple's allowance, and no one claims Marriage Allowance. +Self-employment profits are never negative, because a trading loss is netted +against employment income in uc_individual_earned_income but not in the +benefit cap measure. +""" + +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 * +from policyengine_uk.utils.scenario import Scenario + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +YEARS = [2020, 2023, 2025, 2026] +TENURES = ["RENT_FROM_COUNCIL", "RENT_FROM_HA", "RENT_PRIVATELY", "OWNED_OUTRIGHT"] +LOCAL_AUTHORITIES = ["MERTON", "KINGSTON_UPON_THAMES", "NEWHAM", "WESTMINSTER"] +UNEARNED = [ + "private_pension_income", + "property_income", + "savings_interest_income", + "dividend_income", +] +earnings = st.one_of(st.just(0.0), st.floats(0, 30_000)) +self_employment = st.one_of(st.just(0.0), st.floats(0, 20_000)) +pension = st.one_of(st.just(0.0), st.floats(0, 2_000)) +unearned = st.one_of(st.just(0.0), st.floats(0, 7_000)) +bumps = st.floats(1, 9_000) + + +@st.composite +def families(draw): + adults = [] + for _ in range(draw(st.integers(1, 2))): + adult = dict( + age=draw(st.integers(18, 60)), + employment_income=draw(earnings), + self_employment_income=draw(self_employment), + employee_pension_contributions=draw(pension), + ni_class_3=draw(st.one_of(st.just(0.0), st.floats(0, 950))), + uc_is_in_startup_period=draw(st.booleans()), + ) + for variable in UNEARNED: + adult[variable] = draw(unearned) + adults.append(adult) + return dict( + adults=adults, + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 3)))], + tenure=draw(st.sampled_from(TENURES)), + rent=draw(st.floats(0, 15_000)), + local_authority=draw(st.sampled_from(LOCAL_AUTHORITIES)), + council_tax=draw(st.floats(500, 3_000)), + ) + + +def situation(units, year, bump=None, earnings_only=False): + """One simulation holding every family. + + ``bump`` is (family index, adult index, variable, amount) to add. + ``earnings_only`` drops Class 3 and every kind of unearned income. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + name = f"p{i}_{j}" + person = {"would_claim_marriage_allowance": {year: False}} + for variable, value in adult.items(): + if earnings_only and (variable in UNEARNED or variable == "ni_class_3"): + continue + person[variable] = {year: value} + if bump is not None and bump[:2] == (i, j): + variable, amount = bump[2], bump[3] + person[variable] = {year: adult.get(variable, 0.0) + amount} + people[name] = person + names.append(name) + for k, age in enumerate(unit["children"]): + name = f"c{i}_{k}" + people[name] = {"age": {year: age}} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_uc": {year: True}, + "claims_all_entitled_benefits": {year: True}, + } + households[f"h{i}"] = { + "members": names, + "rent": {year: unit["rent"]}, + "tenure_type": {year: unit["tenure"]}, + "country": {year: "ENGLAND"}, + "region": {year: "LONDON"}, + "local_authority": {year: unit["local_authority"]}, + "council_tax": {year: unit["council_tax"]}, + "savings": {year: 0.0}, + } + return {"people": people, "benunits": benunits, "households": households} + + +BENUNIT_VARIABLES = [ + "benefit_cap_earned_income", + "benefit_cap_earnings_threshold", + "is_benefit_cap_exempt_earnings", + "uc_earned_income_before_work_allowance", + "uc_earned_income", + "uc_unearned_income", + "uc_maximum_amount", + "universal_credit", + "meets_universal_credit_criteria_for_targeted_childcare_entitlement", +] +# Each family is one benefit unit in its own household, so household values +# line up with benefit unit values. +HOUSEHOLD_VARIABLES = ["council_tax_reduction", "council_tax"] + + +def calculate(units, year, scenario=None, **kwargs): + sim = Simulation(situation=situation(units, year, **kwargs), scenario=scenario) + values = {} + for v in BENUNIT_VARIABLES + HOUSEHOLD_VARIABLES: + values[v] = np.asarray(sim.calculate(v, year)) + values["floor_applies"] = np.asarray( + sim.map_result( + sim.calculate("uc_mif_applies", year), "person", "benunit", "any" + ) + ) + return values + + +@st.composite +def bumped(draw, variables): + units = draw(st.lists(families(), min_size=1, max_size=8)) + i = draw(st.integers(0, len(units) - 1)) + j = draw(st.integers(0, len(units[i]["adults"]) - 1)) + return units, (i, j, draw(st.sampled_from(variables)), draw(bumps)) + + +@PROPERTY_SETTINGS +@given(case=bumped(UNEARNED + ["ni_class_3"]), year=st.sampled_from(YEARS)) +def test_benefit_cap_exception_ignores_tax_not_on_earnings(case, year): + units, (i, j, variable, amount) = case + if variable == "ni_class_3": + # Class 3 is a flat weekly amount; keep it to a plausible year. + amount = min(amount, 950.0) + bump = (i, j, variable, amount) + low = calculate(units, year) + high = calculate(units, year, bump=bump) + np.testing.assert_allclose( + high["benefit_cap_earned_income"], + low["benefit_cap_earned_income"], + atol=0.01, + err_msg=f"{bump} {units}", + ) + np.testing.assert_array_equal( + high["is_benefit_cap_exempt_earnings"], + low["is_benefit_cap_exempt_earnings"], + err_msg=f"{bump} {units}", + ) + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=8), + year=st.sampled_from(YEARS), +) +def test_benefit_cap_earned_income_matches_uc_without_the_floor(units, year): + v = calculate(units, year) + cap = v["benefit_cap_earned_income"] + uc = v["uc_earned_income_before_work_allowance"] + no_floor = ~v["floor_applies"] + np.testing.assert_allclose(cap[no_floor], uc[no_floor], atol=0.01, err_msg=units) + assert np.all(cap <= uc + 0.01), units + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=8), + year=st.sampled_from(YEARS), +) +def test_benefit_cap_threshold_is_16_hours_at_the_living_wage(units, year): + v = calculate(units, year) + nlw = Simulation(situation={"people": {"p": {"age": {year: 30}}}}).calculate( + "minimum_wage", year + )[0] + expected = 12 * np.floor(nlw * 16 * 52 / 12) + np.testing.assert_allclose(v["benefit_cap_earnings_threshold"], expected) + if year == 2026: + np.testing.assert_allclose(v["benefit_cap_earnings_threshold"], 10_572) + + +@PROPERTY_SETTINGS +@given(case=bumped(["employment_income"]), year=st.sampled_from(YEARS)) +def test_monotone_in_earnings(case, year): + units, bump = case + low = calculate(units, year) + high = calculate(units, year, bump=bump) + # More earnings never take away the benefit cap exception... + assert np.all( + high["is_benefit_cap_exempt_earnings"] >= low["is_benefit_cap_exempt_earnings"] + ), (bump, units) + # ...and never bring a family within the childcare earnings limit. Where + # the minimum income floor applies, the floor stands in for gross + # earnings while the person's actual tax and NI are still deducted, so + # more employment income can lower earned income; #1973 nets the floor + # instead. Those families are left out here. + criterion = "meets_universal_credit_criteria_for_targeted_childcare_entitlement" + no_floor = ~low["floor_applies"] & ~high["floor_applies"] + assert np.all(high[criterion][no_floor] <= low[criterion][no_floor]), ( + bump, + units, + ) + + +def _use_formulas_before_1986(simulation): + class meets_universal_credit_criteria_for_targeted_childcare_entitlement(Variable): + value_type = bool + entity = BenUnit + label = "UC childcare criterion as computed before #1986" + definition_period = YEAR + + def formula(benunit, period, parameters): + p = parameters(period).gov.dfe.targeted_childcare_entitlement + uc = benunit("universal_credit", period) + earned_income = benunit("uc_earned_income", period) + return (uc > 0) & (earned_income <= p.income_limit.universal_credit) + + class uc_earned_income_before_work_allowance(Variable): + # Only the local CTR schemes read this variable apart from + # uc_earned_income and the childcare criterion, so swapping it for the + # after-work-allowance amount reproduces their old income figure. + value_type = float + entity = BenUnit + label = "UC earned income as local CTR read it before #1986" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + earned_income = add(benunit, period, ["uc_individual_earned_income"]) + work_allowance = benunit("uc_work_allowance", period) + return max_(0, earned_income - work_allowance) + + class uc_earned_income(Variable): + value_type = float + entity = BenUnit + label = "UC earned income (after deductions and work allowance)" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + earned_income = add(benunit, period, ["uc_individual_earned_income"]) + work_allowance = benunit("uc_work_allowance", period) + return max_(0, earned_income - work_allowance) + + system = simulation.tax_benefit_system + system.update_variable( + meets_universal_credit_criteria_for_targeted_childcare_entitlement + ) + system.update_variable(uc_earned_income) + system.update_variable(uc_earned_income_before_work_allowance) + + +BEFORE_1986 = Scenario(simulation_modifier=_use_formulas_before_1986) + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=8), + year=st.sampled_from([2025, 2026]), +) +def test_fix_only_tightens_childcare_and_ctr(units, year): + after = calculate(units, year) + before = calculate(units, year, scenario=BEFORE_1986) + # The UC award itself is unchanged. + np.testing.assert_allclose( + after["universal_credit"], before["universal_credit"], atol=0.01 + ) + assert np.all( + after["uc_earned_income_before_work_allowance"] + >= after["uc_earned_income"] - 0.01 + ), units + criterion = "meets_universal_credit_criteria_for_targeted_childcare_entitlement" + assert np.all(after[criterion] <= before[criterion]), units + assert np.all( + after["council_tax_reduction"] <= before["council_tax_reduction"] + 0.01 + ), units + + +# Scheme parameters: maximum support rate and taper (withdrawal rate). +SCHEMES = { + "MERTON": "merton", + "KINGSTON_UPON_THAMES": "kingston_upon_thames", + "NEWHAM": "newham", + "WESTMINSTER": "westminster", +} + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=8), + year=st.sampled_from([2025, 2026]), +) +def test_local_ctr_matches_the_scheme_formula(units, year): + # Single-adult, no non-dependant households: no non-dependant + # deductions, so CTR for a UC award is + # max(0, liability x maximum support rate + # - taper x max(0, earned income before the work allowance + # + unearned income + UC - UC maximum amount)). + v = calculate(units, year) + system = Simulation(situation={"people": {"p": {"age": {year: 30}}}}) + parameters = system.tax_benefit_system.parameters(year).gov.local_authorities + for k, unit in enumerate(units): + if v["universal_credit"][k] <= 0: + continue + ctr = getattr( + parameters, SCHEMES[unit["local_authority"]] + ).council_tax_reduction + income = ( + v["uc_earned_income_before_work_allowance"][k] + + v["uc_unearned_income"][k] + + v["universal_credit"][k] + ) + excess = max(0.0, income - v["uc_maximum_amount"][k]) + expected = max( + 0.0, + v["council_tax"][k] * ctr.maximum_support_rate + - excess * ctr.means_test.withdrawal_rate, + ) + np.testing.assert_allclose( + v["council_tax_reduction"][k], expected, atol=0.01, err_msg=str(unit) + ) diff --git a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.py b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.py index 52a75c07be..b8e4c7f41e 100644 --- a/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.py +++ b/policyengine_uk/variables/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.py @@ -5,7 +5,19 @@ class meets_universal_credit_criteria_for_targeted_childcare_entitlement(Variabl value_type = bool entity = BenUnit label = "meets Universal Credit criteria for targeted childcare entitlement" + documentation = ( + "Whether the parents are entitled to Universal Credit with earned " + "income, as Universal Credit calculates it and before the work " + "allowance, not exceeding the limit." + ) definition_period = YEAR + reference = dict( + title=( + "Local Authority (Duty to Secure Early Years Provision Free of " + "Charge) Regulations 2014 reg. 1(2), (3) and (4)" + ), + href="https://www.legislation.gov.uk/uksi/2014/2147/regulation/1", + ) def formula(benunit, period, parameters): p = parameters(period).gov.dfe.targeted_childcare_entitlement @@ -13,11 +25,13 @@ def formula(benunit, period, parameters): # Check if receiving Universal Credit uc = benunit("universal_credit", period) - # For Universal Credit recipients, we must use the definition of "earned income" - # The Local Authority (Duty to Secure Early Years Provision Free of Charge) (Amendment) Regulations 2018 - part 2.c - # https://www.legislation.gov.uk/uksi/2018/146/made - - # UC-specific earned income as specified by 2(c)(3)(a) of the Regulations - earned_income = benunit("uc_earned_income", period) + # Reg. 1(3)(a): "earned income" means income for the purposes of + # Chapter 2 of Part 6 of the Universal Credit Regulations 2013, and + # reg. 1(3)(c) reads the limit against a couple's combined income. + # Chapter 2 is each person's earnings after their own tax, National + # Insurance and pension contributions, with the minimum income floor + # (reg. 62). The work allowance is in reg. 22 (Part 3, the award), so + # it is not deducted here. + earned_income = benunit("uc_earned_income_before_work_allowance", period) return (uc > 0) & (earned_income <= p.income_limit.universal_credit) diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_earned_income.py b/policyengine_uk/variables/gov/dwp/benefit_cap_earned_income.py new file mode 100644 index 0000000000..31196cc79b --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_earned_income.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class benefit_cap_earned_income(Variable): + value_type = float + entity = BenUnit + label = "Earned income for the benefit cap earnings exception" + documentation = ( + "The earned income the Universal Credit benefit cap earnings " + "exception tests: each person's earnings less their own pension " + "contributions, income tax and National Insurance, summed over the " + "benefit unit. It leaves out any income the minimum income floor " + "treats a self-employed person as having, and the work allowance is " + "not deducted." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Universal Credit Regulations 2013 reg. 82(1)(a) and (4)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/82", + ) + # Reg. 82(1)(a) tests "the claimant's earned income or, if the claimant + # is a member of a couple, the couple's combined earned income". Reg. + # 82(4) leaves out income a person is treated as having under reg. 62 + # (minimum income floor), so this sums each person's actual earned income. + adds = ["uc_individual_earned_income_before_mif"] diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_earnings_threshold.py b/policyengine_uk/variables/gov/dwp/benefit_cap_earnings_threshold.py new file mode 100644 index 0000000000..ba4505b946 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_earnings_threshold.py @@ -0,0 +1,49 @@ +from policyengine_uk.model_api import * + + +class benefit_cap_earnings_threshold(Variable): + value_type = float + entity = BenUnit + label = "Benefit cap earnings exception threshold" + documentation = ( + "The earned income, over a year, at or above which a Universal Credit " + "award is excepted from the benefit cap: twelve times the monthly " + "amount in the regulations. Since 1 April 2017 the monthly amount is " + "16 hours a week at the national living wage rate, times 52 and " + "divided by 12, with any fraction of a pound disregarded. Before " + "then it was 430 a month." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 82(1)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/82", + ), + dict( + title="Universal Credit Regulations 2013 reg. 6(1A)(za)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/6", + ), + dict( + title="National Minimum Wage Regulations 2015 reg. 4", + href="https://www.legislation.gov.uk/uksi/2015/621/regulation/4", + ), + ] + + def formula(benunit, period, parameters): + p = parameters(period).gov + exception = p.dwp.universal_credit.benefit_cap.earnings_exception + # Reg. 82(1)(a) uses "the hourly rate set out in regulation 4 of the + # National Minimum Wage Regulations": the single national living wage + # rate, whatever the claimant's age. It is the top age band of the + # model's schedule. + hourly_rate = p.hmrc.minimum_wage.non_apprentice.amounts[-1] + monthly_pay = ( + hourly_rate * exception.weekly_hours * WEEKS_IN_YEAR / MONTHS_IN_YEAR + ) + # Reg. 6(1A)(za) disregards a fraction of a pound in the amount + # calculated for reg. 82(1)(a): 12.21 x 16 x 52 / 12 = 846.56 is 846. + monthly_amount = exception.monthly_amount + np.floor(monthly_pay) + # Each monthly assessment period is tested against the monthly + # amount; over a year of level earnings that is twelve times it. + return np.ones(benunit.count) * monthly_amount * MONTHS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py index c27e13c39d..55996da874 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py @@ -4,57 +4,23 @@ class is_benefit_cap_exempt_earnings(Variable): value_type = bool entity = BenUnit - label = "Whether exempt from the benefits cap for non-health/disability reasons" + label = "Whether excepted from the benefit cap because of earnings" + documentation = ( + "The Universal Credit earnings exception: the benefit cap does not " + "apply where the claimant's earned income, or a couple's combined " + "earned income, reaches the pay for 16 hours a week at the national " + "living wage. The nine-month grace period after twelve months of " + "such earnings is not modelled." + ) definition_period = YEAR - reference = "https://www.gov.uk/benefit-cap/when-youre-not-affected" + reference = dict( + title="Universal Credit Regulations 2013 reg. 82(1)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/82", + ) def formula(benunit, period, parameters): - # Check if anyone in benefit unit is over state pension age - person = benunit.members - over_pension_age = person("is_SP_age", period) - has_pensioner = benunit.any(over_pension_age) - - # UC-specific exemptions - # Limited capability for work and work-related activity - has_lcwra = benunit.any(person("uc_limited_capability_for_WRA", period)) - - # Carer element in UC indicates caring for someone with disability - gets_uc_carer_element = benunit("uc_carer_element", period) > 0 - - # Earnings exemption for UC (£846/month = £10,152/year) - # Note: Only check earned income, not UC amount itself to avoid circular dependency - uc_earned = benunit.sum( - benunit.members("employment_income", period) - + benunit.members("self_employment_income", period) - - benunit.members("income_tax", period) - - benunit.members("national_insurance", period) - ) - earnings_threshold = 10_152 - meets_earnings_test = uc_earned >= earnings_threshold - - # Disability and carer benefits that exempt from cap - QUAL_PERSONAL_BENEFITS = [ - "attendance_allowance", - "carers_allowance", - "dla", # Disability Living Allowance (includes components) - "pip_dl", # PIP daily living component - "pip_m", # PIP mobility component - "iidb", # Industrial injuries disability benefit - ] - - # ESA and Working Tax Credit - QUAL_BENUNIT_BENEFITS = [ - "esa_income", # Income-based ESA - "working_tax_credit", # If getting WTC, likely working enough - ] - - qualifying_personal_benefits = add(benunit, period, QUAL_PERSONAL_BENEFITS) - qualifying_benunit_benefits = add(benunit, period, QUAL_BENUNIT_BENEFITS) - - # Check for Armed Forces Compensation Scheme payments - afcs = benunit("afcs", period) > 0 - - # ESA contribution-based with support component - esa_support_component = benunit("esa_contrib", period) > 0 - - return meets_earnings_test + # Only earned income is read, not the Universal Credit award, which + # depends on the cap. + earned_income = benunit("benefit_cap_earned_income", period) + threshold = benunit("benefit_cap_earnings_threshold", period) + return earned_income >= threshold diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py index 1880148d36..135f97bfa6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py @@ -16,6 +16,6 @@ def formula(benunit, period, parameters): # Each person's earned income is net of their own deductions # (reg. 55(5), reg. 57(2)); the work allowance then comes off the # combined earned income before the taper (reg. 22(1)(b)). - earned_income = add(benunit, period, ["uc_individual_earned_income"]) + earned_income = benunit("uc_earned_income_before_work_allowance", period) work_allowance = benunit("uc_work_allowance", period) return max_(0, earned_income - work_allowance) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income_before_work_allowance.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income_before_work_allowance.py new file mode 100644 index 0000000000..9960caf46b --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income_before_work_allowance.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * + + +class uc_earned_income_before_work_allowance(Variable): + value_type = float + entity = BenUnit + label = "Universal Credit earned income before the work allowance" + documentation = ( + "The benefit unit's earned income as Universal Credit calculates it " + "(UC Regs 2013 Part 6 Chapter 2): each person's earnings less their " + "own pension contributions, income tax and National Insurance, or " + "the amount the minimum income floor treats them as having. The work " + "allowance, which belongs to the award calculation in reg. 22, is " + "not deducted. Rules that refer to the Universal Credit calculation " + "of earned income use this amount." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 52", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/52", + ), + dict( + title="Universal Credit Regulations 2013 reg. 22(1)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/22", + ), + ] + adds = ["uc_individual_earned_income"] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py new file mode 100644 index 0000000000..c7ed38c133 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -0,0 +1,51 @@ +from policyengine_uk.model_api import * + + +class uc_individual_earned_income_before_mif(Variable): + value_type = float + entity = Person + label = ( + "Universal Credit earned income of the person, before the minimum income floor" + ) + documentation = ( + "The person's actual earned income for Universal Credit: their " + "earnings less their own relievable pension contributions and their " + "own income tax and National Insurance in respect of their employment " + "and self-employment. The minimum income floor compares this with " + "the net threshold." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 55(5)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + ), + dict( + title="Universal Credit Regulations 2013 reg. 57(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), + ] + + def formula(person, period, parameters): + # The deductions are the person's own and come off only their own + # earnings, so one partner's tax, NI or pension contributions never + # reduce the other partner's earned income. + earnings_components = ["employment_income", "miscellaneous_income"] + bi = parameters(period).gov.contrib.ubi_center.basic_income + if bi.interactions.include_in_means_tests: + earnings_components.append("basic_income") + # A trading loss makes self-employed earnings nil (reg. 57(2)); it + # is not set against employed earnings. + self_employed = max_(0, person("self_employment_income", period)) + earnings = add(person, period, earnings_components) + self_employed + deductions = add( + person, + period, + [ + "pension_contributions", + "uc_income_tax_on_earnings", + "uc_national_insurance_on_earnings", + ], + ) + return max_(0, earnings - deductions) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index 54883e9b4b..3577552f73 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -25,8 +25,18 @@ def legacy_council_tax_reduction( universal_credit = benunit("universal_credit", period) has_uc_award = universal_credit > 0 uc_applicable_amount = benunit("uc_maximum_amount", period) + # The schemes use "the calculation or estimate of the amount of the + # income of the applicant ... made by the Secretary of State for the + # purpose of determining the award of universal credit", plus the award + # (Default Scheme 2013 para. 37(1) and (3)(a); Kingston upon Thames and + # Merton reproduce it, Newham refers to the same calculation, and + # Westminster's scheme is based on the Default Scheme). That income is + # earned income after tax, National Insurance and pension contributions + # and unearned income. The work allowance and taper are steps in the + # award (UC Regs 2013 reg. 22), not in the income, so the work allowance + # is not deducted here. uc_applicable_income = ( - benunit("uc_earned_income", period) + benunit("uc_earned_income_before_work_allowance", period) + benunit("uc_unearned_income", period) + universal_credit ) From 495db15a969f758c66ef5985112f29e2be282d5f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:28:32 -0400 Subject: [PATCH 02/11] Strengthen the benefit cap property test; put the cap docs in their own cell - The Class 3 and unearned income property now bumps one adult in every family by a realistic amount and mixes in earners just above the threshold, so it fails against the pre-#1986 formula (it did not before). - Adds a reg 82(4) property: switching every minimum income floor off never changes benefit cap earned income. - Moves the benefit cap earnings exception docs into a separate notebook cell, leaving the means-test cell that #1973 also edits untouched. Co-Authored-By: Claude Opus 5.5 --- .../programs/gov/dwp/universal-credit.ipynb | 7 +- ...t_uc_earned_income_consumers_properties.py | 90 ++++++++++++++++--- 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index c8c8254179..25317913a6 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -44,7 +44,12 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. The maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the benunit's combined earnings above its work allowance (`uc_work_allowance`), and by all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption. The earnings exemption (UC Regulations 2013 reg 82) compares the claimant's or couple's earned income (`benefit_cap_earned_income`: each person's earnings net of their own tax, National Insurance and pension contributions, without the minimum income floor) with 16 hours a week at the national living wage, in whole pounds a month (`benefit_cap_earnings_threshold`: £846 in 2025-26, £881 in 2026-27). Other exemptions include qualifying disability and carer benefits.\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. The maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the benunit's combined earnings above its work allowance (`uc_work_allowance`), and by all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, contribution-based JSA, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": "### Benefit cap earnings exception\n\nThe cap does not apply where the claimant's earned income, or a couple's combined earned income, reaches 16 hours a week at the national living wage, converted to a monthly amount by multiplying by 52 and dividing by 12, with any fraction of a pound disregarded (UC Regulations 2013 regs 82(1)(a) and 6(1A)(za)): £846 a month in 2025-26 and £881 in 2026-27 (`benefit_cap_earnings_threshold`, twelve times the monthly amount). Before 1 April 2017 it was £430 a month. Earned income here (`benefit_cap_earned_income`) is each person's earnings net of their own income tax, National Insurance and pension contributions, and leaves out income treated as earned under the minimum income floor (reg 82(4)). The nine-month grace period is not modelled." }, { "cell_type": "code", diff --git a/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py index d6824253bb..3d35cacb08 100644 --- a/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py +++ b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py @@ -21,7 +21,8 @@ benefit_cap_earned_income and is_benefit_cap_exempt_earnings unchanged. 2. Differential against the UC means test: benefit cap earned income equals the UC earned income before the work allowance when no one's minimum - income floor applies, and never exceeds it. + income floor applies, and never exceeds it; and switching every floor off + never changes it (reg. 82(4)). 3. The threshold is 12 x floor(12.71 x 16 x 52 / 12) in 2026-27 and the same formula on the national living wage parameter in every year, for every family. @@ -98,12 +99,16 @@ def families(draw): ) -def situation(units, year, bump=None, earnings_only=False): +def situation(units, year, bump=None, earnings_only=False, no_floor=False): """One simulation holding every family. - ``bump`` is (family index, adult index, variable, amount) to add. + ``bump`` is (family index, adult index, variable, amount) to add, or a + list of them. ``earnings_only`` drops Class 3 and every kind of unearned income. + ``no_floor`` puts every adult in a start-up period, so no minimum income + floor applies. """ + bumps = [] if bump is None else [bump] if isinstance(bump, tuple) else bump people, benunits, households = {}, {}, {} for i, unit in enumerate(units): names = [] @@ -114,9 +119,12 @@ def situation(units, year, bump=None, earnings_only=False): if earnings_only and (variable in UNEARNED or variable == "ni_class_3"): continue person[variable] = {year: value} - if bump is not None and bump[:2] == (i, j): - variable, amount = bump[2], bump[3] - person[variable] = {year: adult.get(variable, 0.0) + amount} + if no_floor: + person["uc_is_in_startup_period"] = {year: True} + for b in bumps: + if b[:2] == (i, j): + variable, amount = b[2], b[3] + person[variable] = {year: adult.get(variable, 0.0) + amount} people[name] = person names.append(name) for k, age in enumerate(unit["children"]): @@ -178,14 +186,54 @@ def bumped(draw, variables): return units, (i, j, draw(st.sampled_from(variables)), draw(bumps)) +# Annual benefit cap earnings thresholds (12 x the monthly amount). +THRESHOLDS = {2020: 7_248, 2023: 8_664, 2025: 10_152, 2026: 10_572} + + +@st.composite +def near_threshold_families(draw, year): + """A family whose one earner is just either side of the threshold, so + that any deduction wrongly taken from earned income moves the + exception.""" + earner = dict( + age=draw(st.integers(18, 60)), + employment_income=THRESHOLDS[year] + draw(st.floats(-300, 900)), + ) + adults = [earner] + if draw(st.booleans()): + adults.append(dict(age=draw(st.integers(18, 60)))) + return dict( + adults=adults, + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 4)))], + tenure=draw(st.sampled_from(TENURES)), + rent=draw(st.floats(0, 15_000)), + local_authority=draw(st.sampled_from(LOCAL_AUTHORITIES)), + council_tax=draw(st.floats(500, 3_000)), + ) + + @PROPERTY_SETTINGS -@given(case=bumped(UNEARNED + ["ni_class_3"]), year=st.sampled_from(YEARS)) -def test_benefit_cap_exception_ignores_tax_not_on_earnings(case, year): - units, (i, j, variable, amount) = case - if variable == "ni_class_3": - # Class 3 is a flat weekly amount; keep it to a plausible year. - amount = min(amount, 950.0) - bump = (i, j, variable, amount) +@given(year=st.sampled_from(YEARS), data=st.data()) +def test_benefit_cap_exception_ignores_tax_not_on_earnings(year, data): + units = data.draw( + st.lists( + st.one_of(families(), near_threshold_families(year)), + min_size=1, + max_size=8, + ) + ) + # One adult in every family gets more Class 3 (up to a year's worth) or + # unearned income large enough to be taxed, so a wrongly deducted charge + # would move an earner near the threshold across it. + bump = [] + for i, unit in enumerate(units): + j = data.draw(st.integers(0, len(unit["adults"]) - 1)) + variable = data.draw(st.sampled_from(UNEARNED + ["ni_class_3"])) + if variable == "ni_class_3": + amount = data.draw(st.floats(500, 950)) + else: + amount = data.draw(st.floats(2_000, 9_000)) + bump.append((i, j, variable, amount)) low = calculate(units, year) high = calculate(units, year, bump=bump) np.testing.assert_allclose( @@ -215,6 +263,22 @@ def test_benefit_cap_earned_income_matches_uc_without_the_floor(units, year): assert np.all(cap <= uc + 0.01), units +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=8), + year=st.sampled_from(YEARS), +) +def test_minimum_income_floor_never_counts_for_the_benefit_cap(units, year): + with_floor = calculate(units, year) + without_floor = calculate(units, year, no_floor=True) + np.testing.assert_allclose( + with_floor["benefit_cap_earned_income"], + without_floor["benefit_cap_earned_income"], + atol=0.01, + err_msg=str(units), + ) + + @PROPERTY_SETTINGS @given( units=st.lists(families(), min_size=1, max_size=8), From b696f8d01f274bc6bf532f94e38bfe3732f5deee Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:29:49 -0400 Subject: [PATCH 03/11] Deduct only relievable pension contributions from benefit cap earned income Follows #1949's f333e17f: contributions paid after 75 are not relievable (Finance Act 2004 s. 188(3)(a)), so reg 55(5)(a) does not deduct them. uc_individual_earned_income_before_mif now matches uc_individual_earned_income apart from the minimum income floor. #1973 carries the same file without this change; whichever merges second takes this version. Co-Authored-By: Claude Opus 5.5 --- .../benefit_cap_earnings_exception.yaml | 15 +++++++++++ .../uc_individual_earned_income_before_mif.py | 25 +++++++++++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml index 6107b10432..5a72d208f5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml @@ -158,6 +158,21 @@ benefit_cap_earned_income: 10_500 is_benefit_cap_exempt_earnings: false +- name: Pension contributions paid after 75 are not deducted + # Finance Act 2004 s. 188(3)(a): contributions after 75 are not relievable, + # so reg. 55(5)(a) does not deduct them. 11,000 is under the personal + # allowance and no NI is due over State Pension age, so earned income is + # 11,000. + period: 2026 + input: + people: + person: + age: 76 + employment_income: 11_000 + employee_pension_contributions: 1_000 + output: + benefit_cap_earned_income: 11_000 + - name: Voluntary Class 3 contributions are not deducted period: 2026 input: diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py index c7ed38c133..73a93702a1 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -25,6 +25,10 @@ class uc_individual_earned_income_before_mif(Variable): title="Universal Credit Regulations 2013 reg. 57(2)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", ), + dict( + title="Finance Act 2004 s. 188 (relievable pension contributions)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", + ), ] def formula(person, period, parameters): @@ -39,13 +43,20 @@ def formula(person, period, parameters): # is not set against employed earnings. self_employed = max_(0, person("self_employment_income", period)) earnings = add(person, period, earnings_components) + self_employed - deductions = add( + # Contributions paid after the person reaches 75 are not relievable + # (Finance Act 2004 s. 188(3)(a)), so reg. 55(5)(a) does not deduct + # them. + age_limit = parameters( + period + ).gov.hmrc.pensions.pension_contributions_relief_age_limit + relievable_pension_contributions = person("pension_contributions", period) * ( + person("age", period) < age_limit + ) + tax_and_national_insurance = add( person, period, - [ - "pension_contributions", - "uc_income_tax_on_earnings", - "uc_national_insurance_on_earnings", - ], + ["uc_income_tax_on_earnings", "uc_national_insurance_on_earnings"], + ) + return max_( + 0, earnings - relievable_pension_contributions - tax_and_national_insurance ) - return max_(0, earnings - deductions) From 4f9f8d8e59317ff6f7b9f8499005f6c06f374409 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 09:57:10 -0400 Subject: [PATCH 04/11] Apply the reg 82 earnings exception to UC awards only; review fixes Independent review of b696f8d0 (legal/code and tests/impact lanes): - The benefit cap is shared with Housing Benefit, so the UC earnings exception also excepted HB claimants; with the NLW-based threshold that newly excepted some in years when the threshold was under 10,152. Reg 82(1) applies "to an award of universal credit"; HB has its own exceptions (HB Regs 2006 regs 75E and 75F, WTC entitlement already in is_benefit_cap_exempt_health_disability). The income test is now uc_benefit_cap_earnings_threshold_met, and the exception needs a UC award before the cap as well. Adds the reviewer's HB regression. - Documents that the cap counts miscellaneous_income as the UC award does (FRS misc is mainly odd-job pay, reg 52(a)(iii)); the reg 66(1)(m) royalties split will move both together. - Tests: exact pins at 10,572 and 15,400, ages 74/75 for relievable contributions, a London BRMA in the counterexample, a floor-dependent childcare case, and a fixed 16,500 -> 16,501 childcare crossing in the monotonicity property (it kills a reversed-inequality mutant the random draws missed). Property 1 compares the income test, and the exception where there is a UC award both ways. Co-Authored-By: Claude Opus 5.5 --- .../uc-earned-income-consumers-1986.fixed.md | 2 +- ...ia_for_targeted_childcare_entitlement.yaml | 57 ++++++++++ .../benefit_cap_earnings_exception.yaml | 103 +++++++++++++++--- ...t_uc_earned_income_consumers_properties.py | 59 ++++++++-- .../gov/dwp/benefit_cap_earned_income.py | 5 + .../gov/dwp/is_benefit_cap_exempt_earnings.py | 36 +++--- .../uc_benefit_cap_earnings_threshold_met.py | 23 ++++ 7 files changed, 247 insertions(+), 38 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/uc_benefit_cap_earnings_threshold_met.py diff --git a/changelog.d/uc-earned-income-consumers-1986.fixed.md b/changelog.d/uc-earned-income-consumers-1986.fixed.md index 11300d84c6..47d3070fa6 100644 --- a/changelog.d/uc-earned-income-consumers-1986.fixed.md +++ b/changelog.d/uc-earned-income-consumers-1986.fixed.md @@ -1 +1 @@ -Use Universal Credit earned income as each rule defines it (#1986): the benefit cap earnings exception now sums each person's earnings net of their own tax, National Insurance and pension contributions, without the minimum income floor, against 16 hours a week at the national living wage in whole pounds a month (UC Regs 2013 regs 82 and 6(1A)(za); £881 a month from April 2026) instead of a hard-coded £10,152 a year; the targeted childcare Universal Credit route and the Kingston upon Thames, Merton, Newham and Westminster council tax reduction schemes now count earned income before the work allowance. +Use Universal Credit earned income as each rule defines it (#1986): the benefit cap earnings exception now applies only to an award of Universal Credit (not to Housing Benefit) and sums each person's earnings net of their own tax, National Insurance and relievable pension contributions, without the minimum income floor, against 16 hours a week at the national living wage in whole pounds a month (UC Regs 2013 regs 82 and 6(1A)(za); £881 a month from April 2026) instead of a hard-coded £10,152 a year; the targeted childcare Universal Credit route and the Kingston upon Thames, Merton, Newham and Westminster council tax reduction schemes now count earned income before the work allowance. diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml index 22cda2cac8..568e6240d7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml @@ -115,6 +115,63 @@ uc_earned_income_before_work_allowance: 16_000 meets_universal_credit_criteria_for_targeted_childcare_entitlement: false +- name: Combined earned income exactly at the limit meets the criterion + # "not exceeding": 7,700 + 7,700 = 15,400 <= 15,400. Each is under the + # personal allowance and primary threshold, so no tax or NI. + period: 2026 + input: + people: + parent1: + age: 30 + employment_income: 7_700 + parent2: + age: 30 + employment_income: 7_700 + child: + age: 2 + benunits: + benunit: + members: [parent1, parent2, child] + would_claim_uc: true + households: + household: + members: [parent1, parent2, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + uc_earned_income_before_work_allowance: 15_400 + meets_universal_credit_criteria_for_targeted_childcare_entitlement: true + +- name: Income treated as earned under the minimum income floor counts + # Reg. 1(3)(a) uses UC Part 6 Chapter 2, which includes reg. 62. The + # partner who is not the child's carer has profits of 3,000 and is subject + # to the floor (35 hours at the national living wage), so the couple's + # earned income is far above 15,400, although actual earnings are 3,000. + period: 2026 + input: + people: + self_employed_partner: + age: 30 + self_employment_income: 3_000 + uc_is_in_startup_period: false + carer: + age: 30 + child: + age: 2 + benunits: + benunit: + members: [self_employed_partner, carer, child] + would_claim_uc: true + households: + household: + members: [self_employed_partner, carer, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + uc_mif_applies: [true, false, false] + benefit_cap_earned_income: 3_000 + meets_universal_credit_criteria_for_targeted_childcare_entitlement: false + - name: Without pension contributions, earnings of 16,800 exceed the limit # Tax 4,230 x 20% = 846; NI 4,230 x 8% = 338.40; earned income 15,615.60. period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml index 5a72d208f5..467a949b33 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml @@ -8,6 +8,9 @@ # 12. Reg. 6(1A)(za) disregards a fraction of a pound in that amount. # (4): earned income excludes income treated as earned under reg. 62 (the # minimum income floor). +# The exception applies "to an award of universal credit": the income test is +# uc_benefit_cap_earnings_threshold_met, and is_benefit_cap_exempt_earnings +# also needs a UC award. Cases without one test the income test only. # Earned income is net of the person's own income tax, NI and relievable # pension contributions on their earnings (regs. 55(5), 57(2)); Class 3 is not # on earnings. In 2025-26 and 2026-27 the personal allowance and the primary @@ -118,7 +121,19 @@ employment_income: 10_573 output: benefit_cap_earned_income: 10_573 - is_benefit_cap_exempt_earnings: true + uc_benefit_cap_earnings_threshold_met: true + +- name: Earnings exactly at the 2026-27 threshold meet it + # "equal to or exceeds": 10,572 >= 10,572. + period: 2026 + input: + people: + person: + age: 30 + employment_income: 10_572 + output: + benefit_cap_earned_income: 10_572 + uc_benefit_cap_earnings_threshold_met: true - name: Earnings of 10,571 fall short of the 2026-27 threshold period: 2026 @@ -129,7 +144,7 @@ employment_income: 10_571 output: benefit_cap_earned_income: 10_571 - is_benefit_cap_exempt_earnings: false + uc_benefit_cap_earnings_threshold_met: false - name: Earned income is net of the person's income tax and NI on the earnings # Tax (14,000 - 12,570) x 20% = 286; Class 1 (14,000 - 12,570) x 8% = @@ -143,7 +158,7 @@ employment_income: 14_000 output: benefit_cap_earned_income: 13_599.60 - is_benefit_cap_exempt_earnings: true + uc_benefit_cap_earnings_threshold_met: true - name: Pension contributions are deducted from earned income # Reg. 55(5)(a): 11,500 - 1,000 = 10,500 < 10,572. @@ -156,10 +171,24 @@ employee_pension_contributions: 1_000 output: benefit_cap_earned_income: 10_500 - is_benefit_cap_exempt_earnings: false + uc_benefit_cap_earnings_threshold_met: false -- name: Pension contributions paid after 75 are not deducted - # Finance Act 2004 s. 188(3)(a): contributions after 75 are not relievable, +- name: Pension contributions paid at 74 are deducted + # Under 75 the contribution is relievable (Finance Act 2004 s. 188(3)(a)): + # 11,000 - 1,000 = 10,000. No NI is due over State Pension age. + period: 2026 + input: + people: + person: + age: 74 + employment_income: 11_000 + employee_pension_contributions: 1_000 + output: + benefit_cap_earned_income: 10_000 + +- name: Pension contributions paid at 75 are not deducted + # Finance Act 2004 s. 188(3)(a): contributions paid once the person has + # reached 75 are not relievable, # so reg. 55(5)(a) does not deduct them. 11,000 is under the personal # allowance and no NI is due over State Pension age, so earned income is # 11,000. @@ -167,7 +196,7 @@ input: people: person: - age: 76 + age: 75 employment_income: 11_000 employee_pension_contributions: 1_000 output: @@ -183,7 +212,7 @@ ni_class_3: 900 output: benefit_cap_earned_income: 10_600 - is_benefit_cap_exempt_earnings: true + uc_benefit_cap_earnings_threshold_met: true - name: Tax on the person's property income is not deducted from their earnings # Earnings are the lowest slice of income and sit inside the personal @@ -197,7 +226,7 @@ property_income: 40_000 output: benefit_cap_earned_income: 10_600 - is_benefit_cap_exempt_earnings: true + uc_benefit_cap_earnings_threshold_met: true - name: A partner's tax on property income does not reduce the couple's earned income period: 2026 @@ -214,7 +243,7 @@ members: [earner, partner] output: benefit_cap_earned_income: 10_600 - is_benefit_cap_exempt_earnings: true + uc_benefit_cap_earnings_threshold_met: true - name: A couple's earned income is combined # Reg. 82(1)(a): 6,000 + 6,000 = 12,000 >= 10,572. @@ -232,7 +261,7 @@ members: [person1, person2] output: benefit_cap_earned_income: 12_000 - is_benefit_cap_exempt_earnings: true + uc_benefit_cap_earnings_threshold_met: true - name: Income treated as earned under the minimum income floor does not count # Reg. 82(4). Profits of 3,000 are below the Class 4 lower profits limit, so @@ -248,7 +277,7 @@ output: uc_mif_applies: true benefit_cap_earned_income: 3_000 - is_benefit_cap_exempt_earnings: false + uc_benefit_cap_earnings_threshold_met: false - name: Earnings of 5,200 in 2016-17 meet the 430 a month threshold period: 2016 @@ -258,7 +287,7 @@ age: 30 employment_income: 5_200 output: - is_benefit_cap_exempt_earnings: true + uc_benefit_cap_earnings_threshold_met: true - name: Earnings of 8,700 in 2023-24 meet the 722 a month threshold period: 2023 @@ -268,7 +297,7 @@ age: 30 employment_income: 8_700 output: - is_benefit_cap_exempt_earnings: true + uc_benefit_cap_earnings_threshold_met: true - name: Earnings of 8,600 in 2023-24 fall short of the 722 a month threshold period: 2023 @@ -278,7 +307,7 @@ age: 30 employment_income: 8_600 output: - is_benefit_cap_exempt_earnings: false + uc_benefit_cap_earnings_threshold_met: false # --- #1986 counterexample --- @@ -312,6 +341,7 @@ household: members: [earner, partner, child1, child2, child3, child4] region: LONDON + brma: INNER_NORTH_LONDON tenure_type: RENT_PRIVATELY rent: 36_000 output: @@ -319,3 +349,46 @@ benefit_cap_earnings_threshold: 10_152 is_benefit_cap_exempt_earnings: true benefit_cap_reduction: 0 + uc_benefit_cap_earnings_threshold_met: true + +# --- The exception is for Universal Credit only --- + +- name: Earnings over the UC threshold do not except a Housing Benefit award + # Reg. 82 applies "to an award of universal credit". A Housing Benefit + # claimant with no UC and no Working Tax Credit has no earnings exception + # (HB Regs 2006 reg. 75E needs entitlement to WTC). 2023-24: 8,700 is over + # the UC threshold of 722 x 12 = 8,664, but the family cap still applies. + period: 2023 + input: + people: + parent: + age: 40 + employment_income: 8_700 + hours_worked: 416 + housing_benefit_reported: 100 + child1: + age: 8 + child2: + age: 6 + child3: + age: 4 + child4: + age: 2 + benunits: + benunit: + members: [parent, child1, child2, child3, child4] + would_claim_uc: false + would_claim_housing_benefit: true + working_tax_credit: 0 + households: + household: + members: [parent, child1, child2, child3, child4] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 36_000 + output: + uc_benefit_cap_earnings_threshold_met: true + universal_credit_pre_benefit_cap: 0 + is_benefit_cap_exempt_earnings: false + is_benefit_cap_exempt: false + benefit_cap: 25_323 diff --git a/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py index 3d35cacb08..f0662e9be3 100644 --- a/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py +++ b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py @@ -18,16 +18,19 @@ 1. The benefit cap exception never depends on income tax or NI that is not on earnings: adding Class 3 or any unearned income to any adult leaves - benefit_cap_earned_income and is_benefit_cap_exempt_earnings unchanged. + benefit_cap_earned_income and the income test unchanged, and the + exception too wherever there is a UC award before and after. 2. Differential against the UC means test: benefit cap earned income equals the UC earned income before the work allowance when no one's minimum income floor applies, and never exceeds it; and switching every floor off - never changes it (reg. 82(4)). + never changes it (reg. 82(4)). The exception is the income test plus a + UC award (reg. 82(1)), so it never applies to Housing Benefit alone. 3. The threshold is 12 x floor(12.71 x 16 x 52 / 12) in 2026-27 and the same formula on the national living wage parameter in every year, for every family. -4. Monotone in earnings: the exception is non-decreasing, and the childcare - criterion non-increasing, in any adult's employment income. +4. Monotone in earnings: the benefit cap income test is non-decreasing, and + the childcare criterion non-increasing, in any adult's employment income. + A fixed example crosses the childcare limit with a UC award throughout. 5. Differential against the formulas #1986 replaced: the childcare criterion now implies the old criterion (earned income before the work allowance is never below earned income after it), and local CTR is never higher. @@ -45,7 +48,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -153,6 +156,8 @@ def situation(units, year, bump=None, earnings_only=False, no_floor=False): "benefit_cap_earned_income", "benefit_cap_earnings_threshold", "is_benefit_cap_exempt_earnings", + "uc_benefit_cap_earnings_threshold_met", + "universal_credit_pre_benefit_cap", "uc_earned_income_before_work_allowance", "uc_earned_income", "uc_unearned_income", @@ -243,8 +248,18 @@ def test_benefit_cap_exception_ignores_tax_not_on_earnings(year, data): err_msg=f"{bump} {units}", ) np.testing.assert_array_equal( - high["is_benefit_cap_exempt_earnings"], - low["is_benefit_cap_exempt_earnings"], + high["uc_benefit_cap_earnings_threshold_met"], + low["uc_benefit_cap_earnings_threshold_met"], + err_msg=f"{bump} {units}", + ) + # Unearned income can end the UC award, and with it the exception; where + # there is an award either way, the exception does not move. + on_uc = (low["universal_credit_pre_benefit_cap"] > 0) & ( + high["universal_credit_pre_benefit_cap"] > 0 + ) + np.testing.assert_array_equal( + high["is_benefit_cap_exempt_earnings"][on_uc], + low["is_benefit_cap_exempt_earnings"][on_uc], err_msg=f"{bump} {units}", ) @@ -260,6 +275,13 @@ def test_benefit_cap_earned_income_matches_uc_without_the_floor(units, year): uc = v["uc_earned_income_before_work_allowance"] no_floor = ~v["floor_applies"] np.testing.assert_allclose(cap[no_floor], uc[no_floor], atol=0.01, err_msg=units) + # The exception is the income test plus an award of UC (reg. 82(1)). + np.testing.assert_array_equal( + v["is_benefit_cap_exempt_earnings"], + v["uc_benefit_cap_earnings_threshold_met"] + & (v["universal_credit_pre_benefit_cap"] > 0), + err_msg=str(units), + ) assert np.all(cap <= uc + 0.01), units @@ -295,15 +317,34 @@ def test_benefit_cap_threshold_is_16_hours_at_the_living_wage(units, year): np.testing.assert_allclose(v["benefit_cap_earnings_threshold"], 10_572) +CHILDCARE_LIMIT_CROSSING = ( + [ + dict( + adults=[dict(age=30, employment_income=16_500.0)], + children=[2], + tenure="RENT_FROM_COUNCIL", + rent=9_600.0, + local_authority="MERTON", + council_tax=1_800.0, + ) + ], + (0, 0, "employment_income", 1.0), +) + + @PROPERTY_SETTINGS @given(case=bumped(["employment_income"]), year=st.sampled_from(YEARS)) +# Earned income goes from 15,399.60 to 15,400.32, across the 15,400 limit, +# with a UC award throughout. +@example(case=CHILDCARE_LIMIT_CROSSING, year=2026) def test_monotone_in_earnings(case, year): units, bump = case low = calculate(units, year) high = calculate(units, year, bump=bump) - # More earnings never take away the benefit cap exception... + # More earnings never take away the benefit cap income test... assert np.all( - high["is_benefit_cap_exempt_earnings"] >= low["is_benefit_cap_exempt_earnings"] + high["uc_benefit_cap_earnings_threshold_met"] + >= low["uc_benefit_cap_earnings_threshold_met"] ), (bump, units) # ...and never bring a family within the childcare earnings limit. Where # the minimum income floor applies, the floor stands in for gross diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_earned_income.py b/policyengine_uk/variables/gov/dwp/benefit_cap_earned_income.py index 31196cc79b..d5f5872c45 100644 --- a/policyengine_uk/variables/gov/dwp/benefit_cap_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_earned_income.py @@ -23,4 +23,9 @@ class benefit_cap_earned_income(Variable): # is a member of a couple, the couple's combined earned income". Reg. # 82(4) leaves out income a person is treated as having under reg. 62 # (minimum income floor), so this sums each person's actual earned income. + # It counts the same earnings as the UC award does, including + # miscellaneous_income: in the FRS that is mainly odd-job pay (reg. + # 52(a)(iii) "any other paid work"), though it also holds some income + # reg. 66(1)(m) treats as unearned, such as royalties. Splitting those out + # will move the award and this test together. adds = ["uc_individual_earned_income_before_mif"] diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py index 55996da874..a32df8fe92 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py @@ -7,20 +7,30 @@ class is_benefit_cap_exempt_earnings(Variable): label = "Whether excepted from the benefit cap because of earnings" documentation = ( "The Universal Credit earnings exception: the benefit cap does not " - "apply where the claimant's earned income, or a couple's combined " - "earned income, reaches the pay for 16 hours a week at the national " - "living wage. The nine-month grace period after twelve months of " - "such earnings is not modelled." + "apply to an award of Universal Credit where the claimant's earned " + "income, or a couple's combined earned income, reaches the pay for " + "16 hours a week at the national living wage. It does not apply to " + "Housing Benefit, whose cap has its own exceptions (HB Regs 2006 " + "regs. 75E and 75F; entitlement to Working Tax Credit is in " + "is_benefit_cap_exempt_health_disability). The nine-month grace " + "period is not modelled." ) definition_period = YEAR - reference = dict( - title="Universal Credit Regulations 2013 reg. 82(1)(a)", - href="https://www.legislation.gov.uk/uksi/2013/376/regulation/82", - ) + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 82(1)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/82", + ), + dict( + title="Housing Benefit Regulations 2006 reg. 75E", + href="https://www.legislation.gov.uk/uksi/2006/213/regulation/75E", + ), + ] def formula(benunit, period, parameters): - # Only earned income is read, not the Universal Credit award, which - # depends on the cap. - earned_income = benunit("benefit_cap_earned_income", period) - threshold = benunit("benefit_cap_earnings_threshold", period) - return earned_income >= threshold + # Reg. 82(1): "The benefit cap does not apply to an award of + # universal credit ...". The award before the cap is read, since the + # award after it depends on this exception. + has_uc_award = benunit("universal_credit_pre_benefit_cap", period) > 0 + threshold_met = benunit("uc_benefit_cap_earnings_threshold_met", period) + return has_uc_award & threshold_met diff --git a/policyengine_uk/variables/gov/dwp/uc_benefit_cap_earnings_threshold_met.py b/policyengine_uk/variables/gov/dwp/uc_benefit_cap_earnings_threshold_met.py new file mode 100644 index 0000000000..1536e3f4f5 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/uc_benefit_cap_earnings_threshold_met.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class uc_benefit_cap_earnings_threshold_met(Variable): + value_type = bool + entity = BenUnit + label = "Earned income reaches the Universal Credit benefit cap earnings threshold" + documentation = ( + "Whether the claimant's earned income, or a couple's combined earned " + "income, is at least the pay for 16 hours a week at the national " + "living wage (UC Regs 2013 reg. 82(1)(a)). The benefit cap earnings " + "exception also needs an award of Universal Credit." + ) + definition_period = YEAR + reference = dict( + title="Universal Credit Regulations 2013 reg. 82(1)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/82", + ) + + def formula(benunit, period, parameters): + earned_income = benunit("benefit_cap_earned_income", period) + threshold = benunit("benefit_cap_earnings_threshold", period) + return earned_income >= threshold From ce430940616d508be1932dd32de316dc912314fb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:05:21 -0400 Subject: [PATCH 05/11] Leave the 2023-24 minimum wage rates to #2053 #2053 adds the same S.I. 2023/354 rates with regulation-level citations and tests, so #1999 no longer carries them and the two do not conflict. The benefit cap's 2023-24 threshold follows once #2053 merges (722 a month; 658 until then). The 2023 benefit cap cases move to 2024-25 (793 a month = 9,516 a year): the 9,520/9,500 pair and the Housing Benefit regression (9,600 earnings, cap 25,323). The property tests sample 2024 instead of 2023. Co-Authored-By: Claude Opus 5.5 --- ...ed-income-consumers-1986-nmw-2023.fixed.md | 1 - .../gov/hmrc/minimum_wage/apprentice.yaml | 5 --- .../gov/hmrc/minimum_wage/non_apprentice.yaml | 25 ----------- .../baseline/finance/income/minimum_wage.yaml | 43 ------------------- .../benefit_cap_earnings_exception.yaml | 30 +++++-------- ...t_uc_earned_income_consumers_properties.py | 4 +- 6 files changed, 12 insertions(+), 96 deletions(-) delete mode 100644 changelog.d/uc-earned-income-consumers-1986-nmw-2023.fixed.md diff --git a/changelog.d/uc-earned-income-consumers-1986-nmw-2023.fixed.md b/changelog.d/uc-earned-income-consumers-1986-nmw-2023.fixed.md deleted file mode 100644 index 0c177d4ab1..0000000000 --- a/changelog.d/uc-earned-income-consumers-1986-nmw-2023.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Add the 2023-24 National Minimum Wage and National Living Wage rates (SI 2023/354), which were missing, so 2023 used the 2022-23 rates. diff --git a/policyengine_uk/parameters/gov/hmrc/minimum_wage/apprentice.yaml b/policyengine_uk/parameters/gov/hmrc/minimum_wage/apprentice.yaml index 6109c7cf78..3a4aab3154 100644 --- a/policyengine_uk/parameters/gov/hmrc/minimum_wage/apprentice.yaml +++ b/policyengine_uk/parameters/gov/hmrc/minimum_wage/apprentice.yaml @@ -11,11 +11,6 @@ values: 2020-04-01: 4.15 2021-04-01: 4.3 2022-04-01: 4.81 - 2023-04-01: - value: 5.28 - reference: - - title: The National Minimum Wage (Amendment) Regulations 2023 - href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 6.4 reference: diff --git a/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml b/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml index f08b5ee9cf..e1ab6760d3 100644 --- a/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml +++ b/policyengine_uk/parameters/gov/hmrc/minimum_wage/non_apprentice.yaml @@ -15,11 +15,6 @@ brackets: 2020-04-01: 4.55 2021-04-01: 4.62 2022-04-01: 4.81 - 2023-04-01: - value: 5.28 - reference: - - title: The National Minimum Wage (Amendment) Regulations 2023 - href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 6.4 reference: @@ -49,11 +44,6 @@ brackets: 2020-04-01: 6.45 2021-04-01: 6.56 2022-04-01: 6.83 - 2023-04-01: - value: 7.49 - reference: - - title: The National Minimum Wage (Amendment) Regulations 2023 - href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 8.6 reference: @@ -84,11 +74,6 @@ brackets: 2020-04-01: 8.2 2021-04-01: 8.36 2022-04-01: 9.18 - 2023-04-01: - value: 10.18 - reference: - - title: The National Minimum Wage (Amendment) Regulations 2023 - href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 11.44 reference: @@ -118,11 +103,6 @@ brackets: 2020-04-01: 8.2 2021-04-01: 8.91 2022-04-01: 9.5 - 2023-04-01: - value: 10.42 - reference: - - title: The National Minimum Wage (Amendment) Regulations 2023 - href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 11.44 reference: @@ -152,11 +132,6 @@ brackets: 2020-04-01: 8.72 2021-04-01: 8.91 2022-04-01: 9.5 - 2023-04-01: - value: 10.42 - reference: - - title: The National Minimum Wage (Amendment) Regulations 2023 - href: https://www.legislation.gov.uk/uksi/2023/354/made 2024-04-01: value: 11.44 reference: diff --git a/policyengine_uk/tests/policy/baseline/finance/income/minimum_wage.yaml b/policyengine_uk/tests/policy/baseline/finance/income/minimum_wage.yaml index 2b6622d101..a7ef1035e3 100644 --- a/policyengine_uk/tests/policy/baseline/finance/income/minimum_wage.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/income/minimum_wage.yaml @@ -62,46 +62,3 @@ is_apprentice: true output: minimum_wage: 8 - -- name: 2023-24 national living wage for a person aged 23 or over - # NMW (Amendment) Regulations 2023 (SI 2023/354): reg. 4 rate 10.42. - period: 2023 - absolute_error_margin: 0.001 - input: - age: 30 - output: - minimum_wage: 10.42 - -- name: 2023-24 rate for a person aged 21 or 22 - period: 2023 - absolute_error_margin: 0.001 - input: - age: 21 - output: - minimum_wage: 10.18 - -- name: 2023-24 rate for a person aged 18 to 20 - period: 2023 - absolute_error_margin: 0.001 - input: - age: 19 - output: - minimum_wage: 7.49 - -- name: 2023-24 rate for a person aged under 18 - period: 2023 - absolute_error_margin: 0.001 - input: - age: 17 - output: - minimum_wage: 5.28 - -- name: 2023-24 apprenticeship rate - period: 2023 - absolute_error_margin: 0.001 - input: - age: 19 - is_apprentice: true - output: - minimum_wage: 5.28 - diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml index 467a949b33..cbe571a5c1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml @@ -58,16 +58,6 @@ output: benefit_cap_earnings_threshold: 7_896 # 658 x 12 -- name: 2023-24 threshold from the 10.42 national living wage - # 10.42 x 16 x 52 / 12 = 722.45 (NMW (Amendment) Regulations 2023). - period: 2023 - input: - people: - person: - age: 30 - output: - benefit_cap_earnings_threshold: 8_664 # 722 x 12 - - name: 2024-25 threshold rounds 793.17 down to 793 a month # 11.44 x 16 x 52 / 12 = 793.17. period: 2024 @@ -289,23 +279,23 @@ output: uc_benefit_cap_earnings_threshold_met: true -- name: Earnings of 8,700 in 2023-24 meet the 722 a month threshold - period: 2023 +- name: Earnings of 9,520 in 2024-25 meet the 793 a month threshold + period: 2024 input: people: person: age: 30 - employment_income: 8_700 + employment_income: 9_520 output: uc_benefit_cap_earnings_threshold_met: true -- name: Earnings of 8,600 in 2023-24 fall short of the 722 a month threshold - period: 2023 +- name: Earnings of 9,500 in 2024-25 fall short of the 793 a month threshold + period: 2024 input: people: person: age: 30 - employment_income: 8_600 + employment_income: 9_500 output: uc_benefit_cap_earnings_threshold_met: false @@ -356,14 +346,14 @@ - name: Earnings over the UC threshold do not except a Housing Benefit award # Reg. 82 applies "to an award of universal credit". A Housing Benefit # claimant with no UC and no Working Tax Credit has no earnings exception - # (HB Regs 2006 reg. 75E needs entitlement to WTC). 2023-24: 8,700 is over - # the UC threshold of 722 x 12 = 8,664, but the family cap still applies. - period: 2023 + # (HB Regs 2006 reg. 75E needs entitlement to WTC). 2024-25: 9,600 is over + # the UC threshold of 793 x 12 = 9,516, but the family cap still applies. + period: 2024 input: people: parent: age: 40 - employment_income: 8_700 + employment_income: 9_600 hours_worked: 416 housing_benefit_reported: 100 child1: diff --git a/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py index f0662e9be3..e4696ce05b 100644 --- a/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py +++ b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py @@ -61,7 +61,7 @@ derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) -YEARS = [2020, 2023, 2025, 2026] +YEARS = [2020, 2024, 2025, 2026] TENURES = ["RENT_FROM_COUNCIL", "RENT_FROM_HA", "RENT_PRIVATELY", "OWNED_OUTRIGHT"] LOCAL_AUTHORITIES = ["MERTON", "KINGSTON_UPON_THAMES", "NEWHAM", "WESTMINSTER"] UNEARNED = [ @@ -192,7 +192,7 @@ def bumped(draw, variables): # Annual benefit cap earnings thresholds (12 x the monthly amount). -THRESHOLDS = {2020: 7_248, 2023: 8_664, 2025: 10_152, 2026: 10_572} +THRESHOLDS = {2020: 7_248, 2024: 9_516, 2025: 10_152, 2026: 10_572} @st.composite From 9709b9bcacd35be4ab36edad8cc26ef0c2cb6125 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:22:55 -0400 Subject: [PATCH 06/11] Declare the claimant and partner in the consumer property fixtures After #1896, is_claimant_or_partner is inferred when not given, and an adult under 20 who is 16+ years younger than the other can be presumed a child. The generated families have at most two adults, both claimants, as #1949's fixtures now declare. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_uc_earned_income_consumers_properties.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py index e4696ce05b..287560c46e 100644 --- a/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py +++ b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py @@ -117,7 +117,11 @@ def situation(units, year, bump=None, earnings_only=False, no_floor=False): names = [] for j, adult in enumerate(unit["adults"]): name = f"p{i}_{j}" - person = {"would_claim_marriage_allowance": {year: False}} + # A family has at most two adults: the claimant and partner. + person = { + "would_claim_marriage_allowance": {year: False}, + "is_claimant_or_partner": {year: True}, + } for variable, value in adult.items(): if earnings_only and (variable in UNEARNED or variable == "ni_class_3"): continue From 5b68c27f3237ed77f2da0ee535f381ceab94b7f0 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 13 Aug 2026 12:50:13 +0100 Subject: [PATCH 07/11] Fix benefit cap exemption defects (#1818) Four defects in the benefit cap exception logic: 1. The earnings exception subtracted total income tax liability from earnings, so tax on property, savings, dividend and pension income reduced measured net earnings. Regulation 55(5) of the Universal Credit Regulations 2013 deducts only the tax paid in respect of the employment, primary Class 1 NI and relievable pension contributions. Now uses earned_income_tax, national_insurance and pension_contributions. 2. The earnings threshold was hardcoded as 10_152 in three files. Moved to the dated parameter gov.dwp.benefit_cap_earnings_exemption, expressed monthly as reg. 82 and DWP do, with values for 2020-21 to 2026-27 derived from 16 hours a week at the National Living Wage. 3. Armed Forces Independence Payment is a statutory exception but was never checked. Added to the health/disability qualifying benefits. 4. Removed dead copy-pasted code from all three is_benefit_cap_exempt_* variables, keeping each one's return semantics unchanged apart from defect 3. Co-Authored-By: Claude Opus 5 (1M context) --- changelog.d/1818.fixed.md | 4 + .../dwp/benefit_cap_earnings_exemption.yaml | 22 ++ .../benefit_cap/is_benefit_cap_exempt.yaml | 193 ++++++++++++++++++ .../gov/dwp/is_benefit_cap_exempt.py | 2 +- .../gov/dwp/is_benefit_cap_exempt_earnings.py | 83 +++----- ...is_benefit_cap_exempt_health_disability.py | 14 +- 6 files changed, 254 insertions(+), 64 deletions(-) create mode 100644 changelog.d/1818.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml diff --git a/changelog.d/1818.fixed.md b/changelog.d/1818.fixed.md new file mode 100644 index 0000000000..4bda6ec7a6 --- /dev/null +++ b/changelog.d/1818.fixed.md @@ -0,0 +1,4 @@ +- Benefit cap earnings exception (Universal Credit Regulations 2013 reg. 82) now deducts only the tax attributable to earnings, rather than the claimant's whole income tax liability, so property, savings, dividend and pension income no longer erode net earnings and wrongly remove the exception. Relievable pension contributions are now deducted as reg. 55(5) requires. +- Moved the benefit cap earnings threshold out of three hardcoded copies of `10_152` into the dated parameter `gov.dwp.benefit_cap_earnings_exemption`, with values from 2020-21 to 2026-27 (2026-27 rises to £881 a month) derived from 16 hours a week at the National Living Wage. +- Armed Forces Independence Payment now excepts a benefit unit from the benefit cap, as GOV.UK and the regulations provide. The variable has no dataset source, so this changes household calculations only. +- Removed dead copy-pasted code from the three `is_benefit_cap_exempt_*` variables, which computed exemption conditions that were never returned. diff --git a/policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml b/policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml new file mode 100644 index 0000000000..ac9d86ca47 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml @@ -0,0 +1,22 @@ +description: Monthly earned income at or above which a Universal Credit claimant (or couple, jointly) is excepted from the benefit cap. Set by regulation 82 of the Universal Credit Regulations 2013 as 16 hours per week at the National Living Wage rate in regulation 4 of the National Minimum Wage Regulations 2015, converted to a monthly amount by multiplying by 52 and dividing by 12, and rounded down to whole pounds by DWP. Earned income for this test is net of income tax, National Insurance contributions and relievable pension contributions on the earnings, and excludes income treated as earned under the minimum income floor. +values: + 2020-04-01: 604 + 2021-04-01: 617 + 2022-04-01: 658 + 2023-04-01: 722 + 2024-04-01: 793 + 2025-04-01: 846 + 2026-04-01: 881 +metadata: + label: Benefit cap earnings exemption threshold + unit: currency-GBP + period: month + reference: + - title: The Universal Credit Regulations 2013 reg. 82 (Exceptions - earnings) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82 + - title: The Universal Credit Regulations 2013 reg. 55 (Employed earnings) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/55 + - title: GOV.UK - Benefit cap - When you're not affected + href: https://www.gov.uk/benefit-cap/when-youre-not-affected + - title: GOV.UK - National Minimum Wage and National Living Wage rates + href: https://www.gov.uk/national-minimum-wage-rates diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml new file mode 100644 index 0000000000..10f03798d6 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/is_benefit_cap_exempt.yaml @@ -0,0 +1,193 @@ +# Benefit cap exceptions, Universal Credit Regulations 2013 regs. 82-83. +# https://www.legislation.gov.uk/uksi/2013/376/regulation/82 +# https://www.gov.uk/benefit-cap/when-youre-not-affected + +# --- Earnings exception: only tax on the earnings is deducted (reg. 55(5)) --- + +- name: Unearned income does not reduce net earnings for the earnings exception + # Regulation 55(5) deducts the income tax paid "in respect of the + # employment", not the claimant's whole income tax liability. Property + # income of 40,000 pushes total income tax to over 8,000, but the tax + # attributable to the 13,000 of earnings is under 100, so net earnings stay + # well above the 846/month threshold and the exception applies. + period: 2025 + input: + people: + person: + age: 35 + employment_income: 13_000 + property_income: 40_000 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_earnings: true + is_benefit_cap_exempt: true + +- name: Earnings below the threshold do not except, even with large unearned income + # The same unearned income with earnings of 8,000 leaves net earnings below + # 846 x 12 = 10,152, so the exception does not apply. + period: 2025 + input: + people: + person: + age: 35 + employment_income: 8_000 + property_income: 40_000 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_earnings: false + +- name: A couple's earnings are combined for the earnings exception + # Reg. 82(1)(a) applies to "the claimant's and their partner's combined + # earned income". Neither partner alone reaches 10,152; together they do. + period: 2025 + input: + people: + person1: + age: 35 + employment_income: 6_000 + person2: + age: 35 + employment_income: 6_000 + benunits: + benunit: + members: [person1, person2] + output: + is_benefit_cap_exempt_earnings: true + +- name: Relievable pension contributions reduce earned income + # Reg. 55(5)(a) deducts relievable pension contributions from employed + # earnings, so a contribution that takes net earnings below the threshold + # removes the exception. + period: 2025 + input: + people: + person: + age: 35 + employment_income: 11_000 + employee_pension_contributions: 1_500 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_earnings: false + +# --- Earnings threshold is a dated parameter, not a hardcoded constant --- + +- name: The 2025-26 earnings threshold is 846 a month + period: 2025 + input: + people: + person: + age: 35 + employment_income: 10_300 + benunits: + benunit: + members: [person] + output: + # 10,300 clears 846 x 12 = 10,152. + is_benefit_cap_exempt_earnings: true + +- name: The 2026-27 earnings threshold rises to 881 a month + period: 2026 + input: + people: + person: + age: 35 + employment_income: 10_300 + benunits: + benunit: + members: [person] + output: + # The same earnings fall short of 881 x 12 = 10,572. + is_benefit_cap_exempt_earnings: false + +- name: Earnings above the 2026-27 threshold except from the cap + period: 2026 + input: + people: + person: + age: 35 + employment_income: 10_600 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_earnings: true + +# --- Armed Forces Independence Payment is a statutory exception --- + +- name: Armed Forces Independence Payment excepts from the benefit cap + period: 2026 + input: + people: + person: + age: 35 + armed_forces_independence_payment: 5_740 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_health_disability: true + is_benefit_cap_exempt: true + +- name: Without Armed Forces Independence Payment the cap applies + period: 2026 + input: + people: + person: + age: 35 + armed_forces_independence_payment: 0 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_health_disability: false + is_benefit_cap_exempt: false + +- name: A child's Armed Forces Independence Payment excepts the benefit unit + period: 2026 + input: + people: + parent: + age: 35 + child: + age: 10 + armed_forces_independence_payment: 5_740 + benunits: + benunit: + members: [parent, child] + output: + is_benefit_cap_exempt_health_disability: true + +# --- Other exceptions are unchanged --- + +- name: State pension age excepts from the benefit cap + period: 2026 + input: + people: + person: + age: 70 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_other: true + is_benefit_cap_exempt: true + +- name: Armed Forces Compensation Scheme payments except from the benefit cap + period: 2026 + input: + people: + person: + age: 35 + afcs_reported: 2_000 + benunits: + benunit: + members: [person] + output: + is_benefit_cap_exempt_other: true + is_benefit_cap_exempt_health_disability: true diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py index 563b340854..6bd5018997 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt.py @@ -4,7 +4,7 @@ class is_benefit_cap_exempt(Variable): value_type = bool entity = BenUnit - label = "Whether exempt from the benefits cap because of health or disability" + label = "Whether exempt from the benefits cap" definition_period = YEAR reference = "https://www.gov.uk/benefit-cap/when-youre-not-affected" diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py index 1bdb797d6a..45019d5150 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py @@ -4,61 +4,42 @@ class is_benefit_cap_exempt_earnings(Variable): value_type = bool entity = BenUnit - label = "Whether exempt from the benefits cap for non-health/disability reasons" + label = "Whether exempt from the benefits cap because of earnings" definition_period = YEAR - reference = "https://www.gov.uk/benefit-cap/when-youre-not-affected" + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/82" def formula(benunit, period, parameters): + # Regulation 82 of the Universal Credit Regulations 2013 excepts a + # claimant (or couple, on their combined income) from the benefit cap + # where their earned income reaches 16 hours a week at the National + # Living Wage. Earned income here is employed and self-employed + # earnings net of the income tax, National Insurance and relievable + # pension contributions attributable to those earnings (reg. 55(5), + # reg. 57), not net of total tax on all income. + # + # Reg. 82(1)(a) tests "the claimant's earned income or, if the + # claimant is a member of a couple, the couple's combined earned + # income": a dependant's earnings do not count. + # + # Only earned income is read, not the Universal Credit award itself, + # to avoid a circular dependency with the cap. person = benunit.members - - # UC-specific exemptions - # Limited capability for work and work-related activity - has_lcwra = benunit.any(person("uc_limited_capability_for_WRA", period)) - - # Carer element in UC indicates caring for someone with disability - gets_uc_carer_element = benunit("uc_carer_element", period) > 0 - - # Earnings exemption for UC (£846/month = £10,152/year) - # Note: Only check earned income, not UC amount itself to avoid circular dependency - # UC Regs 2013 reg. 82(1)(a) tests "the claimant's earned income or, - # if the claimant is a member of a couple, the couple's combined - # earned income": a dependant's earnings do not count. claimant = person("is_uc_assessed_claimant", period) - uc_earned = benunit.sum( - ( - person("employment_income", period) - + person("self_employment_income", period) - - person("income_tax", period) - - person("national_insurance", period) - ) - * claimant + earnings = person("employment_income", period) + person( + "self_employment_income", period ) - earnings_threshold = 10_152 - meets_earnings_test = uc_earned >= earnings_threshold - - # Disability and carer benefits that exempt from cap - QUAL_PERSONAL_BENEFITS = [ - "attendance_allowance", - "carers_allowance", - "dla", # Disability Living Allowance (includes components) - "pip_dl", # PIP daily living component - "pip_m", # PIP mobility component - "iidb", # Industrial injuries disability benefit - ] - - # ESA and Working Tax Credit - QUAL_BENUNIT_BENEFITS = [ - "esa_income", # Income-based ESA - "working_tax_credit", # If getting WTC, likely working enough - ] - - qualifying_personal_benefits = add(benunit, period, QUAL_PERSONAL_BENEFITS) - qualifying_benunit_benefits = add(benunit, period, QUAL_BENUNIT_BENEFITS) - - # Check for Armed Forces Compensation Scheme payments - afcs = benunit("afcs", period) > 0 - - # ESA contribution-based with support component - esa_support_component = benunit("esa_contrib", period) > 0 + # earned_income_tax is the tax on non-savings, non-dividend income, + # which excludes property, savings and dividend income. It is the + # closest available measure of tax on earnings; it still includes tax + # on private and state pension income, which is the approximation we + # accept here (households with pension income are generally already + # exempt through the state pension age exception). + deductions = ( + person("earned_income_tax", period) + + person("national_insurance", period) + + person("pension_contributions", period) + ) + net_earnings = max_(0, benunit.sum((earnings - deductions) * claimant)) - return meets_earnings_test + monthly_threshold = parameters(period).gov.dwp.benefit_cap_earnings_exemption + return net_earnings >= monthly_threshold * MONTHS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 72c0d1a847..658f09594c 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -11,24 +11,12 @@ class is_benefit_cap_exempt_health_disability(Variable): def formula(benunit, period, parameters): person = benunit.members - # UC-specific exemptions # Limited capability for work and work-related activity has_lcwra = benunit.any(person("uc_limited_capability_for_WRA", period)) # Carer element in UC indicates caring for someone with disability gets_uc_carer_element = benunit("uc_carer_element", period) > 0 - # Earnings exemption for UC (£846/month = £10,152/year) - # Note: Only check earned income, not UC amount itself to avoid circular dependency - uc_earned = benunit.sum( - benunit.members("employment_income", period) - + benunit.members("self_employment_income", period) - - benunit.members("income_tax", period) - - benunit.members("national_insurance", period) - ) - earnings_threshold = 10_152 - meets_earnings_test = uc_earned >= earnings_threshold - # Disability and carer benefits that exempt from cap QUAL_PERSONAL_BENEFITS = [ "attendance_allowance", @@ -41,6 +29,8 @@ def formula(benunit, period, parameters): "pip_dl", # PIP daily living component "pip_m", # PIP mobility component "iidb", # Industrial injuries disability benefit + # Armed Forces Independence Payment is a statutory exemption + "armed_forces_independence_payment", ] # ESA and Working Tax Credit From ea2b87cece52736992dc6ce1860f16b2a02b0325 Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Thu, 8 Oct 2026 15:51:17 +0100 Subject: [PATCH 08/11] Drop the strict xfail on the dividend benefit cap case now that #1818's fix makes it pass The earnings exception to the benefit cap now nets only earned_income_tax off earnings, so tax on dividends no longer removes it (#1986). Co-Authored-By: Claude Opus 5.5 --- .../test_uc_income_from_capital_properties.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index 4a07d757fd..fe9c64ca20 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -33,10 +33,12 @@ s. 35), which raises the tax a person pays on their earnings and so changes the deduction from them (see test_uc_earnings_deductions_properties.py). The award after the benefit cap is compared only where the cap exemption is -also unchanged: the model's earnings exception to the cap still nets tax on -all of a person's income off their earnings -(PolicyEngine/policyengine-uk#1986), so capital income can switch the cap on. -A strict xfail pins that case. +also unchanged. The cap's earnings exception now nets only the tax on +non-savings, non-dividend income off earnings (PolicyEngine/policyengine-uk#1986), +so savings and dividend income leave it alone, but property income is taxed in +that same band and can still switch the cap on. +test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception pins +the dividend case. """ import numpy as np @@ -323,13 +325,6 @@ def test_tax_on_dividends_does_not_raise_a_working_familys_award(): assert values["uc_earned_income"][0] == pytest.approx(10_000, abs=0.01) -@pytest.mark.xfail( - strict=True, - reason=( - "PolicyEngine/policyengine-uk#1986: the benefit cap earnings exception " - "nets tax on all of a person's income off their earnings" - ), -) def test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception(): # 2026: single claimant aged 30 in London, council rent 20,000, earning # 11,000 with no tax or NI on it. Without dividends the model applies no From f67c068c8300b3b1d63ebfffc6a86318519d042a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:45:00 -0400 Subject: [PATCH 09/11] Measure reg. 82 earned income per claimant on top of #1820 (#1986) #1820 measures the benefit cap's earned income as earnings less earned_income_tax, national_insurance and pension_contributions. That still deducts tax on pension and property income, all Class 3 (the #1986 counterexample: a partner's 923 of voluntary Class 3 removed a couple's exception) and contributions paid after 75. Replace only that measure with benefit_cap_earned_income: each claimant's uc_individual_earned_income_before_mif (earnings less their own tax and NI on those earnings and relievable pension contributions), summed over the claimants, without minimum income floor income (reg. 82(4)). #1820's structure, threshold parameter and comments stay; the comments its measure made untrue are edited. Reg. 82(1) excepts "an award of universal credit", so the exception no longer lifts the cap on a Housing Benefit award without Universal Credit (HB's own work exception is WTC entitlement, HB Regs 2006 reg. 75E). Families with neither award keep the income test, so #1820's YAML cases are unchanged. Threshold: drop #1999's own NLW derivation (benefit_cap_earnings_threshold, uc_benefit_cap_earnings_threshold_met and the earnings_exception parameters) in favour of #1820's gov.dwp.benefit_cap_earnings_exemption, extended back with cited values: 430 a month from 29 April 2013 (reg. 82(1)(a) as made), then 520, 542 and 569 for 2017-18 to 2019-20 (S.I. 2017/138; NMW Regs 2015 reg. 4; reg. 6(1A)(za)). A differential test checks every dated value from April 2017 against floor(16 x NLW x 52 / 12) from gov.hmrc.minimum_wage. Tests: the cap YAML moves onto the parameter with boundary pairs for 2016, 2017, 2018, 2019 and 2023 (722 a month); the property file computes the income test from the parameter and loads the model once per module; a rent pin joins #1820's de-xfailed dividend case; the floor childcare case names the responsible carer, since main now defaults the nomination to the elder. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1986.fixed.md | 1 + .../uc-earned-income-consumers-1986.fixed.md | 1 - .../programs/gov/dwp/universal-credit.ipynb | 2 +- .../dwp/benefit_cap_earnings_exemption.yaml | 26 ++- .../universal_credit/benefit_cap/README.md | 1 - .../benefit_cap/earnings_exception/README.md | 1 - .../earnings_exception/monthly_amount.yaml | 13 -- .../earnings_exception/weekly_hours.yaml | 13 -- ...ia_for_targeted_childcare_entitlement.yaml | 5 + .../benefit_cap_earnings_exception.yaml | 158 ++++++++++++------ ...t_uc_earned_income_consumers_properties.py | 146 ++++++++++++---- .../test_uc_income_from_capital_properties.py | 47 +++++- .../gov/dwp/benefit_cap_earnings_threshold.py | 49 ------ .../gov/dwp/is_benefit_cap_exempt_earnings.py | 48 +++--- .../uc_benefit_cap_earnings_threshold_met.py | 23 --- 15 files changed, 317 insertions(+), 217 deletions(-) create mode 100644 changelog.d/1986.fixed.md delete mode 100644 changelog.d/uc-earned-income-consumers-1986.fixed.md delete mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md delete mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md delete mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/monthly_amount.yaml delete mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/weekly_hours.yaml delete mode 100644 policyengine_uk/variables/gov/dwp/benefit_cap_earnings_threshold.py delete mode 100644 policyengine_uk/variables/gov/dwp/uc_benefit_cap_earnings_threshold_met.py diff --git a/changelog.d/1986.fixed.md b/changelog.d/1986.fixed.md new file mode 100644 index 0000000000..f91f911863 --- /dev/null +++ b/changelog.d/1986.fixed.md @@ -0,0 +1 @@ +- Use Universal Credit earned income as each rule defines it (#1986): the benefit cap earnings exception sums each claimant's earnings net of their own income tax and National Insurance on those earnings and their relievable pension contributions, without minimum income floor income (UC Regs 2013 reg. 82), and does not except a Housing Benefit award without Universal Credit; its threshold parameter now also covers £430 a month before April 2017 and 2017-18 to 2019-20; the targeted childcare Universal Credit route and the Kingston upon Thames, Merton, Newham and Westminster council tax reduction schemes count earned income before the work allowance. diff --git a/changelog.d/uc-earned-income-consumers-1986.fixed.md b/changelog.d/uc-earned-income-consumers-1986.fixed.md deleted file mode 100644 index 47d3070fa6..0000000000 --- a/changelog.d/uc-earned-income-consumers-1986.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Use Universal Credit earned income as each rule defines it (#1986): the benefit cap earnings exception now applies only to an award of Universal Credit (not to Housing Benefit) and sums each person's earnings net of their own tax, National Insurance and relievable pension contributions, without the minimum income floor, against 16 hours a week at the national living wage in whole pounds a month (UC Regs 2013 regs 82 and 6(1A)(za); £881 a month from April 2026) instead of a hard-coded £10,152 a year; the targeted childcare Universal Credit route and the Kingston upon Thames, Merton, Newham and Westminster council tax reduction schemes now count earned income before the work allowance. diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 976d7d5822..8d0441da13 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -49,7 +49,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "### Benefit cap earnings exception\n\nThe cap does not apply where the claimant's earned income, or a couple's combined earned income, reaches 16 hours a week at the national living wage, converted to a monthly amount by multiplying by 52 and dividing by 12, with any fraction of a pound disregarded (UC Regulations 2013 regs 82(1)(a) and 6(1A)(za)): £846 a month in 2025-26 and £881 in 2026-27 (`benefit_cap_earnings_threshold`, twelve times the monthly amount). Before 1 April 2017 it was £430 a month. Earned income here (`benefit_cap_earned_income`) is each person's earnings net of their own income tax, National Insurance and pension contributions, and leaves out income treated as earned under the minimum income floor (reg 82(4)). The nine-month grace period is not modelled." + "source": "### Benefit cap earnings exception\n\nThe cap does not apply to a Universal Credit award where the claimant's earned income, or a couple's combined earned income, reaches 16 hours a week at the national living wage, converted to a monthly amount by multiplying by 52 and dividing by 12, with any fraction of a pound disregarded (UC Regulations 2013 regs 82(1)(a) and 6(1A)(za)): £846 a month in 2025-26 and £881 in 2026-27 (`gov.dwp.benefit_cap_earnings_exemption`). Before 1 April 2017 it was £430 a month. Earned income here (`benefit_cap_earned_income`) is each claimant's earnings net of their own income tax and National Insurance on those earnings and their relievable pension contributions, and leaves out income treated as earned under the minimum income floor (reg 82(4)). Reg 82 excepts only an award of Universal Credit, so the exception does not lift the cap on a Housing Benefit award without Universal Credit; Housing Benefit's own work exception is entitlement to Working Tax Credit (HB Regulations 2006 reg 75E). The nine-month grace period is not modelled." }, { "cell_type": "code", diff --git a/policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml b/policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml index ac9d86ca47..77808c7e9a 100644 --- a/policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml +++ b/policyengine_uk/parameters/gov/dwp/benefit_cap_earnings_exemption.yaml @@ -1,5 +1,27 @@ -description: Monthly earned income at or above which a Universal Credit claimant (or couple, jointly) is excepted from the benefit cap. Set by regulation 82 of the Universal Credit Regulations 2013 as 16 hours per week at the National Living Wage rate in regulation 4 of the National Minimum Wage Regulations 2015, converted to a monthly amount by multiplying by 52 and dividing by 12, and rounded down to whole pounds by DWP. Earned income for this test is net of income tax, National Insurance contributions and relievable pension contributions on the earnings, and excludes income treated as earned under the minimum income floor. +description: Monthly earned income at or above which a Universal Credit claimant (or couple, jointly) is excepted from the benefit cap. Set by regulation 82 of the Universal Credit Regulations 2013, from 1 April 2017 as 16 hours per week at the National Living Wage rate in regulation 4 of the National Minimum Wage Regulations 2015, converted to a monthly amount by multiplying by 52 and dividing by 12, with any fraction of a pound disregarded (regulation 6(1A)(za)), and before then as 430. Earned income for this test is net of income tax, National Insurance contributions and relievable pension contributions on the earnings, and excludes income treated as earned under the minimum income floor. values: + 2013-04-29: + value: 430 + reference: + - title: The Universal Credit Regulations 2013 reg. 82(1)(a) as made ("equal to or exceeds £430"); in force 29 April 2013 (reg. 1) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82/made + 2017-04-01: + value: 520 + reference: + - title: The Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017 (S.I. 2017/138) reg. 2(3)(a), in force 1 April 2017; National Minimum Wage Regulations 2015 reg. 4 at 1 April 2017, £7.50; 7.50 x 16 x 52 / 12 = 520 + href: https://www.legislation.gov.uk/uksi/2017/138/made + - title: The National Minimum Wage Regulations 2015 reg. 4 (1 April 2017) + href: https://www.legislation.gov.uk/uksi/2015/621/regulation/4/2017-04-01 + 2018-04-01: + value: 542 + reference: + - title: The National Minimum Wage Regulations 2015 reg. 4 at 1 April 2018, £7.83; 7.83 x 16 x 52 / 12 = 542.88, fraction disregarded (UC Regs 2013 reg. 6(1A)(za)) + href: https://www.legislation.gov.uk/uksi/2015/621/regulation/4/2018-04-01 + 2019-04-01: + value: 569 + reference: + - title: The National Minimum Wage Regulations 2015 reg. 4 at 1 April 2019, £8.21; 8.21 x 16 x 52 / 12 = 569.23, fraction disregarded (UC Regs 2013 reg. 6(1A)(za)) + href: https://www.legislation.gov.uk/uksi/2015/621/regulation/4/2019-04-01 2020-04-01: 604 2021-04-01: 617 2022-04-01: 658 @@ -14,6 +36,8 @@ metadata: reference: - title: The Universal Credit Regulations 2013 reg. 82 (Exceptions - earnings) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82 + - title: The Universal Credit Regulations 2013 reg. 6(1A)(za) (Rounding) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/6 - title: The Universal Credit Regulations 2013 reg. 55 (Employed earnings) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/55 - title: GOV.UK - Benefit cap - When you're not affected diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md deleted file mode 100644 index e48bb342f4..0000000000 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md +++ /dev/null @@ -1 +0,0 @@ -# Benefit cap diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md deleted file mode 100644 index 1cd731ff55..0000000000 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md +++ /dev/null @@ -1 +0,0 @@ -# Earnings exception diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/monthly_amount.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/monthly_amount.yaml deleted file mode 100644 index 5e4cfed49a..0000000000 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/monthly_amount.yaml +++ /dev/null @@ -1,13 +0,0 @@ -description: Fixed monthly earned income at or above which a Universal Credit award is excepted from the benefit cap. The regulations set a sum of money until 1 April 2017 and a number of hours at the national living wage rate since, so this is zero from that date. -values: - 2013-04-29: 430 - 2017-04-01: 0 -metadata: - unit: currency-GBP - period: month - label: Universal Credit benefit cap earnings exception fixed monthly amount - reference: - - title: The Universal Credit Regulations 2013 reg. 82(1)(a), as made - href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82/made - - title: The Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017 reg. 2(3)(a) - href: https://www.legislation.gov.uk/uksi/2017/138/regulation/2/made diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/weekly_hours.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/weekly_hours.yaml deleted file mode 100644 index 03dd32e203..0000000000 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/weekly_hours.yaml +++ /dev/null @@ -1,13 +0,0 @@ -description: Hours a week at the national living wage rate whose pay, converted to a monthly amount, is the earned income at or above which a Universal Credit award is excepted from the benefit cap. Zero before 1 April 2017, when the regulations set a fixed sum instead. -values: - 2013-04-29: 0 - 2017-04-01: 16 -metadata: - unit: hour - period: week - label: Universal Credit benefit cap earnings exception weekly hours at the national living wage - reference: - - title: The Universal Credit Regulations 2013 reg. 82(1)(a) - href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82 - - title: The Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017 reg. 2(3)(a) - href: https://www.legislation.gov.uk/uksi/2017/138/regulation/2/made diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml index 568e6240d7..0338d627de 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/targeted_childcare_entitlement/meets_universal_credit_criteria_for_targeted_childcare_entitlement.yaml @@ -147,6 +147,9 @@ # partner who is not the child's carer has profits of 3,000 and is subject # to the floor (35 hours at the national living wage), so the couple's # earned income is far above 15,400, although actual earnings are 3,000. + # The couple nominate the other partner as the child's responsible carer + # (reg. 86); the responsible carer of a child under 3 is not subject to + # the floor (uc_work_related_group_apart_from_earnings). period: 2026 input: people: @@ -154,8 +157,10 @@ age: 30 self_employment_income: 3_000 uc_is_in_startup_period: false + uc_is_responsible_carer: false carer: age: 30 + uc_is_responsible_carer: true child: age: 2 benunits: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml index cbe571a5c1..fce1a0f772 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml @@ -5,98 +5,158 @@ # the claimant is a member of a couple, the couple's combined earned income" # is at least the pay for 16 hours a week at the reg. 4 National Minimum # Wage Regulations rate (the national living wage), times 52 and divided by -# 12. Reg. 6(1A)(za) disregards a fraction of a pound in that amount. +# 12. Reg. 6(1A)(za) disregards a fraction of a pound in that amount. Before +# 1 April 2017 the amount was 430 a month. The monthly amounts are the +# parameter gov.dwp.benefit_cap_earnings_exemption. # (4): earned income excludes income treated as earned under reg. 62 (the # minimum income floor). -# The exception applies "to an award of universal credit": the income test is -# uc_benefit_cap_earnings_threshold_met, and is_benefit_cap_exempt_earnings -# also needs a UC award. Cases without one test the income test only. +# The exception applies "to an award of universal credit": it does not except +# a Housing Benefit award (HB Regs 2006 reg. 75E). Cases with no Housing +# Benefit test the income test through is_benefit_cap_exempt_earnings. # Earned income is net of the person's own income tax, NI and relievable # pension contributions on their earnings (regs. 55(5), 57(2)); Class 3 is not # on earnings. In 2025-26 and 2026-27 the personal allowance and the primary -# threshold are both 12,570 a year. +# threshold are both 12,570 a year. In the earlier years tested here the +# earnings are below both. # --- Threshold: 16 x national living wage x 52 / 12, whole pounds --- -- name: Before 1 April 2017 the threshold was 430 a month - # Reg. 82(1)(a) as made; SI 2017/138 substituted the hours formula. +- name: 2016-17 earnings of 5,160 meet the 430 a month threshold + # Reg. 82(1)(a) as made; S.I. 2017/138 substituted the hours formula + # from 1 April 2017. + # 430 x 12 = 5,160; "equal to or exceeds". period: 2016 input: people: person: age: 30 + employment_income: 5160 output: - benefit_cap_earnings_threshold: 5_160 # 430 x 12 + benefit_cap_earned_income: 5160 + is_benefit_cap_exempt_earnings: true -- name: 2017-18 threshold from the 7.50 national living wage - # 7.50 x 16 x 52 / 12 = 520.00. +- name: 2016-17 earnings of 5,159 fall short of the 430 a month threshold + period: 2016 + input: + people: + person: + age: 30 + employment_income: 5159 + output: + benefit_cap_earned_income: 5159 + is_benefit_cap_exempt_earnings: false + +- name: 2017-18 earnings of 6,240 meet the 520 a month threshold + # National living wage 7.50: 7.50 x 16 x 52 / 12 = 520.00. + # 520 x 12 = 6,240; "equal to or exceeds". period: 2017 input: people: person: age: 30 + employment_income: 6240 output: - benefit_cap_earnings_threshold: 6_240 # 520 x 12 + benefit_cap_earned_income: 6240 + is_benefit_cap_exempt_earnings: true -- name: 2020-21 threshold rounds 604.59 down to 604 a month - # 8.72 x 16 x 52 / 12 = 604.59. - period: 2020 +- name: 2017-18 earnings of 6,239 fall short of the 520 a month threshold + period: 2017 input: people: person: age: 30 + employment_income: 6239 output: - benefit_cap_earnings_threshold: 7_248 # 604 x 12 + benefit_cap_earned_income: 6239 + is_benefit_cap_exempt_earnings: false -- name: 2022-23 threshold rounds 658.67 down to 658 a month - # 9.50 x 16 x 52 / 12 = 658.67. - period: 2022 +- name: 2018-19 earnings of 6,504 meet the 542 a month threshold + # National living wage 7.83: 7.83 x 16 x 52 / 12 = 542.88, rounded + # down to 542 (reg. 6(1A)(za)). + # 542 x 12 = 6,504; "equal to or exceeds". + period: 2018 input: people: person: age: 30 + employment_income: 6504 output: - benefit_cap_earnings_threshold: 7_896 # 658 x 12 + benefit_cap_earned_income: 6504 + is_benefit_cap_exempt_earnings: true -- name: 2024-25 threshold rounds 793.17 down to 793 a month - # 11.44 x 16 x 52 / 12 = 793.17. - period: 2024 +- name: 2018-19 earnings of 6,503 fall short of the 542 a month threshold + period: 2018 input: people: person: age: 30 + employment_income: 6503 output: - benefit_cap_earnings_threshold: 9_516 # 793 x 12 + benefit_cap_earned_income: 6503 + is_benefit_cap_exempt_earnings: false -- name: 2025-26 threshold is 846 a month - # 12.21 x 16 x 52 / 12 = 846.56; GOV.UK gives 846 to 31 March 2026. - period: 2025 +- name: 2019-20 earnings of 6,828 meet the 569 a month threshold + # National living wage 8.21: 8.21 x 16 x 52 / 12 = 569.23, rounded + # down to 569. + # 569 x 12 = 6,828; "equal to or exceeds". + period: 2019 input: people: person: age: 30 + employment_income: 6828 output: - benefit_cap_earnings_threshold: 10_152 # 846 x 12 + benefit_cap_earned_income: 6828 + is_benefit_cap_exempt_earnings: true -- name: 2026-27 threshold is 881 a month - # 12.71 x 16 x 52 / 12 = 881.23; GOV.UK gives 881 from 1 April 2026. - period: 2026 +- name: 2019-20 earnings of 6,827 fall short of the 569 a month threshold + period: 2019 input: people: person: age: 30 + employment_income: 6827 output: - benefit_cap_earnings_threshold: 10_572 # 881 x 12 + benefit_cap_earned_income: 6827 + is_benefit_cap_exempt_earnings: false + +- name: 2023-24 earnings of 8,664 meet the 722 a month threshold + # National living wage 10.42 (S.I. 2023/354): 10.42 x 16 x 52 / 12 = + # 722.45, rounded down to 722. GOV.UK gave 722 for 2023-24. + # 722 x 12 = 8,664; "equal to or exceeds". + period: 2023 + input: + people: + person: + age: 30 + employment_income: 8664 + output: + benefit_cap_earned_income: 8664 + is_benefit_cap_exempt_earnings: true + +- name: 2023-24 earnings of 8,663 fall short of the 722 a month threshold + period: 2023 + input: + people: + person: + age: 30 + employment_income: 8663 + output: + benefit_cap_earned_income: 8663 + is_benefit_cap_exempt_earnings: false - name: The threshold uses the national living wage whatever the claimant's age - # Reg. 82(1)(a) refers to the reg. 4 rate, not the claimant's own rate. + # Reg. 82(1)(a) refers to the reg. 4 rate, not the claimant's own rate. A + # claimant aged 19 earning 10,572 meets the 2026-27 threshold of 881 x 12. period: 2026 input: people: person: age: 19 + employment_income: 10_572 output: - benefit_cap_earnings_threshold: 10_572 + benefit_cap_earned_income: 10_572 + is_benefit_cap_exempt_earnings: true # --- Earned income at the threshold --- @@ -111,7 +171,7 @@ employment_income: 10_573 output: benefit_cap_earned_income: 10_573 - uc_benefit_cap_earnings_threshold_met: true + is_benefit_cap_exempt_earnings: true - name: Earnings exactly at the 2026-27 threshold meet it # "equal to or exceeds": 10,572 >= 10,572. @@ -123,7 +183,7 @@ employment_income: 10_572 output: benefit_cap_earned_income: 10_572 - uc_benefit_cap_earnings_threshold_met: true + is_benefit_cap_exempt_earnings: true - name: Earnings of 10,571 fall short of the 2026-27 threshold period: 2026 @@ -134,7 +194,7 @@ employment_income: 10_571 output: benefit_cap_earned_income: 10_571 - uc_benefit_cap_earnings_threshold_met: false + is_benefit_cap_exempt_earnings: false - name: Earned income is net of the person's income tax and NI on the earnings # Tax (14,000 - 12,570) x 20% = 286; Class 1 (14,000 - 12,570) x 8% = @@ -148,7 +208,7 @@ employment_income: 14_000 output: benefit_cap_earned_income: 13_599.60 - uc_benefit_cap_earnings_threshold_met: true + is_benefit_cap_exempt_earnings: true - name: Pension contributions are deducted from earned income # Reg. 55(5)(a): 11,500 - 1,000 = 10,500 < 10,572. @@ -161,7 +221,7 @@ employee_pension_contributions: 1_000 output: benefit_cap_earned_income: 10_500 - uc_benefit_cap_earnings_threshold_met: false + is_benefit_cap_exempt_earnings: false - name: Pension contributions paid at 74 are deducted # Under 75 the contribution is relievable (Finance Act 2004 s. 188(3)(a)): @@ -202,7 +262,7 @@ ni_class_3: 900 output: benefit_cap_earned_income: 10_600 - uc_benefit_cap_earnings_threshold_met: true + is_benefit_cap_exempt_earnings: true - name: Tax on the person's property income is not deducted from their earnings # Earnings are the lowest slice of income and sit inside the personal @@ -216,7 +276,7 @@ property_income: 40_000 output: benefit_cap_earned_income: 10_600 - uc_benefit_cap_earnings_threshold_met: true + is_benefit_cap_exempt_earnings: true - name: A partner's tax on property income does not reduce the couple's earned income period: 2026 @@ -233,7 +293,7 @@ members: [earner, partner] output: benefit_cap_earned_income: 10_600 - uc_benefit_cap_earnings_threshold_met: true + is_benefit_cap_exempt_earnings: true - name: A couple's earned income is combined # Reg. 82(1)(a): 6,000 + 6,000 = 12,000 >= 10,572. @@ -251,7 +311,7 @@ members: [person1, person2] output: benefit_cap_earned_income: 12_000 - uc_benefit_cap_earnings_threshold_met: true + is_benefit_cap_exempt_earnings: true - name: Income treated as earned under the minimum income floor does not count # Reg. 82(4). Profits of 3,000 are below the Class 4 lower profits limit, so @@ -267,7 +327,7 @@ output: uc_mif_applies: true benefit_cap_earned_income: 3_000 - uc_benefit_cap_earnings_threshold_met: false + is_benefit_cap_exempt_earnings: false - name: Earnings of 5,200 in 2016-17 meet the 430 a month threshold period: 2016 @@ -277,7 +337,7 @@ age: 30 employment_income: 5_200 output: - uc_benefit_cap_earnings_threshold_met: true + is_benefit_cap_exempt_earnings: true - name: Earnings of 9,520 in 2024-25 meet the 793 a month threshold period: 2024 @@ -287,7 +347,7 @@ age: 30 employment_income: 9_520 output: - uc_benefit_cap_earnings_threshold_met: true + is_benefit_cap_exempt_earnings: true - name: Earnings of 9,500 in 2024-25 fall short of the 793 a month threshold period: 2024 @@ -297,7 +357,7 @@ age: 30 employment_income: 9_500 output: - uc_benefit_cap_earnings_threshold_met: false + is_benefit_cap_exempt_earnings: false # --- #1986 counterexample --- @@ -336,10 +396,9 @@ rent: 36_000 output: benefit_cap_earned_income: 10_300 - benefit_cap_earnings_threshold: 10_152 + is_uc_entitled: true is_benefit_cap_exempt_earnings: true benefit_cap_reduction: 0 - uc_benefit_cap_earnings_threshold_met: true # --- The exception is for Universal Credit only --- @@ -377,7 +436,8 @@ tenure_type: RENT_FROM_COUNCIL rent: 36_000 output: - uc_benefit_cap_earnings_threshold_met: true + # 9,600 >= 793 x 12 = 9,516: the reg. 82 income test is met. + benefit_cap_earned_income: 9_600 universal_credit_pre_benefit_cap: 0 is_benefit_cap_exempt_earnings: false is_benefit_cap_exempt: false diff --git a/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py index 287560c46e..793e54ee6a 100644 --- a/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py +++ b/policyengine_uk/tests/test_uc_earned_income_consumers_properties.py @@ -23,11 +23,15 @@ 2. Differential against the UC means test: benefit cap earned income equals the UC earned income before the work allowance when no one's minimum income floor applies, and never exceeds it; and switching every floor off - never changes it (reg. 82(4)). The exception is the income test plus a - UC award (reg. 82(1)), so it never applies to Housing Benefit alone. -3. The threshold is 12 x floor(12.71 x 16 x 52 / 12) in 2026-27 and the same - formula on the national living wage parameter in every year, for every - family. + never changes it (reg. 82(4)). The exception is the income test, unless + the family has Housing Benefit and no UC award (reg. 82(1) excepts only + "an award of universal credit"), so it never applies to Housing Benefit + alone. +3. Differential against the minimum wage: every dated value of the + threshold parameter (gov.dwp.benefit_cap_earnings_exemption) from 1 April + 2017 is floor(16 x national living wage x 52 / 12), with the national + living wage read from gov.hmrc.minimum_wage, and 430 before then; the + fiscal-year values the model reads agree for every year. 4. Monotone in earnings: the benefit cap income test is non-decreasing, and the childcare criterion non-increasing, in any adult's employment income. A fixed example crosses the childcare limit with a UC award throughout. @@ -47,7 +51,12 @@ benefit cap measure. """ +from pathlib import Path + +import yaml + import numpy as np +import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st @@ -55,6 +64,9 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.scenario import Scenario +# Load the model once; each simulation gets its own clone of it. +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + PROPERTY_SETTINGS = settings( max_examples=10, deadline=None, @@ -158,9 +170,9 @@ def situation(units, year, bump=None, earnings_only=False, no_floor=False): BENUNIT_VARIABLES = [ "benefit_cap_earned_income", - "benefit_cap_earnings_threshold", "is_benefit_cap_exempt_earnings", - "uc_benefit_cap_earnings_threshold_met", + "is_uc_entitled", + "housing_benefit_pre_benefit_cap", "universal_credit_pre_benefit_cap", "uc_earned_income_before_work_allowance", "uc_earned_income", @@ -184,6 +196,11 @@ def calculate(units, year, scenario=None, **kwargs): sim.calculate("uc_mif_applies", year), "person", "benunit", "any" ) ) + # The reg. 82(1)(a) income test, recomputed from the threshold parameter. + monthly = sim.tax_benefit_system.parameters( + year + ).gov.dwp.benefit_cap_earnings_exemption + values["income_test"] = values["benefit_cap_earned_income"] >= monthly * 12 return values @@ -252,8 +269,8 @@ def test_benefit_cap_exception_ignores_tax_not_on_earnings(year, data): err_msg=f"{bump} {units}", ) np.testing.assert_array_equal( - high["uc_benefit_cap_earnings_threshold_met"], - low["uc_benefit_cap_earnings_threshold_met"], + high["income_test"], + low["income_test"], err_msg=f"{bump} {units}", ) # Unearned income can end the UC award, and with it the exception; where @@ -279,11 +296,14 @@ def test_benefit_cap_earned_income_matches_uc_without_the_floor(units, year): uc = v["uc_earned_income_before_work_allowance"] no_floor = ~v["floor_applies"] np.testing.assert_allclose(cap[no_floor], uc[no_floor], atol=0.01, err_msg=units) - # The exception is the income test plus an award of UC (reg. 82(1)). + # The exception is the income test, except for a Housing Benefit award + # without a UC award (reg. 82(1) excepts "an award of universal credit"). + uc_award = v["universal_credit_pre_benefit_cap"] > 0 + np.testing.assert_array_equal(v["is_uc_entitled"], uc_award) + housing_benefit_only = (v["housing_benefit_pre_benefit_cap"] > 0) & ~uc_award np.testing.assert_array_equal( v["is_benefit_cap_exempt_earnings"], - v["uc_benefit_cap_earnings_threshold_met"] - & (v["universal_credit_pre_benefit_cap"] > 0), + v["income_test"] & ~housing_benefit_only, err_msg=str(units), ) assert np.all(cap <= uc + 0.01), units @@ -305,20 +325,72 @@ def test_minimum_income_floor_never_counts_for_the_benefit_cap(units, year): ) -@PROPERTY_SETTINGS -@given( - units=st.lists(families(), min_size=1, max_size=8), - year=st.sampled_from(YEARS), -) -def test_benefit_cap_threshold_is_16_hours_at_the_living_wage(units, year): - v = calculate(units, year) - nlw = Simulation(situation={"people": {"p": {"age": {year: 30}}}}).calculate( - "minimum_wage", year - )[0] - expected = 12 * np.floor(nlw * 16 * 52 / 12) - np.testing.assert_allclose(v["benefit_cap_earnings_threshold"], expected) - if year == 2026: - np.testing.assert_allclose(v["benefit_cap_earnings_threshold"], 10_572) +PARAMETERS = Path(__file__).parents[1] / "parameters" / "gov" +THRESHOLD_FILE = PARAMETERS / "dwp" / "benefit_cap_earnings_exemption.yaml" +MINIMUM_WAGE_FILE = PARAMETERS / "hmrc" / "minimum_wage" / "non_apprentice.yaml" + + +def _dated(values): + """{date: value} from a parameter's values, plain or with references.""" + return { + str(date): value["value"] if isinstance(value, dict) else value + for date, value in values.items() + } + + +def _value_at(values, date): + return values[max(d for d in values if d <= date)] + + +def test_benefit_cap_threshold_parameter_matches_the_living_wage(): + # Reg. 82(1)(a): 16 hours a week at the reg. 4 rate (the national living + # wage, the top age band of the minimum wage schedule), x 52 / 12, with + # any fraction of a pound disregarded (reg. 6(1A)(za)). Before 1 April + # 2017 the amount was 430 (reg. 82(1)(a) as made; S.I. 2017/138). + # Read from the YAML files, so every dated value is checked, not only the + # fiscal-year values the model reads. + threshold = _dated(yaml.safe_load(THRESHOLD_FILE.read_text())["values"]) + living_wage = _dated( + yaml.safe_load(MINIMUM_WAGE_FILE.read_text())["brackets"][-1]["amount"] + ) + checked = 0 + for date, monthly in threshold.items(): + if date < "2017-04-01": + assert monthly == 430, (date, monthly) + continue + # Each threshold date must be a date the living wage changed, so the + # value cannot be stale. + assert date in living_wage, date + rate = _value_at(living_wage, date) + assert monthly == np.floor(rate * 16 * 52 / 12), (date, monthly, rate) + checked += 1 + # 2017-18 to 2026-27. + assert checked == 10 + # Every living wage change since April 2017 has a threshold value. + assert {d for d in living_wage if d >= "2017-04-01"} <= set(threshold) + + +def test_model_reads_the_threshold_for_each_fiscal_year(): + sim = Simulation(situation={"people": {"p": {"age": {2026: 30}}}}) + expected = { + 2016: 430, + 2017: 520, + 2018: 542, + 2019: 569, + 2020: 604, + 2021: 617, + 2022: 658, + 2023: 722, + 2024: 793, + 2025: 846, + 2026: 881, + } + for year, monthly in expected.items(): + parameters = sim.tax_benefit_system.parameters(year).gov + assert parameters.dwp.benefit_cap_earnings_exemption == monthly, year + rate = parameters.hmrc.minimum_wage.non_apprentice.amounts[-1] + if year >= 2017: + assert monthly == np.floor(rate * 16 * 52 / 12), (year, rate) CHILDCARE_LIMIT_CROSSING = ( @@ -346,10 +418,7 @@ def test_monotone_in_earnings(case, year): low = calculate(units, year) high = calculate(units, year, bump=bump) # More earnings never take away the benefit cap income test... - assert np.all( - high["uc_benefit_cap_earnings_threshold_met"] - >= low["uc_benefit_cap_earnings_threshold_met"] - ), (bump, units) + assert np.all(high["income_test"] >= low["income_test"]), (bump, units) # ...and never bring a family within the childcare earnings limit. Where # the minimum income floor applies, the floor stands in for gross # earnings while the person's actual tax and NI are still deducted, so @@ -376,6 +445,15 @@ def formula(benunit, period, parameters): earned_income = benunit("uc_earned_income", period) return (uc > 0) & (earned_income <= p.income_limit.universal_credit) + def earned_income_after_work_allowance(benunit, period): + # uc_earned_income as main computed it before #1986. + claimants = benunit.members("is_uc_assessed_claimant", period) + earned_income = add_for_members( + benunit, period, ["uc_individual_earned_income"], claimants + ) + work_allowance = benunit("uc_work_allowance", period) + return max_(0, earned_income - work_allowance) + class uc_earned_income_before_work_allowance(Variable): # Only the local CTR schemes read this variable apart from # uc_earned_income and the childcare criterion, so swapping it for the @@ -387,9 +465,7 @@ class uc_earned_income_before_work_allowance(Variable): unit = GBP def formula(benunit, period, parameters): - earned_income = add(benunit, period, ["uc_individual_earned_income"]) - work_allowance = benunit("uc_work_allowance", period) - return max_(0, earned_income - work_allowance) + return earned_income_after_work_allowance(benunit, period) class uc_earned_income(Variable): value_type = float @@ -399,9 +475,7 @@ class uc_earned_income(Variable): unit = GBP def formula(benunit, period, parameters): - earned_income = add(benunit, period, ["uc_individual_earned_income"]) - work_allowance = benunit("uc_work_allowance", period) - return max_(0, earned_income - work_allowance) + return earned_income_after_work_allowance(benunit, period) system = simulation.tax_benefit_system system.update_variable( diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index fe9c64ca20..981c3c29f3 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -33,12 +33,13 @@ s. 35), which raises the tax a person pays on their earnings and so changes the deduction from them (see test_uc_earnings_deductions_properties.py). The award after the benefit cap is compared only where the cap exemption is -also unchanged. The cap's earnings exception now nets only the tax on -non-savings, non-dividend income off earnings (PolicyEngine/policyengine-uk#1986), -so savings and dividend income leave it alone, but property income is taxed in -that same band and can still switch the cap on. -test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception pins -the dividend case. +also unchanged. The cap's earnings exception now nets only each claimant's +own tax and NI on their earnings off them (PolicyEngine/policyengine-uk#1986), +so interest, dividends and rent leave its earned income alone; the exemption +also turns on awards other than Universal Credit, such as Housing Benefit. +test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception and +test_tax_on_rent_does_not_remove_the_benefit_cap_earnings_exception pin the +dividend and property cases. """ import numpy as np @@ -358,3 +359,37 @@ def test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception(): assert with_dividends["universal_credit"][0] == pytest.approx( without["universal_credit"][0], abs=0.01 ) + + +def test_tax_on_rent_does_not_remove_the_benefit_cap_earnings_exception(): + # The dividend case above with 10,000 of property income instead. Rent + # is taxed as non-savings income, in the same band as earnings, but the + # earnings are the lowest slice of it and sit within the personal + # allowance, so no tax is paid in respect of the employment (reg. + # 55(5)(b)). Earned income stays 11,000, over the 2026-27 threshold of + # 881 x 12 = 10,572, and the exception (reg. 82(1)(a)) still applies. + unit = dict( + ages=[30], + children=[], + tenure="RENT_FROM_COUNCIL", + region="LONDON", + rent=20_000.0, + capital={source: 0.0 for source in CAPITAL_SOURCES}, + main_residence_value=0.0, + reported_capital=-1.0, + earnings=11_000.0, + private_pension_income=0.0, + capital_income={ + "savings_interest_income": 0.0, + "dividend_income": 0.0, + "property_income": 10_000.0, + }, + recipient=0, + ) + with_rent = calculate([unit], 2026, marriage_allowance=False) + without = calculate([unit], 2026, income_scale=0.0, marriage_allowance=False) + assert without["is_benefit_cap_exempt"][0] + assert with_rent["is_benefit_cap_exempt"][0] + assert with_rent["universal_credit"][0] == pytest.approx( + without["universal_credit"][0], abs=0.01 + ) diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_earnings_threshold.py b/policyengine_uk/variables/gov/dwp/benefit_cap_earnings_threshold.py deleted file mode 100644 index ba4505b946..0000000000 --- a/policyengine_uk/variables/gov/dwp/benefit_cap_earnings_threshold.py +++ /dev/null @@ -1,49 +0,0 @@ -from policyengine_uk.model_api import * - - -class benefit_cap_earnings_threshold(Variable): - value_type = float - entity = BenUnit - label = "Benefit cap earnings exception threshold" - documentation = ( - "The earned income, over a year, at or above which a Universal Credit " - "award is excepted from the benefit cap: twelve times the monthly " - "amount in the regulations. Since 1 April 2017 the monthly amount is " - "16 hours a week at the national living wage rate, times 52 and " - "divided by 12, with any fraction of a pound disregarded. Before " - "then it was 430 a month." - ) - definition_period = YEAR - unit = GBP - reference = [ - dict( - title="Universal Credit Regulations 2013 reg. 82(1)(a)", - href="https://www.legislation.gov.uk/uksi/2013/376/regulation/82", - ), - dict( - title="Universal Credit Regulations 2013 reg. 6(1A)(za)", - href="https://www.legislation.gov.uk/uksi/2013/376/regulation/6", - ), - dict( - title="National Minimum Wage Regulations 2015 reg. 4", - href="https://www.legislation.gov.uk/uksi/2015/621/regulation/4", - ), - ] - - def formula(benunit, period, parameters): - p = parameters(period).gov - exception = p.dwp.universal_credit.benefit_cap.earnings_exception - # Reg. 82(1)(a) uses "the hourly rate set out in regulation 4 of the - # National Minimum Wage Regulations": the single national living wage - # rate, whatever the claimant's age. It is the top age band of the - # model's schedule. - hourly_rate = p.hmrc.minimum_wage.non_apprentice.amounts[-1] - monthly_pay = ( - hourly_rate * exception.weekly_hours * WEEKS_IN_YEAR / MONTHS_IN_YEAR - ) - # Reg. 6(1A)(za) disregards a fraction of a pound in the amount - # calculated for reg. 82(1)(a): 12.21 x 16 x 52 / 12 = 846.56 is 846. - monthly_amount = exception.monthly_amount + np.floor(monthly_pay) - # Each monthly assessment period is tested against the monthly - # amount; over a year of level earnings that is twelve times it. - return np.ones(benunit.count) * monthly_amount * MONTHS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py index 45019d5150..7dc13bd4d0 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py @@ -6,7 +6,10 @@ class is_benefit_cap_exempt_earnings(Variable): entity = BenUnit label = "Whether exempt from the benefits cap because of earnings" definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/82" + reference = [ + "https://www.legislation.gov.uk/uksi/2013/376/regulation/82", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/75E", + ] def formula(benunit, period, parameters): # Regulation 82 of the Universal Credit Regulations 2013 excepts a @@ -15,31 +18,30 @@ def formula(benunit, period, parameters): # Living Wage. Earned income here is employed and self-employed # earnings net of the income tax, National Insurance and relievable # pension contributions attributable to those earnings (reg. 55(5), - # reg. 57), not net of total tax on all income. + # reg. 57), not net of total tax on all income + # (benefit_cap_earned_income). Each claimant's own deductions come off + # their own earnings only: tax on pensions, property, savings and + # dividends is not deducted, nor are voluntary Class 3 contributions, + # nor contributions paid after 75, which are not relievable. Income + # treated as earned under the minimum income floor does not count + # (reg. 82(4)). # # Reg. 82(1)(a) tests "the claimant's earned income or, if the # claimant is a member of a couple, the couple's combined earned # income": a dependant's earnings do not count. - # - # Only earned income is read, not the Universal Credit award itself, - # to avoid a circular dependency with the cap. - person = benunit.members - claimant = person("is_uc_assessed_claimant", period) - earnings = person("employment_income", period) + person( - "self_employment_income", period - ) - # earned_income_tax is the tax on non-savings, non-dividend income, - # which excludes property, savings and dividend income. It is the - # closest available measure of tax on earnings; it still includes tax - # on private and state pension income, which is the approximation we - # accept here (households with pension income are generally already - # exempt through the state pension age exception). - deductions = ( - person("earned_income_tax", period) - + person("national_insurance", period) - + person("pension_contributions", period) - ) - net_earnings = max_(0, benunit.sum((earnings - deductions) * claimant)) + net_earnings = benunit("benefit_cap_earned_income", period) monthly_threshold = parameters(period).gov.dwp.benefit_cap_earnings_exemption - return net_earnings >= monthly_threshold * MONTHS_IN_YEAR + meets_earnings_test = net_earnings >= monthly_threshold * MONTHS_IN_YEAR + + # Reg. 82(1) excepts "an award of universal credit". It does not + # except a Housing Benefit award, whose cap has its own exception for + # work: entitlement to Working Tax Credit (Housing Benefit Regulations + # 2006 reg. 75E, in is_benefit_cap_exempt_health_disability). The + # model caps both awards with one reduction, so where the benefit + # unit has Housing Benefit and no Universal Credit award this + # exception does not apply. Both awards are read before the cap, to + # avoid a circular dependency with it. + uc_award = benunit("is_uc_entitled", period) + housing_benefit_award = benunit("housing_benefit_pre_benefit_cap", period) > 0 + return meets_earnings_test & (uc_award | ~housing_benefit_award) diff --git a/policyengine_uk/variables/gov/dwp/uc_benefit_cap_earnings_threshold_met.py b/policyengine_uk/variables/gov/dwp/uc_benefit_cap_earnings_threshold_met.py deleted file mode 100644 index 1536e3f4f5..0000000000 --- a/policyengine_uk/variables/gov/dwp/uc_benefit_cap_earnings_threshold_met.py +++ /dev/null @@ -1,23 +0,0 @@ -from policyengine_uk.model_api import * - - -class uc_benefit_cap_earnings_threshold_met(Variable): - value_type = bool - entity = BenUnit - label = "Earned income reaches the Universal Credit benefit cap earnings threshold" - documentation = ( - "Whether the claimant's earned income, or a couple's combined earned " - "income, is at least the pay for 16 hours a week at the national " - "living wage (UC Regs 2013 reg. 82(1)(a)). The benefit cap earnings " - "exception also needs an award of Universal Credit." - ) - definition_period = YEAR - reference = dict( - title="Universal Credit Regulations 2013 reg. 82(1)(a)", - href="https://www.legislation.gov.uk/uksi/2013/376/regulation/82", - ) - - def formula(benunit, period, parameters): - earned_income = benunit("benefit_cap_earned_income", period) - threshold = benunit("benefit_cap_earnings_threshold", period) - return earned_income >= threshold From a609be44eb2e7d6a9b31d2665424ffc8df8190dc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:45:42 -0400 Subject: [PATCH 10/11] Pin miscellaneous income as earned for both the award and the cap (#1961) The cap's earned income shares uc_individual_earned_income_before_mif with the UC award, which counts miscellaneous_income as earned. The FRS input mixes paid work with royalties, trust income and overseas pensions, which reg. 66(1) treats as unearned; #1961 tracks the split. A YAML case pins the shared treatment so the #1961 fix moves the award and the cap together. Also corrects a test comment that still described a fixed 10,152 threshold. Co-Authored-By: Claude Opus 5.5 --- .../claims_as_single_person.yaml | 4 ++-- .../benefit_cap_earnings_exception.yaml | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml index 4176316e7c..9495648510 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml @@ -707,8 +707,8 @@ # "Couple" in the cap includes a single claimant's partner (reg. 78(2)). # 11,000 a year (916.67 a month) exceeds 16 hours at the national living # wage of 12.71 (NMW Regs 2015 reg. 4 from 1 April 2026): - # 12.71 x 16 x 52 / 12 = 881.23 a month. The model uses a fixed - # 10,152 a year; 11,000 clears both. + # 12.71 x 16 x 52 / 12 = 881.23 a month, 881 in whole pounds (reg. + # 6(1A)(za); gov.dwp.benefit_cap_earnings_exemption). is_benefit_cap_exempt_earnings: true - name: Sch 4 para 28(2) - a renter under 35 claiming as a single person is a specified renter, whatever the partner's age diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml index fce1a0f772..22fcb00b3f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml @@ -442,3 +442,25 @@ is_benefit_cap_exempt_earnings: false is_benefit_cap_exempt: false benefit_cap: 25_323 + +# --- Miscellaneous income: one definition for the award and the cap --- + +- name: Miscellaneous income counts as earned for the cap, as it does for the award + # The UC award counts miscellaneous_income as earned income + # (uc_individual_earned_income_before_mif), and the cap shares that + # definition. In the FRS the input mixes paid work, which is earned (reg. + # 52(a)(iii)), with royalties, trust income and overseas pensions, which + # reg. 66(1) treats as unearned; PolicyEngine/policyengine-uk#1961 tracks + # the split. This case pins today's shared treatment, so the #1961 fix + # moves the award and the cap together. 11,000 is under the personal + # allowance and carries no NI. + period: 2026 + input: + people: + person: + age: 30 + miscellaneous_income: 11_000 + output: + uc_earned_income_before_work_allowance: 11_000 + benefit_cap_earned_income: 11_000 + is_benefit_cap_exempt_earnings: true From dbcd505b81b14ad1d7214aac59c9a6319753b8d0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:45:51 -0400 Subject: [PATCH 11/11] Assert the exception in the age 74 and 75 pension contribution cases Each case now checks is_benefit_cap_exempt_earnings as well as the earned income, so a measure that deducts contributions paid after 75 (not relievable, Finance Act 2004 s. 188(3)(a)) fails the exception test too. Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml index 22fcb00b3f..d122444b52 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception.yaml @@ -235,6 +235,7 @@ employee_pension_contributions: 1_000 output: benefit_cap_earned_income: 10_000 + is_benefit_cap_exempt_earnings: false # 10,000 < 881 x 12 = 10,572 - name: Pension contributions paid at 75 are not deducted # Finance Act 2004 s. 188(3)(a): contributions paid once the person has @@ -251,6 +252,7 @@ employee_pension_contributions: 1_000 output: benefit_cap_earned_income: 11_000 + is_benefit_cap_exempt_earnings: true # 11,000 >= 10,572 - name: Voluntary Class 3 contributions are not deducted period: 2026