From 4e287f69e3cb7aba800b9e4d2b3595774cd65cde Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 20:07:31 -0400 Subject: [PATCH 01/23] End income-related ESA on its abolition dates (SI 2025/1148 art. 3A) Add gov.dwp.ESA.income.awards_payable, a 1/0 flag per jurisdiction with fiscal_year_blend, so income-related ESA awards end on 1 July 2026 in Great Britain and 1 October 2026 in Northern Ireland. esa_income pays each award for esa_income_payable_share of the year, and esa_income_report_closed ends the report once none of the year is payable. esa_income_appointee_saving keeps an award saved under art. 3A(2). own_report_is_paid reads the same closure as the award. Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/ESA/income/awards_payable.yaml | 56 +++++++++++++++++++ .../variables/gov/dwp/_legacy_award_payee.py | 9 ++- .../variables/gov/dwp/esa_income.py | 15 +++-- .../gov/dwp/esa_income_appointee_saving.py | 28 ++++++++++ .../gov/dwp/esa_income_payable_share.py | 43 ++++++++++++++ .../gov/dwp/esa_income_report_closed.py | 16 +++++- 6 files changed, 157 insertions(+), 10 deletions(-) create mode 100644 policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml create mode 100644 policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py create mode 100644 policyengine_uk/variables/gov/dwp/esa_income_payable_share.py diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml new file mode 100644 index 0000000000..96d4692202 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml @@ -0,0 +1,56 @@ +description: >- + Whether old style income-related Employment and Support Allowance awards are + still payable (1) or abolished (0). The Welfare Reform Act 2012 abolition of + income-related ESA came into force for every remaining award on 1 July 2026 + in Great Britain and 1 October 2026 in Northern Ireland, except awards saved + for an appointee (esa_income_appointee_saving). A contributory allowance in + the same award continues as new style ESA. Annual formulas read parameters + once, at the start of the model year, after fiscal-year conversion. + Parameters normally sample 30 April, but these numeric flags use + fiscal_year_blend to take the day-weighted average from 6 April to 5 April, + so 2026-27 carries 86 of its 365 days in Great Britain and 178 in Northern + Ireland. In a Simulation reform dictionary a bare year overrides the whole + fiscal year, while a key dated inside the year (for example 2026-07-01) + leaves that model year's value unchanged. Use whole-year values or a date + range for annual reform scenarios. +great_britain: + description: Whether old style income-related Employment and Support Allowance awards are still payable in Great Britain. SI 2025/1148 art. 3A brought section 33(1)(b) of the Welfare Reform Act 2012 into force for them on 1 July 2026. + values: + 2008-10-27: 1 + 2026-07-01: 0 + metadata: + label: Income-related ESA awards payable in Great Britain + unit: /1 + period: year + fiscal_year_blend: true + reference: + - title: SI 2025/1148 art. 3A (inserted by SI 2026/409 art. 2(4)) + href: https://www.legislation.gov.uk/uksi/2025/1148/article/3A + - title: SI 2026/409, The Welfare Reform Act 2012 (Commencement No. 35) (Abolition of Benefits) (Amendment) Order 2026 + href: https://www.legislation.gov.uk/uksi/2026/409/made + - title: SI 2013/983 art. 4(1) (the amending provisions) + href: https://www.legislation.gov.uk/uksi/2013/983/article/4 + - title: Welfare Reform Act 2012 s. 33(1)(b) + href: https://www.legislation.gov.uk/ukpga/2012/5/section/33 +northern_ireland: + description: Whether old style income-related Employment and Support Allowance awards are still payable in Northern Ireland. SR 2025/176 art. 3A brought Article 39(1)(b) of the Welfare Reform (Northern Ireland) Order 2015 into operation for them on 1 October 2026. + values: + 2008-10-27: 1 + 2026-10-01: 0 + metadata: + label: Income-related ESA awards payable in Northern Ireland + unit: /1 + period: year + fiscal_year_blend: true + reference: + - title: SR 2025/176 art. 3A (inserted by SR 2026/115 art. 2(4)) + href: https://www.legislation.gov.uk/nisr/2025/176/article/3A + - title: SR 2026/115, The Welfare Reform (Northern Ireland) Order 2015 (Commencement No. 18) (Abolition of Benefits) (Amendment) Order (Northern Ireland) 2026 + href: https://www.legislation.gov.uk/nisr/2026/115/made + - title: SR 2017/190 art. 6(1) (the amending provisions) + href: https://www.legislation.gov.uk/nisr/2017/190/article/6 + - title: Welfare Reform (Northern Ireland) Order 2015 art. 39(1)(b) + href: https://www.legislation.gov.uk/nisi/2015/2006/article/39 +metadata: + label: Income-related ESA awards payable + economy: false diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index cee5d2c28a..0013024244 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -1,6 +1,7 @@ """Who is "on" a legacy income-related award.""" from policyengine_uk.model_api import * +from policyengine_uk.utils.benefit_unit import REPORT_CLOSED def is_payee_of_couple_award(person, period, reported, can_claim=None, couple=None): @@ -53,8 +54,10 @@ def own_report_is_paid(person, period, award, works, capital_limit): cannot stand in for theirs, because when the claimant or partner reports an award it tests only them. - Their own legacy benefits must remain open under the existing annual - proxy (person_legacy_benefits_closed). A couple's closure does not end + Their own award must remain open (REPORT_CLOSED: for income-based JSA, + their legacy benefits under the existing annual proxy, + person_legacy_benefits_closed; for income-related ESA, also its + abolition, esa_income_report_closed). A couple's closure does not end this separate claim. The benefit unit's award must also be in payment: positive, or nil while its work/capital screen fails and the award is not neutralised. A directly entered or reformed nil removes status while @@ -70,7 +73,7 @@ def own_report_is_paid(person, period, award, works, capital_limit): > benunit(f"{award}_tariff_income", period) ) ) - closed = person("person_legacy_benefits_closed", period) + closed = person(REPORT_CLOSED[award], period) removed = person.simulation.tax_benefit_system.get_variable(award).is_neutralized paid = benunit(award, period) > 0 screen_fails = ~benunit(f"{award}_eligible", period) diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index f8bd1ff885..56066bde6a 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -33,7 +33,12 @@ def income_related_esa_award_on_reports(benunit, period, reported, members=None) own_claim = ~couple & ~works & benunit.project(capital <= limit) tariff = benunit.project(benunit("esa_income_tariff_income", period)) own_awards = where(own_claim, max_(0, reported - tariff), 0) - return couple_award + benunit.sum(own_awards) + # Each award is paid for the share of the year before the abolition of + # income-related ESA reached it (SI 2025/1148 art. 3A; SR 2025/176 art. + # 3A). The couple's award keeps the saving of a member who reports it. + share = person("esa_income_payable_share", period) + couple_share = benunit.max(where(couple & (reported > 0), share, 0)) + return couple_award * couple_share + benunit.sum(own_awards * share) class esa_income(Variable): @@ -44,9 +49,11 @@ class esa_income(Variable): "Reported income-related ESA screened through bounded capital and " "remunerative work tests (esa_income_eligible). Reports whose award " "has ended are left out (esa_income_report_closed): the owner's " - "legacy benefits have closed, or the owner is the claimant or " - "partner of a family that claims Universal Credit. " - "This is not a full entitlement model." + "legacy benefits have closed, the owner is the claimant or " + "partner of a family that claims Universal Credit, or the abolition " + "of income-related ESA has ended the award. Each award is paid for " + "the share of the year before that abolition " + "(esa_income_payable_share). This is not a full entitlement model." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py new file mode 100644 index 0000000000..d5c9a00343 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class esa_income_appointee_saving(Variable): + value_type = bool + entity = Person + label = "Income-related ESA award saved from abolition for an appointee" + documentation = ( + "Whether the old style income-related ESA award on this person's " + "report is saved from the abolition of income-related ESA, so it stays " + "payable after 1 July 2026 (1 October 2026 in Northern Ireland). SI " + "2025/1148 art. 3A(2) saves an award where, immediately before that " + "date, someone is appointed under reg 57 of the Claims and Payments " + "Regulations 2013 to act for a claimant who is unable to act, or where " + "the Secretary of State decided in the six months ending on that date " + "that such an appointment was likely to be needed. SR 2025/176 art. " + "3A(2) does the same in Northern Ireland, with the Department and reg " + "52 of the Northern Ireland Claims and Payments Regulations 2016. A " + "Universal Credit claim still ends a saved award (art. 3A(3); " + "esa_income_report_closed). No dataset identifies appointees, so this " + "defaults to false and dataset runs end every unsaved award." + ) + definition_period = YEAR + default_value = False + reference = ( + "https://www.legislation.gov.uk/uksi/2025/1148/article/3A", + "https://www.legislation.gov.uk/nisr/2025/176/article/3A", + ) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py b/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py new file mode 100644 index 0000000000..d5d547555c --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * + + +class esa_income_payable_share(Variable): + value_type = float + entity = Person + label = "Share of the year an income-related ESA award stays payable" + documentation = ( + "Share of the year for which the old style income-related ESA award " + "on this person's report stays payable. SI 2025/1148 art. 3A brought " + "the abolition of income-related ESA (Welfare Reform Act 2012 " + "s.33(1)(b)) into force for every remaining award on 1 July 2026, and " + "SR 2025/176 art. 3A brought it into operation in Northern Ireland on " + "1 October 2026. So this is the share of the fiscal year before the " + "household's date (gov.dwp.ESA.income.awards_payable): 1 up to " + "2025-26; in 2026-27, 86 of 365 days in Great Britain and 178 of 365 " + "in Northern Ireland; and 0 from 2027-28. An award saved for an " + "appointee (esa_income_appointee_saving) has a share of 1 in every " + "year. esa_income pays each award for this share of the year, and " + "esa_income_report_closed ends the report in a year where it is 0. A " + "contributory allowance continues as new style ESA (esa_contrib)." + ) + definition_period = YEAR + unit = "/1" + reference = ( + "https://www.legislation.gov.uk/uksi/2025/1148/article/3A", + "https://www.legislation.gov.uk/nisr/2025/176/article/3A", + "https://www.legislation.gov.uk/ukpga/2012/5/section/33", + "https://www.legislation.gov.uk/nisi/2015/2006/article/39", + ) + + def formula(person, period, parameters): + payable = parameters(period).gov.dwp.ESA.income.awards_payable + country = person.household("country", period) + # Great Britain's date applies everywhere but Northern Ireland, + # including an unknown country. + share = where( + country == country.possible_values.NORTHERN_IRELAND, + payable.northern_ireland, + payable.great_britain, + ) + # Art. 3A(2) saves an award for an appointee in every year. + return where(person("esa_income_appointee_saving", period), 1, share) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py index dd087317c8..f241eb97da 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py @@ -28,7 +28,14 @@ class esa_income_report_closed(Variable): "part of the family's Universal Credit claim, so the claim does not " "end their award. The model applies this in every year and area: the " "postcode districts named in the commencement orders are not " - "modelled." + "modelled. Every other award ends when the abolition reaches it on " + "1 July 2026, or 1 October 2026 in Northern Ireland (SI 2025/1148 " + "art. 3A; SR 2025/176 art. 3A), so the report is closed in a year " + "where none of it is payable (esa_income_payable_share is 0, from " + "2027-28). In 2026-27 the report stays open and esa_income pays it " + "for the share of the year before that date. An award saved for an " + "appointee (esa_income_appointee_saving) stays open, but a Universal " + "Credit claim or the legacy closure still ends it (art. 3A(3))." ) definition_period = YEAR reference = ( @@ -43,6 +50,9 @@ class esa_income_report_closed(Variable): def formula(person, period, parameters): claims_uc = person.benunit("claims_universal_credit", period) - return person("person_legacy_benefits_closed", period) | ( - person("is_claimant_or_partner", period) & claims_uc + abolished = person("esa_income_payable_share", period) <= 0 + return ( + person("person_legacy_benefits_closed", period) + | (person("is_claimant_or_partner", period) & claims_uc) + | abolished ) From d63086c75cfe5f3a7b71ca13659eddeecb9faa25 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 20:27:37 -0400 Subject: [PATCH 02/23] Test the income-related ESA abolition and stop an ended award holding a UC claim Hand-worked YAML cases for 2025-26, 2026-27 (86/365 in Great Britain, 178/365 in Northern Ireland) and 2027-28, the appointee saving, a saved award with a Universal Credit claim, contributory-only and mixed awards, a family the legacy closure already moved and a non-dependent adult's own award. forgoes_universal_credit_for_tax_free_childcare counts an ESA report as a legacy award a claim would end only while some of the year is payable. Co-Authored-By: Claude Opus 5.5 --- .../esa_income/esa_income_abolition.yaml | 500 ++++++++++++++++++ ...iversal_credit_for_tax_free_childcare.yaml | 48 ++ ...universal_credit_for_tax_free_childcare.py | 9 +- 3 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml new file mode 100644 index 0000000000..ecfda8debe --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml @@ -0,0 +1,500 @@ +# The abolition of income-related ESA (Welfare Reform Act 2012 s.33(1)(b); +# NI: Welfare Reform (NI) Order 2015 art. 39(1)(b)) comes into force for every +# remaining old style ESA award on 1 July 2026 in Great Britain (SI 2025/1148 +# art. 3A(1), inserted by SI 2026/409) and on 1 October 2026 in Northern +# Ireland (SR 2025/176 art. 3A(1), inserted by SR 2026/115). +# +# Each model year is the fiscal year from 6 April to 5 April; 2026-27 has 365 +# days. A Great Britain award is payable for the 86 days from 6 April to 30 +# June 2026 (25 + 31 + 30) and a Northern Ireland award for the 178 days from +# 6 April to 30 September 2026 (25 + 31 + 30 + 31 + 31 + 30). So 2026-27 pays +# the annual award times 86/365 = 0.235616 or 178/365 = 0.487671, and +# 2027-28 pays nothing. +# +# Art. 3A(2) saves an award for an appointee (esa_income_appointee_saving). +# Art. 3A(3): a Universal Credit claim still ends a saved award. A +# contributory allowance continues as new style ESA (esa_contrib). +# +# Unless a case says otherwise, nobody has capital, so there is no tariff +# income and the annual award is the reported amount, and the family does +# not claim Universal Credit. + +- name: Great Britain 2025-26, before the abolition, pays the whole award + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + output: + esa_income_payable_share: 1 + esa_income_report_closed: false + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: true + +- name: Great Britain 2026-27 pays the award until 30 June 2026 (86 of 365 days) + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + output: + esa_income_payable_share: 0.235616 + esa_income_report_closed: false + esa_income_eligible: true + # 5,000 x 86 / 365 = 1,178.08 + esa_income: 1_178.08 + claimant_or_partner_esa_income: 1_178.08 + is_on_income_related_esa: true + +- name: Great Britain 2027-28 pays no income-related ESA + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + output: + esa_income_payable_share: 0 + esa_income_report_closed: true + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: false + # The family does not claim Universal Credit, so nothing replaces it. + claims_universal_credit: false + universal_credit: 0 + +- name: Scotland follows the Great Britain date + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: SCOTLAND + output: + esa_income_payable_share: 0.235616 + esa_income: 1_178.08 + +- name: Wales follows the Great Britain date + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: WALES + output: + esa_income_payable_share: 0.235616 + esa_income: 1_178.08 + +- name: Northern Ireland 2025-26 pays the whole award + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: NORTHERN_IRELAND + output: + esa_income_payable_share: 1 + esa_income: 5_000 + +- name: Northern Ireland 2026-27 pays the award until 30 September 2026 (178 of 365 days) + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: NORTHERN_IRELAND + output: + esa_income_payable_share: 0.487671 + esa_income_report_closed: false + # 5,000 x 178 / 365 = 2,438.36 + esa_income: 2_438.36 + claimant_or_partner_esa_income: 2_438.36 + is_on_income_related_esa: true + +- name: Northern Ireland 2027-28 pays no income-related ESA + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: NORTHERN_IRELAND + output: + esa_income_payable_share: 0 + esa_income_report_closed: true + esa_income: 0 + is_on_income_related_esa: false + +- name: The 2026-27 share applies to the award after tariff income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + savings: 7_000 + output: + # ESA Regs 2008 reg 118: £1 a week for each £250 of capital above £6,000, + # so £7,000 gives £4 a week, 4 x 52 = £208 a year. The award is + # (5,000 - 208) x 86 / 365 = 4,792 x 86 / 365 = 1,129.07. + esa_income_tariff_income: 208 + esa_income: 1_129.07 + +- name: An award saved for an appointee stays payable in Great Britain in 2027-28 + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + esa_income_appointee_saving: true + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + output: + # Art. 3A(2) treats the abolition as not in force for this award. + esa_income_payable_share: 1 + esa_income_report_closed: false + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: true + +- name: An award saved for an appointee is payable for all of 2026-27 in Northern Ireland + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + esa_income_appointee_saving: true + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: NORTHERN_IRELAND + output: + # SR 2025/176 art. 3A(2). + esa_income_payable_share: 1 + esa_income: 5_000 + +- name: A Universal Credit claim still ends an award saved for an appointee + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + esa_income_appointee_saving: true + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: LONDON + output: + # Art. 3A(3): the saving does not stop a claim for Universal Credit from + # bringing the abolition into force. + esa_income_payable_share: 1 + claims_universal_credit: true + esa_income_report_closed: true + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: false + +- name: A contributory-only award continues in full in 2027-28 + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_contrib_reported: 4_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + output: + # The abolition removes only the income-related allowance; a + # contributory allowance is new style ESA (SI 2025/1148 art. 2). + esa_contrib: 4_000 + esa_income: 0 + esa: 4_000 + +- name: A mixed award keeps its contributory allowance and pays the income-related part to 30 June 2026 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_contrib_reported: 4_000 + esa_income_reported: 1_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + output: + esa_contrib: 4_000 + # 1,000 x 86 / 365 = 235.62 + esa_income: 235.62 + # 4,000 + 235.62 + esa: 4_235.62 + +- name: A mixed award keeps only its contributory allowance in 2027-28 + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_contrib_reported: 4_000 + esa_income_reported: 1_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + output: + esa_contrib: 4_000 + esa_income: 0 + esa: 4_000 + +- name: A family the legacy closure already moved has no income-related ESA in 2025-26 + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + child_tax_credit_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant] + region: LONDON + output: + # Tax credits closed from 6 April 2025, and DWP's migration notice ends + # all the family's legacy awards (SI 2014/1230 reg 46(1)(a)), before the + # abolition reaches the award. + legacy_benefits_closed: true + esa_income_payable_share: 1 + esa_income_report_closed: true + esa_income: 0 + is_on_income_related_esa: false + +- name: A family the legacy closure already moved stays without income-related ESA in 2026-27 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + child_tax_credit_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant] + region: LONDON + output: + legacy_benefits_closed: true + esa_income_report_closed: true + esa_income: 0 + +- name: A non-dependent adult's own award is paid to 30 June 2026 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 2_000 + benunits: + benunit: + members: [claimant, other] + would_claim_uc: false + households: + household: + members: [claimant, other] + region: LONDON + output: + esa_income_report_closed: [false, false] + # 2,000 x 86 / 365 = 471.23, on the adult's own claim. + esa_income: 471.23 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, true] + +- name: A non-dependent adult's own award has ended by 2027-28 + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 2_000 + benunits: + benunit: + members: [claimant, other] + would_claim_uc: false + households: + household: + members: [claimant, other] + region: LONDON + output: + esa_income_report_closed: [true, true] + esa_income: 0 + # Their award has ended, so they are not on income-related ESA + # (own_report_is_paid reads esa_income_report_closed). + is_on_income_related_esa: [false, false] + +- name: A couple's award saved for the claimant who reports it stays payable in 2027-28 + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + esa_income_appointee_saving: true + partner: + age: 38 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + households: + household: + members: [claimant, partner] + region: LONDON + output: + esa_income_payable_share: [1, 0] + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: [true, false] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/forgoes_universal_credit_for_tax_free_childcare.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/forgoes_universal_credit_for_tax_free_childcare.yaml index d9594c3cf4..d375159eca 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/forgoes_universal_credit_for_tax_free_childcare.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/forgoes_universal_credit_for_tax_free_childcare.yaml @@ -82,3 +82,51 @@ output: forgoes_universal_credit_for_tax_free_childcare: false universal_credit_pre_benefit_cap: 0 + +# Income-related ESA ends on 1 July 2026 in Great Britain whether or not the +# family claims (SI 2025/1148 art. 3A(1)), so from 2027-28 an ESA report no +# longer holds the family to its claim. An award saved for an appointee (art. +# 3A(2)) still ends on a claim (art. 3A(3)), so it does. +- name: An income-related ESA award its abolition has ended ends either way, so the award is still forgone + period: 2027 + absolute_error_margin: 0.01 + input: + uc_maximum_amount: 21_993 + uc_income_reduction: 20_000 + tax_free_childcare_without_universal_credit: 2_400 + esa_income_reported: 5_000 + legacy_benefits_closed: false + output: + esa_income_payable_share: 0 + forgoes_universal_credit_for_tax_free_childcare: true + universal_credit_pre_benefit_cap: 0 + +- name: A saved income-related ESA award keeps the claim after the abolition + period: 2027 + absolute_error_margin: 0.01 + input: + uc_maximum_amount: 21_993 + uc_income_reduction: 20_000 + tax_free_childcare_without_universal_credit: 2_400 + esa_income_reported: 5_000 + esa_income_appointee_saving: true + legacy_benefits_closed: false + output: + esa_income_payable_share: 1 + forgoes_universal_credit_for_tax_free_childcare: false + universal_credit_pre_benefit_cap: 1_993 + +- name: In 2026-27 an income-related ESA award is payable until 30 June, so it keeps the claim + period: 2026 + absolute_error_margin: 0.01 + input: + uc_maximum_amount: 21_993 + uc_income_reduction: 20_000 + tax_free_childcare_without_universal_credit: 2_400 + esa_income_reported: 5_000 + legacy_benefits_closed: false + output: + # 6 April to 30 June 2026 is 86 of the 365 days of 2026-27. + esa_income_payable_share: 0.235616 + forgoes_universal_credit_for_tax_free_childcare: false + universal_credit_pre_benefit_cap: 1_993 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/forgoes_universal_credit_for_tax_free_childcare.py b/policyengine_uk/variables/gov/dwp/universal_credit/forgoes_universal_credit_for_tax_free_childcare.py index d01b928fc0..5adaacf861 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/forgoes_universal_credit_for_tax_free_childcare.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/forgoes_universal_credit_for_tax_free_childcare.py @@ -6,14 +6,15 @@ # Legacy awards a Universal Credit claim ends (SI 2014/1230 regs 8 and 46). # A family receiving one, until DWP closes it, keeps today's claim: not # claiming would keep that award as well as Tax-Free Childcare, which this -# comparison does not weigh. +# comparison does not weigh. Income-related ESA counts only while some of +# the year is payable (esa_income_payable_share): its abolition ends the +# award whether or not the family claims (SI 2025/1148 art. 3A). LEGACY_AWARDS = ( "child_tax_credit_reported", "working_tax_credit_reported", "housing_benefit_reported", "income_support_reported", "jsa_income_reported", - "esa_income_reported", ) @@ -76,6 +77,8 @@ def formula(benunit, period, parameters): * scp.qualifying_benefits.universal_credit ) top_up = add(benunit, period, ["tax_free_childcare_without_universal_credit"]) - legacy = add(benunit, period, LEGACY_AWARDS) > 0 + esa_payable = person("esa_income_payable_share", period) > 0 + esa_award = benunit.sum(person("esa_income_reported", period) * esa_payable) + legacy = (add(benunit, period, LEGACY_AWARDS) > 0) | (esa_award > 0) keeps_no_legacy_award = ~legacy | benunit("legacy_benefits_closed", period) return keeps_no_legacy_award & (award > 0) & (top_up > award + scp_from_claim) From 7c7bd93d956a8a2584ddc61f40c50e57b86f98cf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 21:21:05 -0400 Subject: [PATCH 03/23] Keep pre-existing ESA tests on their subject with the appointee saving Thirteen YAML cases in 2026-27 and 2027-28 test capital, work, passports, Housing Benefit regulations and the Pension Credit disregard on an income-related ESA award. The abolition now ends or shortens those awards, so each reporter gets esa_income_appointee_saving (SI 2025/1148 art. 3A(2)), which keeps the award payable all year; expected values are unchanged. The UC-claim property test saves every award, so 2027 still tests art. 3A(3). Add the abolition property tests. Co-Authored-By: Claude Opus 5.5 --- .../family/esa_income/esa_income_capital.yaml | 9 + .../esa_income_remunerative_work.yaml | 3 + .../council_tax_reduction_passports.yaml | 18 ++ ...using_benefit_pension_age_regulations.yaml | 6 + .../pension_credit_earnings_disregard.yaml | 6 + .../test_esa_income_abolition_properties.py | 297 ++++++++++++++++++ ...ncome_universal_credit_claim_properties.py | 9 +- 7 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 policyengine_uk/tests/test_esa_income_abolition_properties.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml index 8a01aa7799..6ad40f2aab 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml @@ -30,6 +30,9 @@ claimant: age: 40 esa_income_reported: 1_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true benunits: benunit: members: claimant @@ -50,6 +53,9 @@ claimant: age: 40 esa_income_reported: 1_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true benunits: benunit: members: claimant @@ -71,6 +77,9 @@ claimant: age: 40 esa_income_reported: 3_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true benunits: benunit: members: claimant diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml index 8d7978ab3c..964007f0d5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml @@ -1299,6 +1299,9 @@ age: 30 is_claimant_or_partner: true esa_income_reported: 5_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true partner: age: 30 is_claimant_or_partner: true diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/council_tax_reduction_passports.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/council_tax_reduction_passports.yaml index e36da6ed71..f806f0d93d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/council_tax_reduction_passports.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/council_tax_reduction_passports.yaml @@ -152,6 +152,9 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 4_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true benunits: applicant_unit: members: [applicant] @@ -178,6 +181,9 @@ is_claimant_or_partner: true current_education: NOT_IN_EDUCATION esa_income_reported: 4_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true other: age: 25 is_claimant_or_partner: false @@ -209,6 +215,9 @@ is_claimant_or_partner: true current_education: NOT_IN_EDUCATION esa_income_reported: 4_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true partner: age: 29 is_claimant_or_partner: true @@ -245,6 +254,9 @@ is_claimant_or_partner: true current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true partner: age: 29 is_claimant_or_partner: true @@ -290,11 +302,17 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 200 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true other: age: 25 is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 3_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true benunits: applicant_unit: members: [applicant] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_pension_age_regulations.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_pension_age_regulations.yaml index 86273eb805..81e2354de2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_pension_age_regulations.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_pension_age_regulations.yaml @@ -19,6 +19,9 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true benunits: benunit: members: [claimant, other] @@ -66,6 +69,9 @@ age: 50 is_claimant_or_partner: true esa_income_reported: 5_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true benunits: benunit: members: [claimant, partner] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/pension_credit_earnings_disregard.yaml index 417c6041ab..0023db9681 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/pension_credit_earnings_disregard.yaml @@ -24,6 +24,9 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true benunits: benunit: members: [claimant, other] @@ -82,6 +85,9 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # SI 2025/1148 art. 3A(2) saves this award, so it stays payable + # for the whole year after the 1 July 2026 abolition. + esa_income_appointee_saving: true benunits: benunit: members: [claimant, partner] diff --git a/policyengine_uk/tests/test_esa_income_abolition_properties.py b/policyengine_uk/tests/test_esa_income_abolition_properties.py new file mode 100644 index 0000000000..3d40492296 --- /dev/null +++ b/policyengine_uk/tests/test_esa_income_abolition_properties.py @@ -0,0 +1,297 @@ +"""Property-based tests for the abolition of income-related ESA (#2562). + +The abolition of income-related ESA (Welfare Reform Act 2012 s.33(1)(b)) +came into force for every remaining old style ESA award on 1 July 2026 in +Great Britain (SI 2025/1148 art. 3A, inserted by SI 2026/409) and on 1 +October 2026 in Northern Ireland (SR 2025/176 art. 3A, inserted by SR +2026/115). Art. 3A(2) saves an award for an appointee +(esa_income_appointee_saving); art. 3A(3) lets a Universal Credit claim end +a saved award. A contributory allowance continues as new style ESA. + +Invariants, for any generated population of working-age families in +2025-26, 2026-27 and 2027-28: + +1. Parameter: each fiscal year's gov.dwp.ESA.income.awards_payable value is + the share of 6 April to 5 April falling before the statutory date, counted + here from the two dates (1 before 2026-27, 0 after). +2. Bounds: 0 <= esa_income_payable_share <= 1, and it is 1 for every saved + member in every year. +3. Abolition: from 2027-28, a benefit unit with no saved member pays no + income-related ESA (esa_income and claimant_or_partner_esa_income are 0) + and none of its members is on it; and in every benefit unit, a member who + is neither the claimant nor the partner and has no saving is not on it. +4. Contributory ESA never changes: esa_contrib is the same with and without + the abolition, in every year. +5. Differential against the rule before the abolition (a reform keeping + every award payable in every year, as at origin/main): before 2026-27 + every output is identical; in every year, with no saved member, a benefit + unit's esa_income is exactly its jurisdiction's payable share of that + rule's; and the abolition never raises anyone's income-related ESA. +6. Monotonic: turning the saving on for any members never lowers a benefit + unit's esa_income or esa. +7. Status: a member who is neither the claimant nor the partner is on + income-related ESA only while the award on their report is open. +""" + +import datetime + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import CountryTaxBenefitSystem, Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +ABOLITION_DATES = { + "great_britain": datetime.date(2026, 7, 1), + "northern_ireland": datetime.date(2026, 10, 1), +} +PARAMETER = "gov.dwp.ESA.income.awards_payable" +KEEP_AWARDS_PAYABLE = { + f"{PARAMETER}.{jurisdiction}": {"2015-01-01.2040-12-31": 1} + for jurisdiction in ABOLITION_DATES +} +# 2025-26 is before both dates, 2026-27 holds both, 2027-28 is after both. +YEARS = (2025, 2026, 2027) +BEFORE = (2025,) +AFTER = (2027,) +REGIONS = ["LONDON", "NORTH_EAST", "SCOTLAND", "WALES", "NORTHERN_IRELAND"] +WORKING_AGE = st.integers(25, 60) +# "other" is an adult who is neither the claimant, the partner nor a young +# person they are responsible for, so they claim in their own right. +SHAPES = { + "single": ["claimant"], + "couple": ["claimant", "partner"], + "lone_parent": ["claimant", "child"], + "single_with_other": ["claimant", "other"], + "couple_with_other": ["claimant", "partner", "other"], +} +REPORT = st.sampled_from([0.0, 2_000.0, 6_000.0]) +BENUNIT_OUTPUTS = [ + "esa_income", + "claimant_or_partner_esa_income", + "esa_income_eligible", + "esa", + "claims_universal_credit", +] +PERSON_OUTPUTS = [ + "esa_contrib", + "esa_income_payable_share", + "esa_income_report_closed", + "is_on_income_related_esa", + "is_claimant_or_partner", +] + + +@st.composite +def families(draw): + roles = SHAPES[draw(st.sampled_from(sorted(SHAPES)))] + members = [] + for role in roles: + adult = role != "child" + members.append( + dict( + role=role, + age=draw(WORKING_AGE if adult else st.integers(0, 15)), + esa_income_reported=draw(REPORT) if adult else 0.0, + esa_contrib_reported=( + draw(st.sampled_from([0.0, 0.0, 4_000.0])) if adult else 0.0 + ), + employment_income=( + draw(st.sampled_from([0.0, 0.0, 4_000.0, 20_000.0])) + if adult + else 0.0 + ), + weekly_hours=draw(st.sampled_from([0, 10, 30])) if adult else 0, + saved=draw(st.booleans()) if adult else False, + ) + ) + return dict( + members=members, + region=draw(st.sampled_from(REGIONS)), + # Around the £6,000 tariff threshold and £16,000 limit. + savings=draw(st.sampled_from([0.0, 7_000.0, 20_000.0])), + would_claim_uc=draw(st.booleans()), + ) + + +def every_year(value): + return {year: value for year in YEARS} + + +def situation(units, saving=True): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, member in enumerate(unit["members"]): + name = f"p{i}_{j}" + # Set every input for every member, or it stops being an input + # for the rest of the simulation. + people[name] = { + "age": every_year(member["age"]), + "is_claimant_or_partner": every_year( + member["role"] in ("claimant", "partner") + ), + "current_education": every_year("NOT_IN_EDUCATION"), + "esa_income_reported": every_year(member["esa_income_reported"]), + "esa_contrib_reported": every_year(member["esa_contrib_reported"]), + "employment_income": every_year(member["employment_income"]), + "weekly_hours": every_year(member["weekly_hours"]), + "esa_income_appointee_saving": every_year(saving and member["saved"]), + } + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_uc": every_year(unit["would_claim_uc"]), + "claims_all_entitled_benefits": every_year(False), + } + households[f"h{i}"] = { + "members": names, + "region": every_year(unit["region"]), + "savings": every_year(unit["savings"]), + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(units, reform=None, saving=True): + sim = Simulation(situation=situation(units, saving), reform=reform) + return { + year: { + v: np.asarray(sim.calculate(v, year)) + for v in BENUNIT_OUTPUTS + PERSON_OUTPUTS + } + for year in YEARS + } + + +def members_of(units): + """Each person's benefit unit index, in simulation order.""" + return np.array([i for i, unit in enumerate(units) for _ in unit["members"]]) + + +def saved_people(units): + return np.array([m["saved"] for unit in units for m in unit["members"]]) + + +def saved_units(units): + return np.array([any(m["saved"] for m in unit["members"]) for unit in units]) + + +def fiscal_year_share_before(date, year): + """Share of the fiscal year starting 6 April `year` falling before `date`.""" + start = datetime.date(year, 4, 6) + end = datetime.date(year + 1, 4, 6) + before = (min(max(date, start), end) - start).days + return before / (end - start).days + + +def jurisdiction_share(units, year): + return np.array( + [ + fiscal_year_share_before( + ABOLITION_DATES[ + "northern_ireland" + if unit["region"] == "NORTHERN_IRELAND" + else "great_britain" + ], + year, + ) + for unit in units + ] + ) + + +@pytest.fixture(scope="module") +def baseline_tax_benefit_system(): + """Share immutable baseline parameters across the annual-value checks.""" + return CountryTaxBenefitSystem() + + +@pytest.mark.parametrize("year", range(2015, 2041)) +def test_awards_payable_is_the_day_share_before_the_statutory_date( + year, baseline_tax_benefit_system +): + parameter = baseline_tax_benefit_system.parameters( + str(year) + ).gov.dwp.ESA.income.awards_payable + for name, date in ABOLITION_DATES.items(): + expected = fiscal_year_share_before(date, year) + assert getattr(parameter, name) == pytest.approx(expected, abs=1e-12) + + +def test_day_shares_match_the_hand_counted_days(): + # 6 April to 30 June 2026 is 25 + 31 + 30 = 86 days; to 30 September, + # 86 + 31 + 31 + 30 = 178; 2026-27 has 365 days. + assert fiscal_year_share_before(ABOLITION_DATES["great_britain"], 2026) == ( + 86 / 365 + ) + assert fiscal_year_share_before(ABOLITION_DATES["northern_ireland"], 2026) == ( + 178 / 365 + ) + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=8)) +def test_abolition_invariants(units): + abolished = calculate(units) + kept = calculate(units, reform=KEEP_AWARDS_PAYABLE) + without_saving = calculate(units, saving=False) + benunit = members_of(units) + saved = saved_people(units) + unsaved_unit = ~saved_units(units) + for year in YEARS: + a, k, w = abolished[year], kept[year], without_saving[year] + # 2. Bounds. + share = a["esa_income_payable_share"] + assert np.all((share >= 0) & (share <= 1)), year + assert np.all(share[saved] == 1), year + # 4. Contributory ESA never changes. + assert np.array_equal(a["esa_contrib"], k["esa_contrib"]), year + # 5. The abolition never raises income-related ESA, and without a + # saving each award is the jurisdiction's share of the award before + # the abolition. + assert np.all(a["esa_income"] <= k["esa_income"] + 1e-6), year + assert np.all( + a["claimant_or_partner_esa_income"] + <= k["claimant_or_partner_esa_income"] + 1e-6 + ), year + expected = jurisdiction_share(units, year) * k["esa_income"] + assert np.allclose( + a["esa_income"][unsaved_unit], expected[unsaved_unit], atol=0.01 + ), year + # 6. Turning the saving on never lowers ESA. + for name in ["esa_income", "esa"]: + assert np.all(a[name] >= w[name] - 1e-6), (year, name) + # 7. An outside member is on it only while their award is open. + outside = ~a["is_claimant_or_partner"].astype(bool) + on = a["is_on_income_related_esa"].astype(bool) + closed = a["esa_income_report_closed"].astype(bool) + assert not np.any(outside & on & closed), year + # 5. Before 2026-27 every output matches the rule before the abolition. + for year in BEFORE: + for name in BENUNIT_OUTPUTS + PERSON_OUTPUTS: + assert np.array_equal(abolished[year][name], kept[year][name]), ( + year, + name, + ) + # 3. After both dates, nothing is paid outside the saving. + for year in AFTER: + a = abolished[year] + assert np.all(a["esa_income"][unsaved_unit] == 0), year + assert np.all(a["claimant_or_partner_esa_income"][unsaved_unit] == 0), year + on = a["is_on_income_related_esa"].astype(bool) + assert not np.any(on & unsaved_unit[benunit]), year + outside = ~a["is_claimant_or_partner"].astype(bool) + assert not np.any(on & outside & ~saved), year + # With the saving off for everyone, nobody is paid or on it. + w = without_saving[year] + assert np.all(w["esa_income"] == 0), year + assert not np.any(w["is_on_income_related_esa"].astype(bool)), year diff --git a/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py b/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py index 6fc6e412da..42e5bc35aa 100644 --- a/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py +++ b/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py @@ -9,9 +9,10 @@ income-related ESA in their own right and is not part of it. Invariants, for any generated population of working-age families in 2025 -and 2027 (either side of income-related ESA's 1 July 2026 abolition date, -which the model does not yet apply) with no other legacy benefit, so none is -moved off legacy benefits: +and 2027 (either side of income-related ESA's 1 July 2026 abolition date) +with no other legacy benefit, so none is moved off legacy benefits. Every +award is saved for an appointee (SI 2025/1148 art. 3A(2)), so the abolition +leaves it in place in 2027 and only the claim ends it (art. 3A(3)): 1. Exclusive: a family that claims Universal Credit pays its claimant and partner no income-related ESA (claimant_or_partner_esa_income), and @@ -125,6 +126,8 @@ def situation(units, would_claim_uc=None, drop_couple_reports=False): "esa_income_reported": every_year(report), "employment_income": every_year(member["employment_income"]), "weekly_hours": every_year(member["weekly_hours"]), + # Art. 3A(2) keeps the award open after the abolition. + "esa_income_appointee_saving": every_year(True), } names.append(name) claims = unit["would_claim_uc"] if would_claim_uc is None else would_claim_uc From 99b335b3af7d6a6b94edecb405cbd3a3ced07c51 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:13:00 -0400 Subject: [PATCH 04/23] Keep pre-existing ESA property tests on their subject; document the abolition Python tests that read income-related ESA in 2026-27 or 2027-28 for another rule give their reporters esa_income_appointee_saving (SI 2025/1148 art. 3A(2)): the NLW permitted-work reform, the Merton non-dependant properties and the legacy-closure scope cases. Under disable_simulated_benefits an outside adult's own income-related ESA now ends with the abolition, as income-based JSA ends with its scheme, so that test expects it. The property test runs each population once with three copies (saving as drawn, every award saved, none saved) and checks that a reform keeping the parameter at 1 equals saving every award. Add the changelog fragment and a paragraph on the abolition to docs/engineering/esa-jsa-report-screening.md. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2562.fixed.md | 1 + docs/engineering/esa-jsa-report-screening.md | 10 ++ .../disable_simulated_benefits.py | 4 +- .../tests/test_disable_simulated_benefits.py | 12 +- .../test_esa_income_abolition_properties.py | 133 ++++++++++++++---- .../test_esa_permitted_work_nlw_reforms.py | 4 + .../test_legacy_award_readers_properties.py | 9 +- .../tests/test_legacy_closure_scope.py | 21 ++- 8 files changed, 159 insertions(+), 35 deletions(-) create mode 100644 changelog.d/2562.fixed.md diff --git a/changelog.d/2562.fixed.md b/changelog.d/2562.fixed.md new file mode 100644 index 0000000000..3787801b60 --- /dev/null +++ b/changelog.d/2562.fixed.md @@ -0,0 +1 @@ +Income-related ESA now ends when its abolition reaches the award: on 1 July 2026 in Great Britain (SI 2025/1148 art. 3A, inserted by SI 2026/409) and on 1 October 2026 in Northern Ireland (SR 2025/176 art. 3A, inserted by SR 2026/115). The new `gov.dwp.ESA.income.awards_payable` parameter is day-weighted across each fiscal year, so 2026-27 pays an award for 86/365 of the year in Great Britain and 178/365 in Northern Ireland, and 2027-28 onwards pays none. The new `esa_income_appointee_saving` input keeps an award that art. 3A(2) saves for an appointee; a Universal Credit claim still ends it (art. 3A(3)). Contributory ESA is unchanged. diff --git a/docs/engineering/esa-jsa-report-screening.md b/docs/engineering/esa-jsa-report-screening.md index 1994eb87ad..91552ecefa 100644 --- a/docs/engineering/esa-jsa-report-screening.md +++ b/docs/engineering/esa-jsa-report-screening.md @@ -14,6 +14,16 @@ screen passes. The couple's part stays £2,792. A working outside reporter canno raise the total; failure of the couple's own screen cannot erase another eligible person's separate award. These examples presume separate preassessed claims. +The abolition of income-related ESA reached every remaining award on 1 July 2026 +in Great Britain and 1 October 2026 in Northern Ireland (SI 2025/1148 art. 3A; +SR 2025/176 art. 3A). The model pays each claim for `esa_income_payable_share` +of the year: the couple's claim at the highest share among its reporters, and +each outside claim at its own. In 2026–27 that is 86/365 in Great Britain and +178/365 in Northern Ireland, so the £3,876 above becomes £913.25 in Great Britain. +From 2027–28 `esa_income_report_closed` closes every report, except one saved +for an appointee (`esa_income_appointee_saving`); a Universal Credit claim +still ends a saved award. Contributory ESA is unchanged. + All formula readers use the same original claim groups. A scope mask selects reports; it does not change claimant or partner roles. Claimant-or-partner awards exclude outside claims, including in Income Support and an alternate benefit-cap diff --git a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py index 6c4ef3aeec..0ddfbd9b5f 100644 --- a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py +++ b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py @@ -72,7 +72,9 @@ # Universal Credit anyway (would_claim_uc) is still not eligible for Housing # Benefit or tax credits. An adult outside the couple who reports # income-based JSA is on it, as their own claim, only in the years the model -# has the scheme open (gov.dwp.JSA.income.active: to 2025). +# has the scheme open (gov.dwp.JSA.income.active: to 2025). Likewise one who +# reports income-related ESA is on it only while some of the year is payable +# (esa_income_payable_share: to 2026-27, unless saved for an appointee). # Hold the family closure and each separate member's own closure. NOT_MOVED = ["legacy_benefits_closed", "person_legacy_benefits_closed"] diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 46ae03d233..8c7b89d576 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -363,8 +363,18 @@ def test_each_person_is_on_their_own_award(reformed, year): str(year) ).gov.dwp.JSA.income.active assert jsa_open == (year == DATASET_YEAR) + # Likewise their own income-related ESA, which its abolition ends from 1 + # July 2026 (SI 2025/1148 art. 3A): payable for part of 2026-27, none of + # 2027-28. + esa_open = ( + reformed.tax_benefit_system.parameters( + str(year) + ).gov.dwp.ESA.income.awards_payable.great_britain + > 0 + ) + assert esa_open == (year < DATASET_YEAR + 2) assert not on_esa["excluded_adult_no_award_0"] - assert on_esa["excluded_adult_no_award_1"] + assert on_esa["excluded_adult_no_award_1"] == esa_open assert not on_jsa["excluded_adult_no_award_0"] assert on_jsa["excluded_adult_no_award_1"] == jsa_open assert on_esa["own_esa_0"] and not on_jsa["own_esa_0"] diff --git a/policyengine_uk/tests/test_esa_income_abolition_properties.py b/policyengine_uk/tests/test_esa_income_abolition_properties.py index 3d40492296..721b2975b5 100644 --- a/policyengine_uk/tests/test_esa_income_abolition_properties.py +++ b/policyengine_uk/tests/test_esa_income_abolition_properties.py @@ -8,6 +8,12 @@ (esa_income_appointee_saving); art. 3A(3) lets a Universal Credit claim end a saved award. A contributory allowance continues as new style ESA. +Each generated population runs in one simulation three times over: with the +saving as drawn, with every award saved, and with none saved. Saving every +award pays it in full in every year, as the rule before the abolition did +(origin/main); a reform that keeps the parameter at 1 gives the same outputs +(test_keeping_awards_payable_is_saving_every_award). + Invariants, for any generated population of working-age families in 2025-26, 2026-27 and 2027-28: @@ -21,12 +27,12 @@ and none of its members is on it; and in every benefit unit, a member who is neither the claimant nor the partner and has no saving is not on it. 4. Contributory ESA never changes: esa_contrib is the same with and without - the abolition, in every year. -5. Differential against the rule before the abolition (a reform keeping - every award payable in every year, as at origin/main): before 2026-27 - every output is identical; in every year, with no saved member, a benefit - unit's esa_income is exactly its jurisdiction's payable share of that - rule's; and the abolition never raises anyone's income-related ESA. + the saving, in every year. +5. Differential against the rule before the abolition: before 2026-27 every + output is the same whatever is saved; in every year a benefit unit with no + saved member gets exactly its jurisdiction's payable share of the award + the rule before the abolition pays; and the abolition never raises + anyone's income-related ESA. 6. Monotonic: turning the saving on for any members never lowers a benefit unit's esa_income or esa. 7. Status: a member who is neither the claimant nor the partner is on @@ -89,6 +95,7 @@ "is_on_income_related_esa", "is_claimant_or_partner", ] +SAVING_MODES = ("drawn", "every", "none") @st.composite @@ -127,7 +134,29 @@ def every_year(value): return {year: value for year in YEARS} -def situation(units, saving=True): +def with_saving(units, mode): + """The units with each member's saving as drawn, on for every adult, or + off for everyone.""" + return [ + dict( + unit, + members=[ + dict( + member, + saved={ + "drawn": member["saved"], + "every": member["role"] != "child", + "none": False, + }[mode], + ) + for member in unit["members"] + ], + ) + for unit in units + ] + + +def situation(units): people, benunits, households = {}, {}, {} for i, unit in enumerate(units): names = [] @@ -145,7 +174,7 @@ def situation(units, saving=True): "esa_contrib_reported": every_year(member["esa_contrib_reported"]), "employment_income": every_year(member["employment_income"]), "weekly_hours": every_year(member["weekly_hours"]), - "esa_income_appointee_saving": every_year(saving and member["saved"]), + "esa_income_appointee_saving": every_year(member["saved"]), } names.append(name) benunits[f"b{i}"] = { @@ -161,8 +190,8 @@ def situation(units, saving=True): return {"people": people, "benunits": benunits, "households": households} -def calculate(units, reform=None, saving=True): - sim = Simulation(situation=situation(units, saving), reform=reform) +def calculate(units, reform=None): + sim = Simulation(situation=situation(units), reform=reform) return { year: { v: np.asarray(sim.calculate(v, year)) @@ -172,6 +201,22 @@ def calculate(units, reform=None, saving=True): } +def calculate_each_saving(units): + """Each output for the units under each saving mode, from one + simulation holding all three copies of the population.""" + n = len(units) + p = sum(len(unit["members"]) for unit in units) + combined = [u for mode in SAVING_MODES for u in with_saving(units, mode)] + values = calculate(combined) + out = {mode: {year: {} for year in YEARS} for mode in SAVING_MODES} + for year in YEARS: + for name in BENUNIT_OUTPUTS + PERSON_OUTPUTS: + size = n if name in BENUNIT_OUTPUTS else p + for k, mode in enumerate(SAVING_MODES): + out[mode][year][name] = values[year][name][k * size : (k + 1) * size] + return out + + def members_of(units): """Each person's benefit unit index, in simulation order.""" return np.array([i for i, unit in enumerate(units) for _ in unit["members"]]) @@ -238,60 +283,92 @@ def test_day_shares_match_the_hand_counted_days(): ) +def test_keeping_awards_payable_is_saving_every_award(): + """The parameter and the saving are two routes to a payable award: a + reform keeping awards_payable at 1 gives what saving every award gives.""" + adult = dict( + esa_contrib_reported=0.0, employment_income=0.0, weekly_hours=0, saved=False + ) + units = [ + dict( + members=[dict(adult, role="claimant", age=40, esa_income_reported=5_000.0)], + region=region, + savings=7_000.0, + would_claim_uc=False, + ) + for region in ["LONDON", "NORTHERN_IRELAND"] + ] + [ + dict( + members=[ + dict(adult, role="claimant", age=40, esa_income_reported=2_000.0), + dict(adult, role="other", age=30, esa_income_reported=6_000.0), + ], + region="WALES", + savings=0.0, + would_claim_uc=True, + ) + ] + kept = calculate(units, reform=KEEP_AWARDS_PAYABLE) + saved = calculate(with_saving(units, "every")) + for year in YEARS: + for name in BENUNIT_OUTPUTS + PERSON_OUTPUTS: + assert np.array_equal(kept[year][name], saved[year][name]), (year, name) + + @PROPERTY_SETTINGS @given(st.lists(families(), min_size=1, max_size=8)) def test_abolition_invariants(units): - abolished = calculate(units) - kept = calculate(units, reform=KEEP_AWARDS_PAYABLE) - without_saving = calculate(units, saving=False) + values = calculate_each_saving(units) benunit = members_of(units) saved = saved_people(units) unsaved_unit = ~saved_units(units) for year in YEARS: - a, k, w = abolished[year], kept[year], without_saving[year] + # a: the saving as drawn; k: every award kept; w: no award saved. + a, k, w = (values[mode][year] for mode in SAVING_MODES) # 2. Bounds. share = a["esa_income_payable_share"] assert np.all((share >= 0) & (share <= 1)), year assert np.all(share[saved] == 1), year # 4. Contributory ESA never changes. assert np.array_equal(a["esa_contrib"], k["esa_contrib"]), year + assert np.array_equal(w["esa_contrib"], k["esa_contrib"]), year # 5. The abolition never raises income-related ESA, and without a - # saving each award is the jurisdiction's share of the award before - # the abolition. + # saving each award is the jurisdiction's share of the full award. assert np.all(a["esa_income"] <= k["esa_income"] + 1e-6), year assert np.all( a["claimant_or_partner_esa_income"] <= k["claimant_or_partner_esa_income"] + 1e-6 ), year expected = jurisdiction_share(units, year) * k["esa_income"] + assert np.allclose(w["esa_income"], expected, atol=0.01), year assert np.allclose( a["esa_income"][unsaved_unit], expected[unsaved_unit], atol=0.01 ), year # 6. Turning the saving on never lowers ESA. for name in ["esa_income", "esa"]: assert np.all(a[name] >= w[name] - 1e-6), (year, name) + assert np.all(k[name] >= a[name] - 1e-6), (year, name) # 7. An outside member is on it only while their award is open. - outside = ~a["is_claimant_or_partner"].astype(bool) - on = a["is_on_income_related_esa"].astype(bool) - closed = a["esa_income_report_closed"].astype(bool) - assert not np.any(outside & on & closed), year - # 5. Before 2026-27 every output matches the rule before the abolition. + for v in (a, k, w): + outside = ~v["is_claimant_or_partner"].astype(bool) + on = v["is_on_income_related_esa"].astype(bool) + closed = v["esa_income_report_closed"].astype(bool) + assert not np.any(outside & on & closed), year + # 5. Before 2026-27 every output is the same whatever is saved. for year in BEFORE: for name in BENUNIT_OUTPUTS + PERSON_OUTPUTS: - assert np.array_equal(abolished[year][name], kept[year][name]), ( - year, - name, - ) + for mode in ("drawn", "none"): + assert np.array_equal( + values[mode][year][name], values["every"][year][name] + ), (year, name, mode) # 3. After both dates, nothing is paid outside the saving. for year in AFTER: - a = abolished[year] + a, w = values["drawn"][year], values["none"][year] assert np.all(a["esa_income"][unsaved_unit] == 0), year assert np.all(a["claimant_or_partner_esa_income"][unsaved_unit] == 0), year on = a["is_on_income_related_esa"].astype(bool) assert not np.any(on & unsaved_unit[benunit]), year outside = ~a["is_claimant_or_partner"].astype(bool) assert not np.any(on & outside & ~saved), year - # With the saving off for everyone, nobody is paid or on it. - w = without_saving[year] assert np.all(w["esa_income"] == 0), year assert not np.any(w["is_on_income_related_esa"].astype(bool)), year diff --git a/policyengine_uk/tests/test_esa_permitted_work_nlw_reforms.py b/policyengine_uk/tests/test_esa_permitted_work_nlw_reforms.py index cd91060c3b..5b98074ec7 100644 --- a/policyengine_uk/tests/test_esa_permitted_work_nlw_reforms.py +++ b/policyengine_uk/tests/test_esa_permitted_work_nlw_reforms.py @@ -24,11 +24,14 @@ def _reported_claimants(): + # SI 2025/1148 art. 3A(2) saves both awards, so each stays payable for the + # whole of 2026-27 after the 1 July 2026 abolition of income-related ESA. return { "claimant": { "age": 50, "is_claimant_or_partner": True, "esa_income_reported": 3_000, + "esa_income_appointee_saving": True, }, "partner": {"age": 48, "is_claimant_or_partner": True}, "outside_adult": { @@ -37,6 +40,7 @@ def _reported_claimants(): "employment_income": 10_700, "hours_worked": 780, "esa_income_reported": 5_000, + "esa_income_appointee_saving": True, }, } diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index f82151672d..a24ed8a6b1 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -102,8 +102,11 @@ YEAR = 2025 # Merton's working-age council tax reduction scheme has parameters from # April 2026, so the non-dependant property runs in 2027 (income-related ESA -# only: Income Support and income-based JSA closed in April 2026). +# only: Income Support and income-based JSA closed in April 2026). Its ESA +# reporters keep their awards through the appointee saving (SI 2025/1148 art. +# 3A(2)), since the abolition ends every other award from 1 July 2026. NON_DEP_YEAR = 2027 +SAVED = {"esa_income_appointee_saving": {NON_DEP_YEAR: True}} FAMILY_READERS = [ "claimant_or_partner_esa_income", @@ -938,6 +941,7 @@ def test_other_members_esa_never_changes_non_dependant_exemption(drawn): "is_claimant_or_partner": {year: True}, "current_education": {year: "NOT_IN_EDUCATION"}, "esa_income_reported": {year: award}, + **SAVED, } unit.append(name) if existing_award is not None: @@ -947,6 +951,7 @@ def test_other_members_esa_never_changes_non_dependant_exemption(drawn): "is_claimant_or_partner": {year: False}, "current_education": {year: "NOT_IN_EDUCATION"}, "esa_income_reported": {year: existing_award}, + **SAVED, } unit.append(name) if other_present: @@ -956,6 +961,7 @@ def test_other_members_esa_never_changes_non_dependant_exemption(drawn): "is_claimant_or_partner": {year: False}, "current_education": {year: "NOT_IN_EDUCATION"}, "esa_income_reported": {year: other_award}, + **SAVED, } unit.append(name) people[names[0]]["is_claimant_or_partner"] = {year: True} @@ -1277,6 +1283,7 @@ def member(age, role, report, works): "is_claimant_or_partner": {year: role}, "current_education": {year: "NOT_IN_EDUCATION"}, "esa_income_reported": {year: report}, + **SAVED, } if works: inputs.update({k: {year: v} for k, v in FULL_TIME_PAY.items()}) diff --git a/policyengine_uk/tests/test_legacy_closure_scope.py b/policyengine_uk/tests/test_legacy_closure_scope.py index e3f4428e40..89fe031c98 100644 --- a/policyengine_uk/tests/test_legacy_closure_scope.py +++ b/policyengine_uk/tests/test_legacy_closure_scope.py @@ -10,6 +10,11 @@ from policyengine_uk import Simulation +# The 2027 cases keep their income-related ESA awards through the appointee +# saving (SI 2025/1148 art. 3A(2)), so only the legacy closure can end them; +# the abolition ends every other award from 1 July 2026. +SAVED = {"esa_income_appointee_saving": True} + def household(year, claimant=None, partner=None, outside=None, child=False): members = { @@ -54,7 +59,7 @@ def household(year, claimant=None, partner=None, outside=None, child=False): @pytest.mark.parametrize("report", ["income_support_reported", "jsa_income_reported"]) @pytest.mark.parametrize("payee", ["claimant", "partner"]) def test_outside_closed_report_does_not_close_couple_award(report, payee): - inputs = {payee: {"esa_income_reported": 5_000}} + inputs = {payee: {"esa_income_reported": 5_000, **SAVED}} sim = Simulation(situation=household(2027, **inputs, outside={report: 1_000})) assert not sim.calculate("legacy_benefits_closed", 2027)[0] assert sim.calculate("claimant_or_partner_esa_income", 2027)[0] == 5_000 @@ -65,7 +70,11 @@ def test_outside_pension_credit_report_does_not_shield_couple_closure(): sim = Simulation( situation=household( 2027, - claimant={"income_support_reported": 1_000, "esa_income_reported": 5_000}, + claimant={ + "income_support_reported": 1_000, + "esa_income_reported": 5_000, + **SAVED, + }, outside={"age": 70, "pension_credit_reported": 1_000}, ) ) @@ -99,8 +108,12 @@ def test_outside_own_closed_report_still_ends_only_their_own_claim(): sim = Simulation( situation=household( 2027, - claimant={"esa_income_reported": 5_000}, - outside={"esa_income_reported": 5_000, "income_support_reported": 1_000}, + claimant={"esa_income_reported": 5_000, **SAVED}, + outside={ + "esa_income_reported": 5_000, + "income_support_reported": 1_000, + **SAVED, + }, ) ) assert not sim.calculate("legacy_benefits_closed", 2027)[0] From eb090e06311b8cb218fd0ffb88000bd13efc7e3d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:15:40 -0400 Subject: [PATCH 05/23] Say in is_on_income_related_esa that the abolition ends an outside member's status Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py index dde494cf3b..45f9529ae9 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py @@ -26,7 +26,9 @@ class is_on_income_related_esa(Variable): "capital, within the capital limit. On the formula path the claimant's " "or partner's claim and work never change this. The legacy " "benefits for this person's own award must remain open " - "(person_legacy_benefits_closed), and the benefit unit's modelled award must " + "(person_legacy_benefits_closed), the abolition of income-related ESA " + "must not have ended the award (esa_income_report_closed), and the " + "benefit unit's modelled award must " "be in payment: positive, or nil while esa_income_eligible fails and " "esa_income is not neutralised. On the formula path that nil comes only " "from the retained value-based override convention. Formula totals " From 8fba8562c71ff2711a85f7cc334935c6a0979eea Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 22:17:32 -0400 Subject: [PATCH 06/23] Describe the awards_payable flag by what main does with it Ordinary parameters take their 30 April value; the flag's fiscal_year_blend day-weights each fiscal year. Drop a sentence about dated reform keys that the current reform routes no longer follow, and say plainly that the appointee saving is off in dataset runs. Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/ESA/income/awards_payable.yaml | 14 +++++--------- .../gov/dwp/esa_income_appointee_saving.py | 2 +- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml index 96d4692202..0c3eb39781 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml @@ -4,15 +4,11 @@ description: >- income-related ESA came into force for every remaining award on 1 July 2026 in Great Britain and 1 October 2026 in Northern Ireland, except awards saved for an appointee (esa_income_appointee_saving). A contributory allowance in - the same award continues as new style ESA. Annual formulas read parameters - once, at the start of the model year, after fiscal-year conversion. - Parameters normally sample 30 April, but these numeric flags use - fiscal_year_blend to take the day-weighted average from 6 April to 5 April, - so 2026-27 carries 86 of its 365 days in Great Britain and 178 in Northern - Ireland. In a Simulation reform dictionary a bare year overrides the whole - fiscal year, while a key dated inside the year (for example 2026-07-01) - leaves that model year's value unchanged. Use whole-year values or a date - range for annual reform scenarios. + the same award continues as new style ESA. Ordinary parameters take their + 30 April value for a model year, which would leave 2026-27 untouched, so + these numeric flags use fiscal_year_blend to take the day-weighted average + from 6 April to 5 April: 2026-27 carries 86 of its 365 days in Great Britain + and 178 in Northern Ireland. great_britain: description: Whether old style income-related Employment and Support Allowance awards are still payable in Great Britain. SI 2025/1148 art. 3A brought section 33(1)(b) of the Welfare Reform Act 2012 into force for them on 1 July 2026. values: diff --git a/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py index d5c9a00343..bc880b7e9f 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py @@ -18,7 +18,7 @@ class esa_income_appointee_saving(Variable): "52 of the Northern Ireland Claims and Payments Regulations 2016. A " "Universal Credit claim still ends a saved award (art. 3A(3); " "esa_income_report_closed). No dataset identifies appointees, so this " - "defaults to false and dataset runs end every unsaved award." + "defaults to false and the saving is off in dataset runs." ) definition_period = YEAR default_value = False From beb1b18689d23bcba0daa56f5e7210542e4a675d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:52:13 -0400 Subject: [PATCH 07/23] Claim Universal Credit when the ESA abolition reaches an award, and close it from 2027-28 - esa_income_abolition_reached: the claimant's or partner's unsaved income-related ESA claim is payable for less than the whole year; it joins legacy_benefits_closed in claims_uc_at_legacy_closure. - legacy_award_is_closed closes an ESA report whose claim has nothing payable, under the Income Support and JSA Pension Credit guard. - esa_income_payable_share is the claim's share: a couple's claim is saved when either member who reports it is saved. - own_report_is_paid lets a stored award, not the abolition closure, decide an outside member's status, so disable_simulated_benefits keeps them on the award it pays; the reform holds the new claim term. Co-Authored-By: Claude Opus 5.5 --- .../disable_simulated_benefits.py | 32 ++++++---- .../variables/gov/dwp/_legacy_award_payee.py | 33 +++++++--- .../variables/gov/dwp/esa_income.py | 8 +-- .../gov/dwp/esa_income_payable_share.py | 50 ++++++++++----- .../gov/dwp/esa_income_report_closed.py | 19 +++--- .../gov/dwp/is_on_income_related_esa.py | 12 +++- .../left_pension_route_at_legacy_closure.py | 12 ++-- .../claims_uc_at_legacy_closure.py | 22 +++++-- .../claims_universal_credit.py | 3 +- .../esa_income_abolition_reached.py | 59 ++++++++++++++++++ .../legacy_benefits_closed.py | 61 ++++++++++++++----- .../person_legacy_benefits_closed.py | 13 +++- .../would_claim_uc_at_legacy_closure.py | 9 ++- 13 files changed, 253 insertions(+), 80 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py diff --git a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py index 0ddfbd9b5f..d3e94e56ed 100644 --- a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py +++ b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py @@ -60,23 +60,31 @@ # The model's move off legacy benefits (legacy_benefits_closed): once a legacy # benefit a family reports has closed, DWP's migration notice ends all its # legacy awards, whether or not the family then claims Universal Credit, -# unless DWP sent it to Pension Credit instead. The reform pays every listed -# benefit, the legacy ones and Universal Credit alike, at its dataset-year -# report in every year, so it moves no family: it holds these false for its -# years. The closure then ends none of the family's legacy benefits (their -# eligibility and the "on" statuses read from it, and Housing Benefit and tax -# credit eligibility), prompts no Universal Credit claim -# (claims_uc_at_legacy_closure) and takes no family off the Pension Credit +# unless DWP sent it to Pension Credit instead. The abolition of +# income-related ESA reaching a family's award (esa_income_abolition_reached) +# likewise prompts a Universal Credit claim from 2026-27. The reform pays +# every listed benefit, the legacy ones and Universal Credit alike, at its +# dataset-year report in every year, so it moves no family: it holds these +# false for its years. The closure then ends none of the family's legacy +# benefits (their eligibility and the "on" statuses read from it, and Housing +# Benefit and tax credit eligibility), neither prompts a Universal Credit +# claim (claims_uc_at_legacy_closure) and no family leaves the Pension Credit # route. Only the closure is held: the model's other rules still apply, and # the reform still pays the amounts reported. A family the model has claim # Universal Credit anyway (would_claim_uc) is still not eligible for Housing # Benefit or tax credits. An adult outside the couple who reports # income-based JSA is on it, as their own claim, only in the years the model -# has the scheme open (gov.dwp.JSA.income.active: to 2025). Likewise one who -# reports income-related ESA is on it only while some of the year is payable -# (esa_income_payable_share: to 2026-27, unless saved for an appointee). -# Hold the family closure and each separate member's own closure. -NOT_MOVED = ["legacy_benefits_closed", "person_legacy_benefits_closed"] +# has the scheme open (gov.dwp.JSA.income.active: to 2025). One who reports +# income-related ESA is on it in every year, as the reform pays it in every +# year: the abolition closes their report (esa_income_report_closed), but +# the held award, not the formula's, decides their status +# (_legacy_award_payee.own_report_is_paid). Hold the family closure, each +# separate member's own closure, and the abolition's claim. +NOT_MOVED = [ + "legacy_benefits_closed", + "person_legacy_benefits_closed", + "esa_income_abolition_reached", +] # The first year in which the model pays a carer in Scotland Carer Support # Payment in place of Carer's Allowance: carers_allowance_pre_overlap and diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 0013024244..f91ec44ddf 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -1,7 +1,7 @@ """Who is "on" a legacy income-related award.""" from policyengine_uk.model_api import * -from policyengine_uk.utils.benefit_unit import REPORT_CLOSED +from policyengine_uk.utils.benefit_unit import HALF_PENNY, REPORT_CLOSED def is_payee_of_couple_award(person, period, reported, can_claim=None, couple=None): @@ -40,7 +40,9 @@ def is_payee_of_couple_award(person, period, reported, can_claim=None, couple=No return where(person.benunit.any(reports), reports, claimant) -def own_report_is_paid(person, period, award, works, capital_limit): +def own_report_is_paid( + person, period, award, works, capital_limit, award_on_reports=None +): """Whether income-related ESA or income-based JSA (award: "esa_income" or "jsa_income") is paid on this person's own report, for a member who is neither the claimant nor the partner and so claims in their own right. @@ -54,11 +56,17 @@ def own_report_is_paid(person, period, award, works, capital_limit): cannot stand in for theirs, because when the claimant or partner reports an award it tests only them. - Their own award must remain open (REPORT_CLOSED: for income-based JSA, - their legacy benefits under the existing annual proxy, - person_legacy_benefits_closed; for income-related ESA, also its - abolition, esa_income_report_closed). A couple's closure does not end - this separate claim. The benefit unit's award must also be in payment: + Their own legacy benefits must remain open under the existing annual + proxy (person_legacy_benefits_closed). A couple's closure does not end + this separate claim. An award with closures of its own (REPORT_CLOSED: + for income-related ESA, its abolition, esa_income_report_closed) passes + award_on_reports, the award its formula gives on open person reports. + Where the stored award is that formula's, those closures, which leave + the report out of it, end status too. Where it holds another value, + entered directly or held by disable_simulated_benefits in every year, + that value decides payment, as it does for the claimant and partner + (claimant_or_partner_esa_income), so only the legacy closure ends + status. The benefit unit's award must also be in payment: positive, or nil while its work/capital screen fails and the award is not neutralised. A directly entered or reformed nil removes status while that screen passes; neutralising the award always removes it. These @@ -73,7 +81,16 @@ def own_report_is_paid(person, period, award, works, capital_limit): > benunit(f"{award}_tariff_income", period) ) ) - closed = person(REPORT_CLOSED[award], period) + closed = person("person_legacy_benefits_closed", period) + if award_on_reports is not None: + report_closed = person(REPORT_CLOSED[award], period) + stored = benunit(award, period) + open_reported = person(f"{award}_reported", period) * ~report_closed + formula_award = award_on_reports(benunit, period, open_reported) + as_formula = np.isclose( + stored, formula_award.astype(stored.dtype), rtol=0, atol=HALF_PENNY + ) + closed = where(benunit.project(as_formula), report_closed, closed) removed = person.simulation.tax_benefit_system.get_variable(award).is_neutralized paid = benunit(award, period) > 0 screen_fails = ~benunit(f"{award}_eligible", period) diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index 56066bde6a..8064e4a446 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -33,11 +33,11 @@ def income_related_esa_award_on_reports(benunit, period, reported, members=None) own_claim = ~couple & ~works & benunit.project(capital <= limit) tariff = benunit.project(benunit("esa_income_tariff_income", period)) own_awards = where(own_claim, max_(0, reported - tariff), 0) - # Each award is paid for the share of the year before the abolition of + # Each claim is paid for the share of the year before the abolition of # income-related ESA reached it (SI 2025/1148 art. 3A; SR 2025/176 art. - # 3A). The couple's award keeps the saving of a member who reports it. + # 3A). The claimant and partner share one claim, so one share. share = person("esa_income_payable_share", period) - couple_share = benunit.max(where(couple & (reported > 0), share, 0)) + couple_share = benunit.max(where(couple, share, 0)) return couple_award * couple_share + benunit.sum(own_awards * share) @@ -51,7 +51,7 @@ class esa_income(Variable): "has ended are left out (esa_income_report_closed): the owner's " "legacy benefits have closed, the owner is the claimant or " "partner of a family that claims Universal Credit, or the abolition " - "of income-related ESA has ended the award. Each award is paid for " + "of income-related ESA has ended the award. Each claim is paid for " "the share of the year before that abolition " "(esa_income_payable_share). This is not a full entitlement model." ) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py b/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py index d5d547555c..b729c1da02 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py @@ -4,27 +4,38 @@ class esa_income_payable_share(Variable): value_type = float entity = Person - label = "Share of the year an income-related ESA award stays payable" + label = "Share of the year the income-related ESA claim stays payable" documentation = ( - "Share of the year for which the old style income-related ESA award " - "on this person's report stays payable. SI 2025/1148 art. 3A brought " - "the abolition of income-related ESA (Welfare Reform Act 2012 " - "s.33(1)(b)) into force for every remaining award on 1 July 2026, and " - "SR 2025/176 art. 3A brought it into operation in Northern Ireland on " - "1 October 2026. So this is the share of the fiscal year before the " - "household's date (gov.dwp.ESA.income.awards_payable): 1 up to " - "2025-26; in 2026-27, 86 of 365 days in Great Britain and 178 of 365 " - "in Northern Ireland; and 0 from 2027-28. An award saved for an " - "appointee (esa_income_appointee_saving) has a share of 1 in every " - "year. esa_income pays each award for this share of the year, and " - "esa_income_report_closed ends the report in a year where it is 0. A " - "contributory allowance continues as new style ESA (esa_contrib)." + "Share of the year for which the old style income-related ESA claim " + "this person belongs to stays payable. The claimant and partner share " + "one claim; any other member claims in their own right. The Secretary " + "of State appointed 1 July 2026 for the abolition of income-related " + "ESA (Welfare Reform Act 2012 s.33(1)(b)) to reach every remaining " + "award (SI 2025/1148 art. 3A(1)), and the Department appointed 1 " + "October 2026 in Northern Ireland (SR 2025/176 art. 3A(1)). So this is " + "the share of the fiscal year before the household's date " + "(gov.dwp.ESA.income.awards_payable): 1 up to 2025-26; in 2026-27, 86 " + "of 365 days in Great Britain and 178 of 365 in Northern Ireland; and " + "0 from 2027-28. Art. 3A(1)(b) leaves an award already due to end at " + "the end of a two-week run-on period (art. 1(2)) to that schedule: " + "with a migration notice deadline of 22 June 2026 and no claim, SI " + "2014/1230 reg 46(1)(a) ends the award on 5 July, after the general " + "date. This annual share does not track deadlines or claim dates, so " + "it pays every award to the general date. A claim saved for an " + "appointee has a share of 1 in " + "every year: a couple's claim is saved when either member who reports " + "the award is saved (esa_income_appointee_saving), and saving a member " + "who reports nothing saves nothing. esa_income pays each claim for " + "this share of the year, and esa_income_report_closed ends its reports " + "in a year where it is 0. A contributory allowance continues as new " + "style ESA (esa_contrib)." ) definition_period = YEAR unit = "/1" reference = ( "https://www.legislation.gov.uk/uksi/2025/1148/article/3A", "https://www.legislation.gov.uk/nisr/2025/176/article/3A", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", "https://www.legislation.gov.uk/ukpga/2012/5/section/33", "https://www.legislation.gov.uk/nisi/2015/2006/article/39", ) @@ -39,5 +50,12 @@ def formula(person, period, parameters): payable.northern_ireland, payable.great_britain, ) - # Art. 3A(2) saves an award for an appointee in every year. - return where(person("esa_income_appointee_saving", period), 1, share) + # Art. 3A(2) saves an award for an appointee in every year. The + # claimant's and partner's award is one award, saved if either of + # them reports it and is saved; another member's is their own. + reports_saved = (person("esa_income_reported", period) > 0) & person( + "esa_income_appointee_saving", period + ) + couple = person("is_claimant_or_partner", period) + saved = where(couple, person.benunit.any(couple & reports_saved), reports_saved) + return where(saved, 1, share) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py index f241eb97da..b79349df87 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py @@ -28,14 +28,17 @@ class esa_income_report_closed(Variable): "part of the family's Universal Credit claim, so the claim does not " "end their award. The model applies this in every year and area: the " "postcode districts named in the commencement orders are not " - "modelled. Every other award ends when the abolition reaches it on " - "1 July 2026, or 1 October 2026 in Northern Ireland (SI 2025/1148 " - "art. 3A; SR 2025/176 art. 3A), so the report is closed in a year " - "where none of it is payable (esa_income_payable_share is 0, from " - "2027-28). In 2026-27 the report stays open and esa_income pays it " - "for the share of the year before that date. An award saved for an " - "appointee (esa_income_appointee_saving) stays open, but a Universal " - "Credit claim or the legacy closure still ends it (art. 3A(3))." + "modelled. The abolition reaches every other award on 1 July 2026, " + "or 1 October 2026 in Northern Ireland (SI 2025/1148 art. 3A; SR " + "2025/176 art. 3A), so the report is closed in a year where none of " + "its claim is payable (esa_income_payable_share is 0, from 2027-28). " + "In 2026-27 the report stays open and esa_income pays it for the " + "share of the year before that date, unless the family claims " + "Universal Credit when the abolition reaches its award " + "(claims_uc_at_legacy_closure). A claim saved for an appointee " + "(esa_income_appointee_saving) stays open, every member's report " + "included, but a Universal Credit claim or the legacy closure still " + "ends it (art. 3A(3))." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py index 45f9529ae9..f0c40d9b5f 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py @@ -3,6 +3,9 @@ is_payee_of_couple_award, own_report_is_paid, ) +from policyengine_uk.variables.gov.dwp.esa_income import ( + income_related_esa_award_on_reports, +) class is_on_income_related_esa(Variable): @@ -26,9 +29,11 @@ class is_on_income_related_esa(Variable): "capital, within the capital limit. On the formula path the claimant's " "or partner's claim and work never change this. The legacy " "benefits for this person's own award must remain open " - "(person_legacy_benefits_closed), the abolition of income-related ESA " - "must not have ended the award (esa_income_report_closed), and the " - "benefit unit's modelled award must " + "(person_legacy_benefits_closed), and so must their report " + "(esa_income_report_closed, which the abolition of income-related " + "ESA closes) while esa_income holds its formula's award; a value " + "entered directly or held by disable_simulated_benefits decides " + "payment instead. The benefit unit's modelled award must " "be in payment: positive, or nil while esa_income_eligible fails and " "esa_income is not neutralised. On the formula path that nil comes only " "from the retained value-based override convention. Formula totals " @@ -55,5 +60,6 @@ def formula(person, period, parameters): "esa_income", works=person("esa_income_claimant_remunerative_work", period), capital_limit=parameters(period).gov.dwp.ESA.income.capital.limit, + award_on_reports=income_related_esa_award_on_reports, ) return where(person("is_claimant_or_partner", period), couple_award, own_award) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py index 8b24f90e10..081f7d8c68 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py @@ -7,7 +7,8 @@ class left_pension_route_at_legacy_closure(Variable): label = "Left the pension-age route when its legacy benefits closed" documentation = ( "Whether a family meeting the Pension Credit age conditions left that " - "route when DWP moved it to Universal Credit (legacy_benefits_closed): " + "route when DWP moved it to Universal Credit (legacy_benefits_closed, " + "or esa_income_abolition_reached): " "it claimed Universal Credit at the closure and is entitled to it " "before benefit-cap reductions and deductions. A family that does " "not claim, or claims but has nil entitlement, keeps the route. A " @@ -29,9 +30,12 @@ class left_pension_route_at_legacy_closure(Variable): ) def formula(benunit, period, parameters): - moved = benunit("legacy_benefits_closed", period) & benunit( - "meets_pension_credit_age_conditions", period - ) + # A protected mixed-age couple whose income-related ESA award the + # abolition reaches is moved too (esa_income_abolition_reached). + moved = ( + benunit("legacy_benefits_closed", period) + | benunit("esa_income_abolition_reached", period) + ) & benunit("meets_pension_credit_age_conditions", period) # SI 2019/37 art. 4(3)(b)(ii), inserted by SI 2024/604, lets a # protected couple that did not claim Universal Credit, or claimed # but was not entitled, reclaim Housing Benefit within three months diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py index 2ee3f940cb..f36ae782db 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py @@ -7,13 +7,25 @@ class claims_uc_at_legacy_closure(Variable): label = "Claims Universal Credit when its legacy benefits close" documentation = ( "Whether this family claims Universal Credit because DWP moved it off " - "its legacy benefits (legacy_benefits_closed) and it would claim then " - "(would_claim_uc_at_legacy_closure)." + "its legacy benefits (legacy_benefits_closed), or because the " + "abolition of income-related ESA has reached the claimant's or " + "partner's award (esa_income_abolition_reached, from 2026-27), and it " + "would claim then (would_claim_uc_at_legacy_closure). The claim ends " + "the income-related ESA award (esa_income_report_closed) and the " + "Housing Benefit award (housing_benefit_eligible). A family that does " + "not claim keeps its income-related ESA for the part of 2026-27 " + "before the abolition reached it, and its Housing Benefit, until " + "legacy_benefits_closed moves it in 2027-28." ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2014/1230/regulation/44" + reference = ( + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/44", + "https://www.legislation.gov.uk/uksi/2025/1148/article/3A", + "https://www.legislation.gov.uk/nisr/2025/176/article/3A", + ) def formula(benunit, period, parameters): - return benunit("legacy_benefits_closed", period) & benunit( - "would_claim_uc_at_legacy_closure", period + moved = benunit("legacy_benefits_closed", period) | benunit( + "esa_income_abolition_reached", period ) + return moved & benunit("would_claim_uc_at_legacy_closure", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py index 952acc87e0..f302195ae4 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py @@ -8,7 +8,8 @@ class claims_universal_credit(Variable): documentation = ( "Whether this family claims Universal Credit: it would claim it " "anyway (would_claim_uc), or it claims when DWP moves it off its " - "legacy benefits (claims_uc_at_legacy_closure). Universal Credit is " + "legacy benefits or the abolition of income-related ESA reaches its " + "award (claims_uc_at_legacy_closure). Universal Credit is " "paid only to claiming families. A claim ends the family's Housing " "Benefit continuing award and Child Tax Credit, and the claimant's " "and partner's income-related ESA (esa_income_report_closed). Once " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py b/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py new file mode 100644 index 0000000000..60a484fc68 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py @@ -0,0 +1,59 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.legacy_benefits_closed import ( + moves_working_age_awards, +) + + +class esa_income_abolition_reached(Variable): + value_type = bool + entity = BenUnit + label = "Abolition of income-related ESA has reached the family's award" + documentation = ( + "Whether the claimant or partner reports an old style income-related " + "ESA award that its abolition has reached: their claim stays payable " + "for less than the whole year (esa_income_payable_share below 1), " + "from 2026-27. The Secretary of State appointed 1 July 2026 for the " + "abolition to reach every remaining award (SI 2025/1148 art. 3A), and " + "the Department appointed 1 October 2026 in Northern Ireland (SR " + "2025/176 art. 3A). DWP sent migration notices to 879,000 households " + "on income-related ESA by December 2025, most of which had claimed " + "Universal Credit (LA Welfare Direct 6/2026, paras 51 to 54). So a " + "family whose award the abolition reaches claims Universal Credit if " + "it would claim when moved (claims_uc_at_legacy_closure), from the " + "year its award starts to end. From 2027-28 the award has ended and " + "the family is moved off its legacy benefits (legacy_benefits_closed). " + "A claim saved for an appointee (esa_income_appointee_saving) is not " + "reached. As for the other working-age closures, a family on Pension " + "Credit, or a single claimant, or joint claimants both, over the " + "qualifying age for State Pension Credit, is not moved (SI 2014/1230 " + "reg 8(2B)). This reads reports, the household's country and the " + "saving, never an award. Reports by other benefit-unit members, who " + "claim in their own right, do not count." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2025/1148/article/3A", + "https://www.legislation.gov.uk/nisr/2025/176/article/3A", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/44", + "https://www.gov.uk/government/publications/la-welfare-direct-bulletins-2026/la-welfare-direct-62026", + ) + + def formula(benunit, period, parameters): + person = benunit.members + owners = person("is_claimant_or_partner", period) + + def reports(*variables): + amounts = sum(person(name, period) for name in variables) + return benunit.sum(amounts * owners) > 0 + + reached = benunit.any( + owners + & (person("esa_income_reported", period) > 0) + & (person("esa_income_payable_share", period) < 1) + ) + return reached & moves_working_age_awards( + reports, + benunit("meets_pension_credit_age_conditions", period), + benunit("is_mixed_age_couple", period), + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py index 51e8d1889f..2ae04645e4 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py @@ -1,11 +1,29 @@ from policyengine_uk.model_api import * -def legacy_award_is_closed(reports, pension_age_route, mixed_age_couple, dwp): +def moves_working_age_awards(reports, pension_age_route, mixed_age_couple): + """Whether closing a working-age legacy award moves its owners. + + Income Support, income-based JSA and income-related ESA are working-age + benefits; the rules ending legacy awards on a Universal Credit claim do + not apply to a single claimant, or joint claimants both, over the + qualifying age for State Pension Credit (SI 2014/1230 reg 8(2B)), and a + family on Pension Credit is not moved. + """ + on_pension_credit = reports("pension_credit_reported") + wholly_pension_age = pension_age_route & ~mixed_age_couple + return ~on_pension_credit & ~wholly_pension_age + + +def legacy_award_is_closed( + reports, pension_age_route, mixed_age_couple, dwp, esa_income_abolished +): """Existing annual migration proxy for one award's owners. This preserves the model's programme closure dates and Pension Credit exceptions. It does not simulate notices, deadlines or run-on periods. + esa_income_abolished says the owners report an income-related ESA award + whose claim has nothing left payable (esa_income_payable_share is 0). """ on_pension_credit = reports("pension_credit_reported") # A tax credit closure notice sent to Pension Credit, not Universal @@ -23,19 +41,19 @@ def legacy_award_is_closed(reports, pension_age_route, mixed_age_couple, dwp): & (not dwp.tax_credits.active) & ~pension_credit_route ) - # Income Support and income-based JSA are working-age benefits; the - # rules ending legacy awards on a Universal Credit claim do not apply - # to a single claimant, or joint claimants both, over the qualifying - # age for State Pension Credit (reg 8(2B)). - wholly_pension_age = pension_age_route & ~mixed_age_couple income_support_closed = reports("income_support_reported") & ( not dwp.income_support.active ) jsa_income_closed = reports("jsa_income_reported") & (not dwp.JSA.income.active) + # Income-related ESA closes once its abolition leaves none of a claim + # payable: from 2027-28, after the abolition reached every remaining + # award on 1 July 2026, or 1 October 2026 in Northern Ireland (SI + # 2025/1148 art. 3A; SR 2025/176 art. 3A). A claim saved for an + # appointee does not close. + working_age_closed = income_support_closed | jsa_income_closed return tax_credits_closed | ( - (income_support_closed | jsa_income_closed) - & ~on_pension_credit - & ~wholly_pension_age + (working_age_closed | esa_income_abolished) + & moves_working_age_awards(reports, pension_age_route, mixed_age_couple) ) @@ -56,11 +74,19 @@ class legacy_benefits_closed(Variable): "Credit but not Working Tax Credit that meet the Pension Credit age " "conditions. Pension-age and protected mixed-age families on Working " "Tax Credit are included; Income Support and income-based JSA " - "closures do not move a wholly pension-age family. Housing Benefit and income-related " - "Employment and Support Allowance join this list when the model ends " - "their working-age awards. Reports by other benefit-unit members do " - "not close or shield this award. This annual proxy does not model " - "actual notices, deadlines, run-on periods or Housing Benefit exceptions." + "closures do not move a wholly pension-age family. Income-related " + "Employment and Support Allowance joins this list from 2027-28: the " + "Secretary of State appointed 1 July 2026 (the Department, 1 October " + "2026 in Northern Ireland) for its abolition to reach every remaining " + "award, so from then a reported claim has nothing left payable " + "(esa_income_payable_share is 0), unless an appointee saving keeps " + "it. The same Pension Credit and pension-age exceptions apply. In " + "2026-27 a family whose award the abolition reaches can claim " + "Universal Credit (claims_uc_at_legacy_closure) but is not moved. " + "Housing Benefit joins this list when the model ends its working-age " + "awards. Reports by other benefit-unit members do not close or " + "shield this award. This annual proxy does not model actual notices, " + "deadlines, run-on periods or Housing Benefit exceptions." ) definition_period = YEAR reference = ( @@ -71,6 +97,7 @@ class legacy_benefits_closed(Variable): "https://www.legislation.gov.uk/uksi/2019/167/article/3A", "https://www.legislation.gov.uk/ukpga/2012/5/section/33", "https://www.legislation.gov.uk/uksi/2025/1148/article/3A", + "https://www.legislation.gov.uk/nisr/2025/176/article/3A", ) def formula(benunit, period, parameters): @@ -81,9 +108,15 @@ def reports(*variables): amounts = sum(person(name, period) for name in variables) return benunit.sum(amounts * owners) > 0 + esa_income_abolished = benunit.any( + owners + & (person("esa_income_reported", period) > 0) + & (person("esa_income_payable_share", period) <= 0) + ) return legacy_award_is_closed( reports, benunit("meets_pension_credit_age_conditions", period), benunit("is_mixed_age_couple", period), parameters(period).gov.dwp, + esa_income_abolished, ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py b/policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py index 9276abf287..9a25d4e955 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py @@ -12,7 +12,9 @@ class person_legacy_benefits_closed(Variable): "The existing annual legacy-closure proxy, scoped to the award owner. " "The claimant and partner share legacy_benefits_closed. Other members " "claiming in their own right use only their own reports and Pension " - "Credit qualifying age. Their reports neither close nor shield the " + "Credit qualifying age, and their income-related ESA closes with " + "their own claim's share (esa_income_payable_share). Their reports " + "neither close nor shield the " "couple's award, and the couple's reports do not close theirs. No new " "notice, deadline, run-on or legal relationship inference is made." ) @@ -27,8 +29,15 @@ def reports(*variables): return sum(person(name, period) for name in variables) > 0 pension_age = person("has_attained_state_pension_credit_qualifying_age", period) + esa_income_abolished = (person("esa_income_reported", period) > 0) & ( + person("esa_income_payable_share", period) <= 0 + ) own_closed = legacy_award_is_closed( - reports, pension_age, pension_age & False, parameters(period).gov.dwp + reports, + pension_age, + pension_age & False, + parameters(period).gov.dwp, + esa_income_abolished, ) return where( person("is_claimant_or_partner", period), diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py index 05e75d08f5..c313e3b437 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py @@ -7,11 +7,14 @@ class would_claim_uc_at_legacy_closure(Variable): label = "Would claim Universal Credit when its legacy benefits close" documentation = ( "Whether this family would claim Universal Credit once a legacy " - "means-tested benefit it receives closes (legacy_benefits_closed). " + "means-tested benefit it receives closes (legacy_benefits_closed), " + "or once the abolition of income-related ESA reaches the claimant's " + "or partner's award (esa_income_abolition_reached, from 2026-27). " "Datasets draw it at DWP's Move to Universal Credit claim rates by " "legacy benefit combination. It does not affect years before the " - "closure, when would_claim_uc alone decides the Universal Credit " - "claim. Defaults to True, like would_claim_uc." + "closure or the abolition, when would_claim_uc alone decides the " + "Universal Credit claim. Defaults to True, like would_claim_uc, so a " + "family whose data carries no draw claims." ) definition_period = YEAR default_value = True From 5091a31a48f1063cf79f50a92135c031e2939a79 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:26:24 -0500 Subject: [PATCH 08/23] Test the abolition's Universal Credit claim, closure and shared saved claim - esa_income_abolition.yaml: share cases opt out of the claim; new cases for the claim (GB, NI, couples, a protected mixed-age couple), the 2027-28 move off legacy benefits, saved claims, a saved partner who reports nothing, and the Pension Credit age guard. - Property tests: an independent reading of each award from the regulations, one share per couple, saving a non-reporter changes nothing, and the claim path never gives income-related ESA. - The UC closure and work-screen references model the claim; families in tests about other rules set would_claim_uc_at_legacy_closure false or the saving. - disable_simulated_benefits keeps an outside adult on the ESA it pays. - own_report_is_paid reads the benefit unit population for the award. Co-Authored-By: Claude Opus 5.5 --- .../tests/legacy_award_work_reference.py | 33 +- .../esa_income/esa_income_abolition.yaml | 408 +++++++++++++++++- .../esa_income_remunerative_work.yaml | 6 + .../dwp/pension_credit/mixed_age_couples.yaml | 4 + .../person_legacy_benefits_closed.yaml | 3 +- .../tests/test_disable_simulated_benefits.py | 31 +- .../test_esa_income_abolition_properties.py | 350 ++++++++++----- .../test_legacy_award_work_properties.py | 8 +- .../tests/test_mixed_age_couple_properties.py | 4 + .../test_uc_legacy_closure_properties.py | 77 +++- .../variables/gov/dwp/_legacy_award_payee.py | 7 +- 11 files changed, 773 insertions(+), 158 deletions(-) diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 318019bded..83d0742564 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -181,10 +181,41 @@ def reports(variable): return not all(adult["age"] >= 70 for adult in couple) +def esa_abolition_claim(couple, parameters): + """Whether the claimant and partner claim Universal Credit when the + abolition of income-related ESA reaches their award, which ends their + reports (esa_income_report_closed). + + The abolition reaches every remaining award on 1 July 2026 in England + (SI 2025/1148 art. 3A), the households' default country, and no award + here is saved for an appointee, so it reaches any award reported in a + year with less than all of it payable. would_claim_uc_at_legacy_closure + keeps its default, true. The conditions are those of the other + working-age closures: no Pension Credit report, and not all pension age. + """ + payable = parameters.gov.dwp.ESA.income.awards_payable.great_britain + reports_award = any(m.get("esa_income_reported", 0) > 0 for m in couple) + on_pension_credit = any(m.get("pension_credit_reported", 0) > 0 for m in couple) + if not reports_award or payable >= 1 or on_pension_credit: + return False + assert all(not 65 <= adult["age"] < 70 for adult in couple), couple + return not all(adult["age"] >= 70 for adult in couple) + + +def couple_report_closed(couple, reported, parameters): + """Whether the claimant's and partner's own reports of an award have + closed: their legacy benefits have closed, or, for income-related ESA, + they claim Universal Credit when its abolition reaches the award.""" + closed = legacy_benefits_closed(couple, [], parameters) + if reported == "esa_income_reported": + closed = closed or esa_abolition_claim(couple, parameters) + return closed + + def candidates(couple, others, reported, parameters): """Indexes (into couple + others) of members who could be the claimant.""" members = couple + others - couple_closed = legacy_benefits_closed(couple, [], parameters) + couple_closed = couple_report_closed(couple, reported, parameters) open_reports = [ member.get(reported, 0) > 0 and not ( diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml index ecfda8debe..b8a0d4eda5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml @@ -1,23 +1,32 @@ -# The abolition of income-related ESA (Welfare Reform Act 2012 s.33(1)(b); -# NI: Welfare Reform (NI) Order 2015 art. 39(1)(b)) comes into force for every -# remaining old style ESA award on 1 July 2026 in Great Britain (SI 2025/1148 -# art. 3A(1), inserted by SI 2026/409) and on 1 October 2026 in Northern -# Ireland (SR 2025/176 art. 3A(1), inserted by SR 2026/115). +# The Secretary of State appointed 1 July 2026 for the abolition of +# income-related ESA (Welfare Reform Act 2012 s.33(1)(b)) to reach every +# remaining old style ESA award in Great Britain (SI 2025/1148 art. 3A(1), +# inserted by SI 2026/409). The Department appointed 1 October 2026 in +# Northern Ireland (Welfare Reform (NI) Order 2015 art. 39(1)(b); SR 2025/176 +# art. 3A(1), inserted by SR 2026/115). # # Each model year is the fiscal year from 6 April to 5 April; 2026-27 has 365 # days. A Great Britain award is payable for the 86 days from 6 April to 30 # June 2026 (25 + 31 + 30) and a Northern Ireland award for the 178 days from # 6 April to 30 September 2026 (25 + 31 + 30 + 31 + 31 + 30). So 2026-27 pays # the annual award times 86/365 = 0.235616 or 178/365 = 0.487671, and -# 2027-28 pays nothing. +# 2027-28 pays nothing. Art. 3A(1)(b) leaves an award already due to end at +# the end of a two-week run-on to that schedule; the annual model pays every +# award to the general date. +# +# When the abolition reaches the claimant's or partner's award, the family +# claims Universal Credit if would_claim_uc_at_legacy_closure (default true), +# and the claim ends the award. From 2027-28 the award has ended and the +# family is moved off its legacy benefits (legacy_benefits_closed). Cases +# that test the 2026-27 share set would_claim_uc_at_legacy_closure to false. # # Art. 3A(2) saves an award for an appointee (esa_income_appointee_saving). -# Art. 3A(3): a Universal Credit claim still ends a saved award. A +# A couple's award is one award, saved when either member who reports it is +# saved. Art. 3A(3): a Universal Credit claim still ends a saved award. A # contributory allowance continues as new style ESA (esa_contrib). # # Unless a case says otherwise, nobody has capital, so there is no tariff -# income and the annual award is the reported amount, and the family does -# not claim Universal Credit. +# income and the annual award is the reported amount. - name: Great Britain 2025-26, before the abolition, pays the whole award period: 2025 @@ -54,11 +63,16 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] region: LONDON output: + # The family does not claim Universal Credit when the abolition reaches + # its award, so it keeps the award to 30 June 2026. + esa_income_abolition_reached: true + claims_uc_at_legacy_closure: false esa_income_payable_share: 0.235616 esa_income_report_closed: false esa_income_eligible: true @@ -79,11 +93,15 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] region: LONDON output: + # The award has ended, so DWP has moved the family off its legacy + # benefits. + legacy_benefits_closed: true esa_income_payable_share: 0 esa_income_report_closed: true esa_income_eligible: false @@ -106,6 +124,7 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] @@ -126,6 +145,7 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] @@ -166,11 +186,13 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] region: NORTHERN_IRELAND output: + esa_income_abolition_reached: true esa_income_payable_share: 0.487671 esa_income_report_closed: false # 5,000 x 178 / 365 = 2,438.36 @@ -190,11 +212,13 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] region: NORTHERN_IRELAND output: + legacy_benefits_closed: true esa_income_payable_share: 0 esa_income_report_closed: true esa_income: 0 @@ -212,6 +236,7 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] @@ -242,8 +267,11 @@ members: [claimant] region: LONDON output: - # Art. 3A(2) treats the abolition as not in force for this award. + # Art. 3A(2) treats the abolition as not in force for this award, so it + # neither prompts a claim nor moves the family. esa_income_payable_share: 1 + esa_income_abolition_reached: false + legacy_benefits_closed: false esa_income_report_closed: false esa_income: 5_000 claimant_or_partner_esa_income: 5_000 @@ -269,6 +297,8 @@ output: # SR 2025/176 art. 3A(2). esa_income_payable_share: 1 + esa_income_abolition_reached: false + claims_universal_credit: false esa_income: 5_000 - name: A Universal Credit claim still ends an award saved for an appointee @@ -334,6 +364,7 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] @@ -358,6 +389,7 @@ benunit: members: [claimant] would_claim_uc: false + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] @@ -440,6 +472,10 @@ members: [claimant, other] region: LONDON output: + # The adult claims in their own right, so their report neither prompts + # the family's claim nor is ended by it. + esa_income_abolition_reached: false + claims_universal_credit: false esa_income_report_closed: [false, false] # 2,000 x 86 / 365 = 471.23, on the adult's own claim. esa_income: 471.23 @@ -468,10 +504,12 @@ members: [claimant, other] region: LONDON output: + # Their own award has ended, which closes their own legacy benefits but + # not the family's. + person_legacy_benefits_closed: [false, true] + legacy_benefits_closed: false esa_income_report_closed: [true, true] esa_income: 0 - # Their award has ended, so they are not on income-related ESA - # (own_report_is_paid reads esa_income_report_closed). is_on_income_related_esa: [false, false] - name: A couple's award saved for the claimant who reports it stays payable in 2027-28 @@ -494,7 +532,351 @@ members: [claimant, partner] region: LONDON output: - esa_income_payable_share: [1, 0] + # The couple's award is one award, so both members share its share. + esa_income_payable_share: [1, 1] + esa_income_abolition_reached: false + legacy_benefits_closed: false esa_income: 5_000 claimant_or_partner_esa_income: 5_000 is_on_income_related_esa: [true, false] + +- name: Great Britain 2026-27, a family whose award the abolition reaches claims Universal Credit, ending the award and its Housing Benefit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + housing_benefit_reported: 4_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 5_200 + output: + # would_claim_uc_at_legacy_closure defaults to true. The claim ends the + # income-related ESA award (esa_income_report_closed) and the Housing + # Benefit award (SI 2014/1230 reg 8(2A)). + esa_income_abolition_reached: true + legacy_benefits_closed: false + claims_uc_at_legacy_closure: true + claims_universal_credit: true + esa_income_report_closed: true + esa_income: 0 + is_on_income_related_esa: false + housing_benefit_eligible: false + housing_benefit: 0 + +- name: Great Britain 2026-27, a family that does not claim keeps its Housing Benefit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + housing_benefit_reported: 4_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 5_200 + output: + claims_universal_credit: false + esa_income: 1_178.08 + housing_benefit_eligible: true + +- name: Great Britain 2027-28, the move off legacy benefits ends a family's Housing Benefit even without a claim + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + housing_benefit_reported: 4_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 5_200 + output: + # SI 2014/1230 reg 46(1)(a): with no claim by the deadline, every + # existing award ends. + legacy_benefits_closed: true + claims_universal_credit: false + esa_income: 0 + housing_benefit_eligible: false + housing_benefit: 0 + +- name: Great Britain 2027-28, a family the abolition moved claims Universal Credit + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + output: + legacy_benefits_closed: true + claims_uc_at_legacy_closure: true + claims_universal_credit: true + esa_income: 0 + +- name: Northern Ireland 2026-27, a family whose award the abolition reaches claims Universal Credit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: NORTHERN_IRELAND + output: + esa_income_abolition_reached: true + claims_universal_credit: true + esa_income_report_closed: true + esa_income: 0 + +- name: A saved award prompts no Universal Credit claim in 2026-27 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + esa_income_appointee_saving: true + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + output: + # would_claim_uc_at_legacy_closure defaults to true, but the abolition + # has not reached a saved award. + esa_income_abolition_reached: false + claims_uc_at_legacy_closure: false + esa_income: 5_000 + +- name: Before the abolition a couple's award counts its two reports once + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 2_000 + partner: + age: 38 + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + households: + household: + members: [claimant, partner] + region: LONDON + output: + # One claim: 2,000 + 3,000. + esa_income: 5_000 + +- name: Saving a partner who reports nothing does not save the claimant's award + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + partner: + age: 38 + esa_income_appointee_saving: true + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant, partner] + region: LONDON + output: + esa_income_payable_share: [0.235616, 0.235616] + esa_income_abolition_reached: true + # 5,000 x 86 / 365 = 1,178.08 + esa_income: 1_178.08 + +- name: Saving a partner who reports nothing does not stop the couple's claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + partner: + age: 38 + esa_income_appointee_saving: true + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + households: + household: + members: [claimant, partner] + region: LONDON + output: + esa_income_abolition_reached: true + claims_universal_credit: true + esa_income: 0 + +- name: The claimant's saving keeps the couple's whole award after the abolition + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 2_000 + esa_income_appointee_saving: true + partner: + age: 38 + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + households: + household: + members: [claimant, partner] + region: LONDON + output: + # A couple's award is one award: the saving keeps it, the partner's + # report included. + esa_income_payable_share: [1, 1] + esa_income_report_closed: [false, false] + esa_income_abolition_reached: false + legacy_benefits_closed: false + esa_income: 5_000 + +- name: The abolition moves a mixed-age couple, whose younger member reports the award + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 5_000 + partner: + age: 70 + is_claimant_or_partner: true + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + households: + household: + members: [claimant, partner] + region: LONDON + output: + is_mixed_age_couple: true + esa_income_abolition_reached: true + claims_uc_at_legacy_closure: true + esa_income: 0 + +- name: As for Income Support and income-based JSA, the abolition does not move a single claimant over the Pension Credit qualifying age + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: LONDON + output: + # SI 2014/1230 reg 8(2B). The award still ends. + esa_income_abolition_reached: false + legacy_benefits_closed: false + claims_uc_at_legacy_closure: false + esa_income_report_closed: true + esa_income: 0 + +- name: A protected mixed-age couple that claims when the abolition reaches its award leaves the pension-age route + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 5_000 + partner: + age: 70 + is_claimant_or_partner: true + housing_benefit_reported: 4_000 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + has_mixed_age_couple_pension_credit_saving: true + households: + household: + members: [claimant, partner] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 5_200 + output: + # The couple claims jointly (UC Regs 2013 reg 3(2)(a)) and is entitled, + # so it leaves the pension-age route and its Housing Benefit ends. + meets_pension_credit_age_conditions: true + esa_income_abolition_reached: true + claims_uc_at_legacy_closure: true + left_pension_route_at_legacy_closure: true + housing_benefit_eligible: false + housing_benefit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml index 964007f0d5..cde3e92417 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml @@ -165,6 +165,9 @@ benunit: members: [claimant] would_claim_uc: false + # The family does not claim Universal Credit when the abolition of + # income-related ESA reaches the award, so the work screen decides. + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] @@ -188,6 +191,9 @@ benunit: members: [claimant] would_claim_uc: false + # The family does not claim Universal Credit when the abolition of + # income-related ESA reaches the award, so the work screen decides. + would_claim_uc_at_legacy_closure: false households: household: members: [claimant] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/mixed_age_couples.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/mixed_age_couples.yaml index 83e9f1ca82..2f47ac75d4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/mixed_age_couples.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/mixed_age_couples.yaml @@ -105,6 +105,10 @@ benunit: members: [older, younger] would_claim_uc: false + # The couple does not claim Universal Credit when the abolition of + # income-related ESA reaches the award, so it keeps the continuing + # Housing Benefit award this case tests. + would_claim_uc_at_legacy_closure: false households: household: members: [older, younger] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml index 352e12a1be..5fe7a5a212 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml @@ -7,7 +7,8 @@ "claimant": { "age": 30, "is_claimant_or_partner": true, - "esa_income_reported": 5000 + "esa_income_reported": 5000, + "esa_income_appointee_saving": true }, "partner": { "age": 30, diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 8c7b89d576..564383444d 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -343,6 +343,21 @@ def test_no_family_is_moved_off_its_legacy_benefits(reformed, unreformed, year): assert by_family(reformed, "legacy_benefits_closed", year) == dict.fromkeys( NAMES, False ) + # The abolition of income-related ESA reaches the claimant's own award + # from 2026-27 (SI 2025/1148 art. 3A), which would prompt a Universal + # Credit claim; the reform holds that too, so it prompts no claim. + reached = year > DATASET_YEAR + assert by_family(unreformed, "esa_income_abolition_reached", year) == { + "excluded_adult": False, + "excluded_adult_no_award": False, + "own_esa": reached, + "own_jsa": False, + "own_esa_over_tariff": reached, + "excluded_income_support": False, + } + assert by_family(reformed, "claims_uc_at_legacy_closure", year) == dict.fromkeys( + NAMES, False + ) ten_years = list(range(DATASET_YEAR, DATASET_YEAR + YEARS_IN_FUTURE)) for variable in NOT_MOVED: assert known_years(reformed, variable) == ten_years, variable @@ -363,18 +378,12 @@ def test_each_person_is_on_their_own_award(reformed, year): str(year) ).gov.dwp.JSA.income.active assert jsa_open == (year == DATASET_YEAR) - # Likewise their own income-related ESA, which its abolition ends from 1 - # July 2026 (SI 2025/1148 art. 3A): payable for part of 2026-27, none of - # 2027-28. - esa_open = ( - reformed.tax_benefit_system.parameters( - str(year) - ).gov.dwp.ESA.income.awards_payable.great_britain - > 0 - ) - assert esa_open == (year < DATASET_YEAR + 2) + # The reform pays their income-related ESA in every year too, after its + # abolition on 1 July 2026 (SI 2025/1148 art. 3A) included. The award it + # holds, not the abolition's closure of their report, decides whether it + # is payable to them, as for the claimant and partner. assert not on_esa["excluded_adult_no_award_0"] - assert on_esa["excluded_adult_no_award_1"] == esa_open + assert on_esa["excluded_adult_no_award_1"] assert not on_jsa["excluded_adult_no_award_0"] assert on_jsa["excluded_adult_no_award_1"] == jsa_open assert on_esa["own_esa_0"] and not on_jsa["own_esa_0"] diff --git a/policyengine_uk/tests/test_esa_income_abolition_properties.py b/policyengine_uk/tests/test_esa_income_abolition_properties.py index 721b2975b5..3022cb2d95 100644 --- a/policyengine_uk/tests/test_esa_income_abolition_properties.py +++ b/policyengine_uk/tests/test_esa_income_abolition_properties.py @@ -1,17 +1,26 @@ """Property-based tests for the abolition of income-related ESA (#2562). -The abolition of income-related ESA (Welfare Reform Act 2012 s.33(1)(b)) -came into force for every remaining old style ESA award on 1 July 2026 in -Great Britain (SI 2025/1148 art. 3A, inserted by SI 2026/409) and on 1 -October 2026 in Northern Ireland (SR 2025/176 art. 3A, inserted by SR -2026/115). Art. 3A(2) saves an award for an appointee -(esa_income_appointee_saving); art. 3A(3) lets a Universal Credit claim end -a saved award. A contributory allowance continues as new style ESA. - -Each generated population runs in one simulation three times over: with the -saving as drawn, with every award saved, and with none saved. Saving every -award pays it in full in every year, as the rule before the abolition did -(origin/main); a reform that keeps the parameter at 1 gives the same outputs +The Secretary of State appointed 1 July 2026 for the abolition of +income-related ESA (Welfare Reform Act 2012 s.33(1)(b)) to reach every +remaining old style ESA award in Great Britain (SI 2025/1148 art. 3A, +inserted by SI 2026/409), and the Department appointed 1 October 2026 in +Northern Ireland (SR 2025/176 art. 3A, inserted by SR 2026/115). Art. 3A(2) +saves an award for an appointee (esa_income_appointee_saving); a couple's +award is one award, saved when either member who reports it is saved. +Art. 3A(3) lets a Universal Credit claim end a saved award. A contributory +allowance continues as new style ESA. + +When the abolition reaches the claimant's or partner's award +(esa_income_abolition_reached, from 2026-27), the family claims Universal +Credit if would_claim_uc_at_legacy_closure. From 2027-28 the award has ended +and the family is moved off its legacy benefits (legacy_benefits_closed). + +Each generated population runs in one simulation five times over: with the +saving and claim flag as drawn; with every award saved; with none saved; +with the saving of every member who reports nothing flipped; and with +nobody claiming when moved. Saving every award pays it in full in every +year, as the rule before the abolition did; a reform that keeps the +parameter at 1 gives the same outputs (test_keeping_awards_payable_is_saving_every_award). Invariants, for any generated population of working-age families in @@ -20,26 +29,39 @@ 1. Parameter: each fiscal year's gov.dwp.ESA.income.awards_payable value is the share of 6 April to 5 April falling before the statutory date, counted here from the two dates (1 before 2026-27, 0 after). -2. Bounds: 0 <= esa_income_payable_share <= 1, and it is 1 for every saved - member in every year. -3. Abolition: from 2027-28, a benefit unit with no saved member pays no - income-related ESA (esa_income and claimant_or_partner_esa_income are 0) - and none of its members is on it; and in every benefit unit, a member who - is neither the claimant nor the partner and has no saving is not on it. -4. Contributory ESA never changes: esa_contrib is the same with and without - the saving, in every year. -5. Differential against the rule before the abolition: before 2026-27 every - output is the same whatever is saved; in every year a benefit unit with no - saved member gets exactly its jurisdiction's payable share of the award - the rule before the abolition pays; and the abolition never raises - anyone's income-related ESA. +2. Bounds: 0 <= esa_income_payable_share <= 1; the claimant and partner + share one value; and it is 1 for every member of a saved claim. +3. Abolition: from 2027-28 an unsaved claim pays nothing: a family whose + couple claim is unsaved has no claimant_or_partner_esa_income, a member + outside the couple whose claim is unsaved is not on income-related ESA, + and with no saving nobody gets any. +4. Contributory ESA never changes, whatever is saved or claimed. +5. Before 2026-27 every output is the same whatever is saved or claimed; + the abolition never raises anyone's income-related ESA; and a family + that claims nothing and saves nothing gets exactly its jurisdiction's + share of the award before the abolition. 6. Monotonic: turning the saving on for any members never lowers a benefit unit's esa_income or esa. -7. Status: a member who is neither the claimant nor the partner is on - income-related ESA only while the award on their report is open. +7. Status: a member outside the couple is on income-related ESA only while + the award on their report is open. +8. Saving a member who reports nothing changes no output. +9. The claim path never gives a family income-related ESA: turning + would_claim_uc_at_legacy_closure on never raises esa_income, and a family + that claims when the abolition reaches its award has no + claimant_or_partner_esa_income that year. +10. A saved claim never prompts the claim or the closure: a family whose + couple claim is saved is neither reached nor moved, and a member outside + the couple with a saved claim keeps their legacy benefits open. +11. Independent reading: for families with no work, esa_income, + claims_universal_credit and each outside member's status equal amounts + worked here from the regulations (the £6,000 tariff threshold, £1 a week + for each £250 or part above it and the £16,000 limit, ESA Regs 2008 regs + 110 and 118), the dates and the saving, without the model's code. This + bounds a couple's claim by one share and keeps a saved claim whole. """ import datetime +import math import numpy as np import pytest @@ -80,13 +102,17 @@ "single_with_other": ["claimant", "other"], "couple_with_other": ["claimant", "partner", "other"], } -REPORT = st.sampled_from([0.0, 2_000.0, 6_000.0]) +COUPLE = ("claimant", "partner") +REPORT = st.sampled_from([0.0, 2_000.0, 3_000.0, 6_000.0]) BENUNIT_OUTPUTS = [ "esa_income", "claimant_or_partner_esa_income", "esa_income_eligible", "esa", "claims_universal_credit", + "claims_uc_at_legacy_closure", + "esa_income_abolition_reached", + "legacy_benefits_closed", ] PERSON_OUTPUTS = [ "esa_contrib", @@ -94,12 +120,20 @@ "esa_income_report_closed", "is_on_income_related_esa", "is_claimant_or_partner", + "person_legacy_benefits_closed", ] -SAVING_MODES = ("drawn", "every", "none") +MODES = ("drawn", "every", "none", "flipped", "no_claim") +# Statutory amounts for the independent reading: ESA Regs 2008 reg 110 (the +# £16,000 limit) and reg 118 (£1 a week for each £250, or part of £250, +# above £6,000), annualised over 52 weeks as the model does. +CAPITAL_LIMIT = 16_000 +TARIFF_THRESHOLD = 6_000 +TARIFF_STEP = 250 +WEEKS = 52 @st.composite -def families(draw): +def families(draw, work=True): roles = SHAPES[draw(st.sampled_from(sorted(SHAPES)))] members = [] for role in roles: @@ -114,10 +148,12 @@ def families(draw): ), employment_income=( draw(st.sampled_from([0.0, 0.0, 4_000.0, 20_000.0])) - if adult + if adult and work else 0.0 ), - weekly_hours=draw(st.sampled_from([0, 10, 30])) if adult else 0, + weekly_hours=( + draw(st.sampled_from([0, 10, 30])) if adult and work else 0 + ), saved=draw(st.booleans()) if adult else False, ) ) @@ -125,8 +161,11 @@ def families(draw): members=members, region=draw(st.sampled_from(REGIONS)), # Around the £6,000 tariff threshold and £16,000 limit. - savings=draw(st.sampled_from([0.0, 7_000.0, 20_000.0])), + savings=draw( + st.sampled_from([0.0, 6_000.0, 6_100.0, 7_000.0, 16_000.0, 20_000.0]) + ), would_claim_uc=draw(st.booleans()), + claims_when_moved=draw(st.booleans()), ) @@ -134,23 +173,23 @@ def every_year(value): return {year: value for year in YEARS} -def with_saving(units, mode): - """The units with each member's saving as drawn, on for every adult, or - off for everyone.""" +def in_mode(units, mode): + """The units under a saving and claim mode (see MODES).""" + + def saved(member, unit): + if mode == "every": + return member["role"] != "child" + if mode == "none": + return False + if mode == "flipped" and member["esa_income_reported"] == 0: + return member["role"] != "child" and not member["saved"] + return member["saved"] + return [ dict( unit, - members=[ - dict( - member, - saved={ - "drawn": member["saved"], - "every": member["role"] != "child", - "none": False, - }[mode], - ) - for member in unit["members"] - ], + claims_when_moved=unit["claims_when_moved"] and mode != "no_claim", + members=[dict(m, saved=saved(m, unit)) for m in unit["members"]], ) for unit in units ] @@ -166,9 +205,7 @@ def situation(units): # for the rest of the simulation. people[name] = { "age": every_year(member["age"]), - "is_claimant_or_partner": every_year( - member["role"] in ("claimant", "partner") - ), + "is_claimant_or_partner": every_year(member["role"] in COUPLE), "current_education": every_year("NOT_IN_EDUCATION"), "esa_income_reported": every_year(member["esa_income_reported"]), "esa_contrib_reported": every_year(member["esa_contrib_reported"]), @@ -180,6 +217,7 @@ def situation(units): benunits[f"b{i}"] = { "members": names, "would_claim_uc": every_year(unit["would_claim_uc"]), + "would_claim_uc_at_legacy_closure": every_year(unit["claims_when_moved"]), "claims_all_entitled_benefits": every_year(False), } households[f"h{i}"] = { @@ -201,18 +239,18 @@ def calculate(units, reform=None): } -def calculate_each_saving(units): - """Each output for the units under each saving mode, from one - simulation holding all three copies of the population.""" +def calculate_each_mode(units, modes=MODES): + """Each output for the units under each mode, from one simulation + holding a copy of the population for each.""" n = len(units) p = sum(len(unit["members"]) for unit in units) - combined = [u for mode in SAVING_MODES for u in with_saving(units, mode)] + combined = [u for mode in modes for u in in_mode(units, mode)] values = calculate(combined) - out = {mode: {year: {} for year in YEARS} for mode in SAVING_MODES} + out = {mode: {year: {} for year in YEARS} for mode in modes} for year in YEARS: for name in BENUNIT_OUTPUTS + PERSON_OUTPUTS: size = n if name in BENUNIT_OUTPUTS else p - for k, mode in enumerate(SAVING_MODES): + for k, mode in enumerate(modes): out[mode][year][name] = values[year][name][k * size : (k + 1) * size] return out @@ -222,12 +260,22 @@ def members_of(units): return np.array([i for i, unit in enumerate(units) for _ in unit["members"]]) -def saved_people(units): - return np.array([m["saved"] for unit in units for m in unit["members"]]) +def couple_claim_saved(unit): + return any( + m["role"] in COUPLE and m["esa_income_reported"] > 0 and m["saved"] + for m in unit["members"] + ) + + +def claim_saved(member, unit): + """Whether the claim this member belongs to is saved.""" + if member["role"] in COUPLE: + return couple_claim_saved(unit) + return member["esa_income_reported"] > 0 and member["saved"] -def saved_units(units): - return np.array([any(m["saved"] for m in unit["members"]) for unit in units]) +def saved_claim_people(units): + return np.array([claim_saved(m, u) for u in units for m in u["members"]]) def fiscal_year_share_before(date, year): @@ -238,20 +286,55 @@ def fiscal_year_share_before(date, year): return before / (end - start).days -def jurisdiction_share(units, year): - return np.array( - [ - fiscal_year_share_before( - ABOLITION_DATES[ - "northern_ireland" - if unit["region"] == "NORTHERN_IRELAND" - else "great_britain" - ], - year, - ) - for unit in units - ] +def unit_share(unit, year): + jurisdiction = ( + "northern_ireland" if unit["region"] == "NORTHERN_IRELAND" else "great_britain" ) + return fiscal_year_share_before(ABOLITION_DATES[jurisdiction], year) + + +def jurisdiction_share(units, year): + return np.array([unit_share(unit, year) for unit in units]) + + +def tariff_income(capital): + """ESA Regs 2008 reg 118, annualised.""" + excess = max(0.0, capital - TARIFF_THRESHOLD) + return math.ceil(excess / TARIFF_STEP) * WEEKS + + +def independent_reading(unit, year): + """esa_income, the Universal Credit claim and each member outside the + couple's status, for a family nobody in which works, worked from the + regulations, the dates and the saving alone.""" + share = unit_share(unit, year) + capital = unit["savings"] + tariff = tariff_income(capital) + within_limit = capital <= CAPITAL_LIMIT + couple = [m for m in unit["members"] if m["role"] in COUPLE] + couple_reported = sum(m["esa_income_reported"] for m in couple) + couple_saved = couple_claim_saved(unit) + couple_share = 1.0 if couple_saved else share + # The abolition reaches an unsaved couple claim from the year less than + # all of it is payable; from the year none is, the family is moved. + reached = couple_reported > 0 and not couple_saved and share < 1 + claims = unit["would_claim_uc"] or (reached and unit["claims_when_moved"]) + # A claim ends the couple's reports; the abolition pays the rest for + # the claim's share of the year. + couple_award = 0.0 + if couple_reported > 0 and within_limit and not claims: + couple_award = max(0.0, couple_reported - tariff) * couple_share + outside_awards, outside_on = [], [] + for m in unit["members"]: + if m["role"] in COUPLE: + continue + own_share = 1.0 if claim_saved(m, unit) else share + own = within_limit and m["esa_income_reported"] > tariff + outside_awards.append( + max(0.0, m["esa_income_reported"] - tariff) * own_share if own else 0.0 + ) + outside_on.append(bool(own and own_share > 0)) + return couple_award + sum(outside_awards), claims, outside_on @pytest.fixture(scope="module") @@ -283,6 +366,14 @@ def test_day_shares_match_the_hand_counted_days(): ) +def test_tariff_income_matches_the_hand_counted_steps(): + # £7,000 is four £250 steps above £6,000; £6,100 is part of one. + assert tariff_income(7_000) == 4 * 52 + assert tariff_income(6_100) == 52 + assert tariff_income(6_000) == 0 + assert tariff_income(16_000) == 40 * 52 + + def test_keeping_awards_payable_is_saving_every_award(): """The parameter and the saving are two routes to a payable award: a reform keeping awards_payable at 1 gives what saving every award gives.""" @@ -295,21 +386,24 @@ def test_keeping_awards_payable_is_saving_every_award(): region=region, savings=7_000.0, would_claim_uc=False, + claims_when_moved=True, ) for region in ["LONDON", "NORTHERN_IRELAND"] ] + [ dict( members=[ dict(adult, role="claimant", age=40, esa_income_reported=2_000.0), + dict(adult, role="partner", age=38, esa_income_reported=3_000.0), dict(adult, role="other", age=30, esa_income_reported=6_000.0), ], region="WALES", savings=0.0, - would_claim_uc=True, + would_claim_uc=False, + claims_when_moved=True, ) ] kept = calculate(units, reform=KEEP_AWARDS_PAYABLE) - saved = calculate(with_saving(units, "every")) + saved = calculate(in_mode(units, "every")) for year in YEARS: for name in BENUNIT_OUTPUTS + PERSON_OUTPUTS: assert np.array_equal(kept[year][name], saved[year][name]), (year, name) @@ -318,57 +412,105 @@ def test_keeping_awards_payable_is_saving_every_award(): @PROPERTY_SETTINGS @given(st.lists(families(), min_size=1, max_size=8)) def test_abolition_invariants(units): - values = calculate_each_saving(units) + values = calculate_each_mode(units) benunit = members_of(units) - saved = saved_people(units) - unsaved_unit = ~saved_units(units) + saved_claim = saved_claim_people(units) + couple_saved = np.array([couple_claim_saved(unit) for unit in units]) + unsaved_unit = np.array( + [ + not any(claim_saved(m, unit) for m in unit["members"]) + and not any(m["saved"] for m in unit["members"]) + for unit in units + ] + ) for year in YEARS: - # a: the saving as drawn; k: every award kept; w: no award saved. - a, k, w = (values[mode][year] for mode in SAVING_MODES) - # 2. Bounds. + a, k, w, f, n = (values[mode][year] for mode in MODES) + couple = a["is_claimant_or_partner"].astype(bool) + outside = ~couple + # 2. Bounds, one share per couple, and 1 for a saved claim. share = a["esa_income_payable_share"] assert np.all((share >= 0) & (share <= 1)), year - assert np.all(share[saved] == 1), year + assert np.all(share[saved_claim] == 1), year + for i in range(len(units)): + shares = share[(benunit == i) & couple] + assert np.all(shares == shares[0]), (year, units[i]) # 4. Contributory ESA never changes. - assert np.array_equal(a["esa_contrib"], k["esa_contrib"]), year - assert np.array_equal(w["esa_contrib"], k["esa_contrib"]), year - # 5. The abolition never raises income-related ESA, and without a - # saving each award is the jurisdiction's share of the full award. - assert np.all(a["esa_income"] <= k["esa_income"] + 1e-6), year - assert np.all( - a["claimant_or_partner_esa_income"] - <= k["claimant_or_partner_esa_income"] + 1e-6 - ), year + for v in (a, w, f, n): + assert np.array_equal(v["esa_contrib"], k["esa_contrib"]), year + # 5. The abolition never raises income-related ESA. + for v in (a, w, f, n): + assert np.all(v["esa_income"] <= k["esa_income"] + 1e-6), year + assert np.all( + v["claimant_or_partner_esa_income"] + <= k["claimant_or_partner_esa_income"] + 1e-6 + ), year + # 5. With no claim and no saving, each award is the jurisdiction's + # share of the award before the abolition. (The no-claim copy keeps + # the drawn saving, so this reads families with nothing saved.) expected = jurisdiction_share(units, year) * k["esa_income"] - assert np.allclose(w["esa_income"], expected, atol=0.01), year - assert np.allclose( - a["esa_income"][unsaved_unit], expected[unsaved_unit], atol=0.01 - ), year + claims_anyway = np.array([unit["would_claim_uc"] for unit in units]) + plain = unsaved_unit & ~claims_anyway + assert np.allclose(n["esa_income"][plain], expected[plain], atol=0.01), year # 6. Turning the saving on never lowers ESA. for name in ["esa_income", "esa"]: assert np.all(a[name] >= w[name] - 1e-6), (year, name) assert np.all(k[name] >= a[name] - 1e-6), (year, name) # 7. An outside member is on it only while their award is open. - for v in (a, k, w): - outside = ~v["is_claimant_or_partner"].astype(bool) + for v in (a, k, w, f, n): on = v["is_on_income_related_esa"].astype(bool) closed = v["esa_income_report_closed"].astype(bool) assert not np.any(outside & on & closed), year - # 5. Before 2026-27 every output is the same whatever is saved. + # 8. Saving a member who reports nothing changes nothing. + for name in BENUNIT_OUTPUTS + PERSON_OUTPUTS: + assert np.array_equal(f[name], a[name]), (year, name) + # 9. The claim path never gives income-related ESA, and a family + # that claims when the abolition reaches its award has none. + assert np.all(a["esa_income"] <= n["esa_income"] + 1e-6), year + claimed_when_reached = a["claims_uc_at_legacy_closure"].astype(bool) & a[ + "esa_income_abolition_reached" + ].astype(bool) + assert np.all(a["claimant_or_partner_esa_income"][claimed_when_reached] == 0) + # 10. A saved claim prompts neither the claim nor the closure. No + # generated family reports another legacy benefit. + for v in (a, k, f, n): + assert not np.any( + v["esa_income_abolition_reached"].astype(bool) & couple_saved + ), year + assert not np.any(v["legacy_benefits_closed"].astype(bool) & couple_saved) + assert not np.any( + v["person_legacy_benefits_closed"].astype(bool) & outside & saved_claim + ), year + assert not np.any(k["esa_income_abolition_reached"]), year + assert not np.any(k["legacy_benefits_closed"]), year + # 5. Before 2026-27 every output is the same whatever is saved or claimed. for year in BEFORE: for name in BENUNIT_OUTPUTS + PERSON_OUTPUTS: - for mode in ("drawn", "none"): + for mode in ("drawn", "none", "flipped", "no_claim"): assert np.array_equal( values[mode][year][name], values["every"][year][name] ), (year, name, mode) # 3. After both dates, nothing is paid outside the saving. for year in AFTER: a, w = values["drawn"][year], values["none"][year] - assert np.all(a["esa_income"][unsaved_unit] == 0), year - assert np.all(a["claimant_or_partner_esa_income"][unsaved_unit] == 0), year + couple = a["is_claimant_or_partner"].astype(bool) + assert np.all(a["claimant_or_partner_esa_income"][~couple_saved] == 0), year on = a["is_on_income_related_esa"].astype(bool) - assert not np.any(on & unsaved_unit[benunit]), year - outside = ~a["is_claimant_or_partner"].astype(bool) - assert not np.any(on & outside & ~saved), year + assert not np.any(on & ~couple & ~saved_claim), year assert np.all(w["esa_income"] == 0), year assert not np.any(w["is_on_income_related_esa"].astype(bool)), year + + +@PROPERTY_SETTINGS +@given(st.lists(families(work=False), min_size=1, max_size=8)) +def test_awards_match_an_independent_reading(units): + values = calculate_each_mode(units, modes=("drawn",))["drawn"] + benunit = members_of(units) + for year in YEARS: + v = values[year] + outside = ~v["is_claimant_or_partner"].astype(bool) + on = v["is_on_income_related_esa"].astype(bool) + for i, unit in enumerate(units): + award, claims, outside_on = independent_reading(unit, year) + assert abs(float(v["esa_income"][i]) - award) < 0.01, (year, unit) + assert bool(v["claims_universal_credit"][i]) == claims, (year, unit) + assert on[(benunit == i) & outside].tolist() == outside_on, (year, unit) diff --git a/policyengine_uk/tests/test_legacy_award_work_properties.py b/policyengine_uk/tests/test_legacy_award_work_properties.py index 3cb5d833d2..87aae9aab5 100644 --- a/policyengine_uk/tests/test_legacy_award_work_properties.py +++ b/policyengine_uk/tests/test_legacy_award_work_properties.py @@ -10,7 +10,10 @@ closure (PolicyEngine/policyengine-uk#2119): income-based JSA is closed in 2026, so there a JSA report closes the family's legacy benefits and ends both awards unless the claimant and partner are all over Pension Credit - age; + age; and in 2026 the abolition of income-related ESA reaches the couple's + award, so the family claims Universal Credit + (would_claim_uc_at_legacy_closure keeps its default), which ends their + ESA reports under the same age and Pension Credit conditions; - more hours or more pay for anyone never makes a family eligible, in the years tested (2025 and 2026) and within the scope below. Before 6 April 2024 reg 99(3)(a) took a fixed Class 2 deduction once profit reached a @@ -42,6 +45,7 @@ from hypothesis import strategies as st from legacy_award_work_reference import ( + couple_report_closed, esa_screen, esa_weekly_earnings, jsa_screen, @@ -320,7 +324,7 @@ def test_member_outside_the_family_never_changes_the_couples_screen(units, year, for i, (couple, *_rest) in enumerate(without): assert not flags[ends[n + i] - 1] if any(a[reported] > 0 for a in couple): - closed = legacy_benefits_closed(couple, [], parameters) + closed = couple_report_closed(couple, reported, parameters) # An open couple report still selects the same work screen. # Once closed, only an outside member's separate claim can # be a candidate, so the aggregate screen may then change. diff --git a/policyengine_uk/tests/test_mixed_age_couple_properties.py b/policyengine_uk/tests/test_mixed_age_couple_properties.py index 812fad9ef6..de8cee86b1 100644 --- a/policyengine_uk/tests/test_mixed_age_couple_properties.py +++ b/policyengine_uk/tests/test_mixed_age_couple_properties.py @@ -188,6 +188,10 @@ def cases(draw): "benunit": { "would_claim_uc": draw(st.booleans()), "would_claim_pc": draw(st.booleans()), + # The reference formulas read would_claim_uc alone, so no family + # here claims when the abolition of income-related ESA reaches + # the older member's award in 2026-27. + "would_claim_uc_at_legacy_closure": False, }, "household": { "tenure_type": tenure, diff --git a/policyengine_uk/tests/test_uc_legacy_closure_properties.py b/policyengine_uk/tests/test_uc_legacy_closure_properties.py index 38d7d0b8ca..9c4773cc44 100644 --- a/policyengine_uk/tests/test_uc_legacy_closure_properties.py +++ b/policyengine_uk/tests/test_uc_legacy_closure_properties.py @@ -1,7 +1,10 @@ """Property-based tests for Universal Credit claims when legacy benefits close. Tax credits closed from 6 April 2025 and Income Support and income-based JSA -from 1 April 2026. DWP moved each family off its legacy benefits with a notice +from 1 April 2026. The abolition of income-related ESA reached every remaining +award on 1 July 2026 (SI 2025/1148 art. 3A), so a family whose award it +reaches can claim from 2026-27 and is moved from 2027-28, when none of the +award is payable. DWP moved each family off its legacy benefits with a notice covering all of them. A Universal Credit migration notice ends the awards on a claim (SI 2014/1230 reg 8) or at its deadline (reg 46). A tax credit closure notice sent families on Pension Credit, and families on Child Tax Credit but @@ -15,10 +18,14 @@ 1. Closure: legacy_benefits_closed holds exactly for a family that reports tax credits from 2025 and is not sent to Pension Credit, or reports Income - Support or income-based JSA from 2026, is not on Pension Credit and is not - wholly pension age. -2. Claim: claims_universal_credit is would_claim_uc, or the closure with the - closure claim flag; Universal Credit is paid only to claiming families. + Support or income-based JSA from 2026, or income-related ESA from 2027, is + not on Pension Credit and is not wholly pension age. The ESA abolition + reaches (esa_income_abolition_reached) a family reporting income-related + ESA from 2026 under the same Pension Credit conditions. +2. Claim: claims_universal_credit is would_claim_uc, or the closure or the + abolition reaching the award with the closure claim flag; Universal Credit + is paid only to claiming families, and a claiming family gets no + income-related ESA. 3. Migration ends every legacy award: a family moved off legacy benefits gets no tax credits, Income Support, income-related ESA or income-based JSA. It gets no Housing Benefit unless it keeps the pension-age route by not @@ -29,8 +36,9 @@ 4. Exclusive: no family gets Universal Credit with Pension Credit or with Housing Benefit. 5. Metamorphic, the flag is inert unless it decides a claim or age waiver: negating - would_claim_uc_at_legacy_closure changes no benefit for a family not - moved off legacy benefits, or for a working-age one that would claim + would_claim_uc_at_legacy_closure changes no benefit for a family neither + moved off legacy benefits nor reached by the ESA abolition, or for a + working-age one that would claim Universal Credit anyway and has a qualifying working-age claimant. In particular nothing changes in 2024, before any closure. @@ -97,6 +105,7 @@ ] OUTPUTS = BENEFITS + [ "legacy_benefits_closed", + "esa_income_abolition_reached", "claims_universal_credit", "left_pension_route_at_legacy_closure", "meets_pension_credit_age_conditions", @@ -196,14 +205,28 @@ def expected_closure(unit, year, pension_age_route, mixed_age): pension_age_route and not r["working_tax_credit_reported"] ) is_or_jsa = r["income_support_reported"] or r["jsa_income_reported"] + working_age_closed = (is_or_jsa and year >= 2026) or ( + r["esa_income_reported"] and year >= 2027 + ) return (tax_credits and year >= 2025 and not to_pension_credit) or ( - is_or_jsa - and year >= 2026 + working_age_closed and not r["pension_credit_reported"] and not wholly_pension_age ) +def expected_reached(unit, year, pension_age_route, mixed_age): + """The ESA abolition reaches an unsaved award from 2026-27 (no generated + family has the appointee saving).""" + r = unit["reported"] + return ( + r["esa_income_reported"] > 0 + and year >= 2026 + and not r["pension_credit_reported"] > 0 + and not (pension_age_route and not mixed_age) + ) + + def has_working_age_claimant(unit): return any( role != "child" @@ -224,22 +247,29 @@ def test_closure_claims_and_the_end_of_legacy_awards(units): mixed_age = bool(v["is_mixed_age_couple"][i]) closed = expected_closure(unit, year, pension_age_route, mixed_age) assert bool(v["legacy_benefits_closed"][i]) == closed, (year, unit) - claims_at_closure = closed and unit["claims_at_closure"] + reached = expected_reached(unit, year, pension_age_route, mixed_age) + assert bool(v["esa_income_abolition_reached"][i]) == reached, (year, unit) + moved = closed or reached + claims_at_closure = moved and unit["claims_at_closure"] claims = unit["would_claim_uc"] or claims_at_closure assert bool(v["claims_universal_credit"][i]) == claims, (year, unit) if not claims: assert v["universal_credit"][i] == 0, (year, unit) + else: + # Only the claimant reports, so a claim ends the award. + assert v["esa_income"][i] == 0, (year, unit) assert not (v["universal_credit"][i] > 0 and v["pension_credit"][i] > 0) assert not (v["universal_credit"][i] > 0 and v["housing_benefit"][i] > 0) - if not closed: + if not (closed or claims_at_closure): continue - for benefit in [ - "tax_credits", - "income_support", - "esa_income", - "jsa_income", - ]: - assert v[benefit][i] == 0, (year, benefit, unit) + if closed: + for benefit in [ + "tax_credits", + "income_support", + "esa_income", + "jsa_income", + ]: + assert v[benefit][i] == 0, (year, benefit, unit) uc_eligible = bool(v["is_uc_eligible"][i]) # A working-age claimant, or the reg 60A upper-age waiver for # WTC migrants, is still needed. A pensioner with an ineligible @@ -300,7 +330,8 @@ def test_the_closure_flag_only_decides_closure_claims(units): "working_tax_credit_reported" ] > 0 and not has_working_age_claimant(unit) closed = bool(drawn[year]["legacy_benefits_closed"][i]) - decides = closed and ( + moved = closed or bool(drawn[year]["esa_income_abolition_reached"][i]) + decides = moved and ( not unit["would_claim_uc"] or bool(drawn[year]["meets_pension_credit_age_conditions"][i]) or age_waiver_decides @@ -364,10 +395,10 @@ def test_a_closure_claim_pays_the_universal_credit_of_any_claim(units): for i, unit in enumerate(units): # On the pension-age route only a closure claim reaches Universal # Credit, so the two claims differ by design there. - if ( - v["legacy_benefits_closed"][i] - and not (v["meets_pension_credit_age_conditions"][i]) - ): + moved = ( + v["legacy_benefits_closed"][i] | v["esa_income_abolition_reached"][i] + ) + if moved and not (v["meets_pension_credit_age_conditions"][i]): for benefit in BENEFITS + [ "uc_housing_costs_element", "is_lha_shared_accommodation_rate_specified_renter", diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index f91ec44ddf..7b338217dc 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -83,14 +83,15 @@ def own_report_is_paid( ) closed = person("person_legacy_benefits_closed", period) if award_on_reports is not None: + units = person.simulation.populations["benunit"] report_closed = person(REPORT_CLOSED[award], period) - stored = benunit(award, period) + stored = units(award, period) open_reported = person(f"{award}_reported", period) * ~report_closed - formula_award = award_on_reports(benunit, period, open_reported) + formula_award = award_on_reports(units, period, open_reported) as_formula = np.isclose( stored, formula_award.astype(stored.dtype), rtol=0, atol=HALF_PENNY ) - closed = where(benunit.project(as_formula), report_closed, closed) + closed = where(units.project(as_formula), report_closed, closed) removed = person.simulation.tax_benefit_system.get_variable(award).is_neutralized paid = benunit(award, period) > 0 screen_fails = ~benunit(f"{award}_eligible", period) From 1a0756c81b2a21d9d661a5033f4223dbd601913e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:26:31 -0500 Subject: [PATCH 09/23] Describe the abolition's claim path, run-on exception and award history Co-Authored-By: Claude Opus 5.5 --- changelog.d/2562.fixed.md | 2 +- docs/engineering/esa-jsa-report-screening.md | 26 +++++++++++------ .../gov/dwp/ESA/income/awards_payable.yaml | 29 ++++++++++++------- .../gov/dwp/esa_includes_support_component.py | 10 ++++--- .../gov/dwp/esa_income_appointee_saving.py | 15 ++++++++-- .../variables/gov/dwp/has_own_esa_award.py | 10 +++++-- ..._mixed_age_couple_pension_credit_saving.py | 10 ++++--- .../uc_limited_capability_for_WRA.py | 7 +++-- .../income/claims_all_entitled_benefits.py | 5 +++- 9 files changed, 75 insertions(+), 39 deletions(-) diff --git a/changelog.d/2562.fixed.md b/changelog.d/2562.fixed.md index 3787801b60..624c90ec56 100644 --- a/changelog.d/2562.fixed.md +++ b/changelog.d/2562.fixed.md @@ -1 +1 @@ -Income-related ESA now ends when its abolition reaches the award: on 1 July 2026 in Great Britain (SI 2025/1148 art. 3A, inserted by SI 2026/409) and on 1 October 2026 in Northern Ireland (SR 2025/176 art. 3A, inserted by SR 2026/115). The new `gov.dwp.ESA.income.awards_payable` parameter is day-weighted across each fiscal year, so 2026-27 pays an award for 86/365 of the year in Great Britain and 178/365 in Northern Ireland, and 2027-28 onwards pays none. The new `esa_income_appointee_saving` input keeps an award that art. 3A(2) saves for an appointee; a Universal Credit claim still ends it (art. 3A(3)). Contributory ESA is unchanged. +Model the 2026 income-related ESA abolition and its Universal Credit claims. An award pays to 30 June 2026 in Great Britain and 30 September 2026 in Northern Ireland (86/365 and 178/365 of 2026-27), and a family whose award the abolition reaches claims Universal Credit if it would claim when moved. From 2027-28 the award has ended and DWP has moved the family off its legacy benefits. A new input keeps an award saved for an appointee. diff --git a/docs/engineering/esa-jsa-report-screening.md b/docs/engineering/esa-jsa-report-screening.md index 91552ecefa..bebb785bb5 100644 --- a/docs/engineering/esa-jsa-report-screening.md +++ b/docs/engineering/esa-jsa-report-screening.md @@ -14,15 +14,23 @@ screen passes. The couple's part stays £2,792. A working outside reporter canno raise the total; failure of the couple's own screen cannot erase another eligible person's separate award. These examples presume separate preassessed claims. -The abolition of income-related ESA reached every remaining award on 1 July 2026 -in Great Britain and 1 October 2026 in Northern Ireland (SI 2025/1148 art. 3A; -SR 2025/176 art. 3A). The model pays each claim for `esa_income_payable_share` -of the year: the couple's claim at the highest share among its reporters, and -each outside claim at its own. In 2026–27 that is 86/365 in Great Britain and -178/365 in Northern Ireland, so the £3,876 above becomes £913.25 in Great Britain. -From 2027–28 `esa_income_report_closed` closes every report, except one saved -for an appointee (`esa_income_appointee_saving`); a Universal Credit claim -still ends a saved award. Contributory ESA is unchanged. +The Secretary of State appointed 1 July 2026 for the abolition of income-related +ESA to reach every remaining award in Great Britain, and the Department appointed +1 October 2026 in Northern Ireland (SI 2025/1148 art. 3A; SR 2025/176 art. 3A). +An award already due to end at the end of a two-week run-on ends on that +schedule instead: with a migration notice deadline of 22 June 2026 and no claim, +SI 2014/1230 reg 46(1)(a) ends the award on 5 July. The model has no deadline or +claim dates, so it pays each claim for `esa_income_payable_share` of the year, to +the general date: one share for the couple's claim and one for each outside +claim. In 2026–27 that is 86/365 in Great Britain and 178/365 in Northern +Ireland, so for a couple that does not claim Universal Credit the £3,876 above +becomes £913.25 in Great Britain. When the abolition reaches the couple's award, +the family claims Universal Credit if `would_claim_uc_at_legacy_closure`, and +the claim ends the couple's award; outside claims carry on. From 2027–28 +`esa_income_report_closed` closes every report and `legacy_benefits_closed` +moves the family, except for a claim saved for an appointee +(`esa_income_appointee_saving`), which a Universal Credit claim still ends. +Contributory ESA is unchanged. All formula readers use the same original claim groups. A scope mask selects reports; it does not change claimant or partner roles. Claimant-or-partner awards diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml index 0c3eb39781..7d8029ba94 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml @@ -1,16 +1,21 @@ description: >- Whether old style income-related Employment and Support Allowance awards are - still payable (1) or abolished (0). The Welfare Reform Act 2012 abolition of - income-related ESA came into force for every remaining award on 1 July 2026 - in Great Britain and 1 October 2026 in Northern Ireland, except awards saved - for an appointee (esa_income_appointee_saving). A contributory allowance in - the same award continues as new style ESA. Ordinary parameters take their - 30 April value for a model year, which would leave 2026-27 untouched, so - these numeric flags use fiscal_year_blend to take the day-weighted average - from 6 April to 5 April: 2026-27 carries 86 of its 365 days in Great Britain - and 178 in Northern Ireland. + still payable (1) or abolished (0). The Secretary of State appointed 1 July + 2026 for the Welfare Reform Act 2012 abolition of income-related ESA to + reach every remaining award in Great Britain, and the Department appointed + 1 October 2026 in Northern Ireland. Two kinds of award escape that date: one + saved for an appointee (esa_income_appointee_saving), and one already due to + end at the end of a two-week run-on, which ends on that schedule instead. A + claimant with a migration notice deadline of 22 June 2026 who makes no + claim keeps the award to 5 July. The model has no claim or deadline dates, + so it pays every unsaved award to the general date. A contributory + allowance in the same award continues as new style ESA. Ordinary + parameters take their 30 April value for a model year, which would leave + 2026-27 untouched, so these numeric flags use fiscal_year_blend to take the + day-weighted average from 6 April to 5 April: 2026-27 carries 86 of its 365 + days in Great Britain and 178 in Northern Ireland. great_britain: - description: Whether old style income-related Employment and Support Allowance awards are still payable in Great Britain. SI 2025/1148 art. 3A brought section 33(1)(b) of the Welfare Reform Act 2012 into force for them on 1 July 2026. + description: Whether old style income-related Employment and Support Allowance awards are still payable in Great Britain. SI 2025/1148 art. 3A(1) appoints 1 July 2026 for section 33(1)(b) of the Welfare Reform Act 2012 to come into force for every award it has not yet reached, except an award due to end at the end of a two-week run-on, which ends on that schedule. values: 2008-10-27: 1 2026-07-01: 0 @@ -26,10 +31,12 @@ great_britain: href: https://www.legislation.gov.uk/uksi/2026/409/made - title: SI 2013/983 art. 4(1) (the amending provisions) href: https://www.legislation.gov.uk/uksi/2013/983/article/4 + - title: SI 2014/1230 reg 46(1)(a) (termination two weeks after a migration notice deadline) + href: https://www.legislation.gov.uk/uksi/2014/1230/regulation/46 - title: Welfare Reform Act 2012 s. 33(1)(b) href: https://www.legislation.gov.uk/ukpga/2012/5/section/33 northern_ireland: - description: Whether old style income-related Employment and Support Allowance awards are still payable in Northern Ireland. SR 2025/176 art. 3A brought Article 39(1)(b) of the Welfare Reform (Northern Ireland) Order 2015 into operation for them on 1 October 2026. + description: Whether old style income-related Employment and Support Allowance awards are still payable in Northern Ireland. SR 2025/176 art. 3A(1) appoints 1 October 2026 for Article 39(1)(b) of the Welfare Reform (Northern Ireland) Order 2015 to come into operation for every award it has not yet reached, except an award due to end at the end of a two-week run-on, which ends on that schedule. values: 2008-10-27: 1 2026-10-01: 0 diff --git a/policyengine_uk/variables/gov/dwp/esa_includes_support_component.py b/policyengine_uk/variables/gov/dwp/esa_includes_support_component.py index c3cec6cdf4..8c7ec98dce 100644 --- a/policyengine_uk/variables/gov/dwp/esa_includes_support_component.py +++ b/policyengine_uk/variables/gov/dwp/esa_includes_support_component.py @@ -4,15 +4,17 @@ class esa_includes_support_component(Variable): value_type = bool entity = Person - label = "ESA includes the support component" + label = "ESA award included the support component" documentation = ( - "Whether this person's employment and support allowance includes the " - "support component: the assessment phase has ended and they have " + "Whether this person's employment and support allowance includes, or " + "included before it ended, the support component: the assessment " + "phase ended and they have " "limited capability for work-related activity (Welfare Reform Act 2007 " "s.2(2) for the contributory allowance, s.4(4) for the income-related " "allowance). Surveys record this as being in the ESA support group. " "Datasets or users can supply it. Otherwise it is assumed for anyone " - "with an ESA award of their own (has_own_esa_award): in the Family " + "who has held an ESA award of their own (has_own_esa_award), including " + "an income-related award its abolition has ended: in the Family " "Resources Survey 2024-25 about nine in ten ESA recipients who answer " "say they are in the support group." ) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py index bc880b7e9f..bd5603533d 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py @@ -16,9 +16,18 @@ class esa_income_appointee_saving(Variable): "that such an appointment was likely to be needed. SR 2025/176 art. " "3A(2) does the same in Northern Ireland, with the Department and reg " "52 of the Northern Ireland Claims and Payments Regulations 2016. A " - "Universal Credit claim still ends a saved award (art. 3A(3); " - "esa_income_report_closed). No dataset identifies appointees, so this " - "defaults to false and the saving is off in dataset runs." + "couple's award is one award: a saving for either member who reports " + "it saves the whole claim (esa_income_payable_share), and a saving for " + "a member who reports nothing saves nothing. A saved claim prompts no " + "Universal Credit claim and does not move the family " + "(esa_income_abolition_reached, legacy_benefits_closed), but a " + "Universal Credit claim, or another legacy benefit's closure, still " + "ends it (art. 3A(3); esa_income_report_closed). DWP identified " + "around 500 claimants who need a personal or corporate acting body " + "and exempted them from the June 2026 closure (LA Welfare Direct " + "6/2026, para 53). No " + "dataset identifies appointees, so this defaults to false and the " + "saving is off in dataset runs." ) definition_period = YEAR default_value = False diff --git a/policyengine_uk/variables/gov/dwp/has_own_esa_award.py b/policyengine_uk/variables/gov/dwp/has_own_esa_award.py index f474fe3561..774dd0a22f 100644 --- a/policyengine_uk/variables/gov/dwp/has_own_esa_award.py +++ b/policyengine_uk/variables/gov/dwp/has_own_esa_award.py @@ -4,10 +4,14 @@ class has_own_esa_award(Variable): value_type = bool entity = Person - label = "has an ESA award of their own" + label = "has held an ESA award of their own" documentation = ( - "Whether this person claims an employment and support allowance " - "themselves: they report contributory ESA or an income-related award. " + "Whether this person has held an employment and support allowance " + "of their own: they report contributory ESA or an income-related " + "award. The report records the award's history, so it counts after " + "the abolition of income-related ESA or a Universal Credit claim has " + "ended the award (esa_income_report_closed): the work capability " + "assessment behind it still stands. " "When the family's income-related award is entered directly " "(esa_income) rather than reported, it is attributed to one claimant: " "the claimant or partner who heads the benefit unit, or else the " diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py b/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py index 35cd614de0..e5d057686a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py @@ -11,13 +11,15 @@ class has_mixed_age_couple_pension_credit_saving(Variable): "apply to a mixed-age couple, because on 14 May 2019 the couple was " "entitled, as that couple, to Pension Credit or pension-age Housing " "Benefit, and has been entitled to one of them ever since. Set it as " - "an input where it is known. By default it is inferred from current " + "an input where it is known. By default it is inferred from reported " "receipt: reported Pension Credit, or Housing Benefit reported by the " - "pension-age member without Income Support, income-based JSA or " - "income-related ESA (which would " + "pension-age member without a report of Income Support, income-based " + "JSA or income-related ESA (which would have " "put the couple under the working-age Housing Benefit Regulations), " "with no reported Universal Credit, where the older member was born " - "early enough to have reached the qualifying age by 14 May 2019." + "early enough to have reached the qualifying age by 14 May 2019. The " + "reports record history: an income-related ESA report still rules the " + "saving out after the award has ended." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py index cb195c4cae..38eb0e3d15 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py @@ -12,9 +12,10 @@ class uc_limited_capability_for_WRA(Variable): "benefit receipt, ESA included. A determination of limited capability " "for work-related activity is what gives ESA its support component, " "and Universal Credit takes the ESA determination (UC Regs 2013 reg " - "40(1)(a)(ii)). So a person whose own ESA award (has_own_esa_award) " - "does not include the support component (esa_includes_support_" - "component) is taken not to have it, whatever the flag, though someone " + "40(1)(a)(ii)). So a person whose own ESA award (has_own_esa_award), " + "current or ended, did not include the support component " + "(esa_includes_support_component) is taken not to have it, whatever " + "the flag, though someone " "still in the ESA assessment phase may yet be found to. The model does " "not infer it the other way, from the support component alone. Users " "can set it directly." diff --git a/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py b/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py index 545a7153a5..266b8f9116 100644 --- a/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py +++ b/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py @@ -14,7 +14,10 @@ class claims_all_entitled_benefits(Variable): "checks (Child Tax Credit, Working Tax Credit, Universal Credit, " "Housing Benefit, income-based JSA, Income Support and income-related " "ESA), and false where it reports £1 or more, in which case its " - "reported benefits are taken as the complete set. Council Tax " + "reported benefits are taken as the complete set. A report counts " + "after its award has ended, as an income-related ESA award does " + "after its abolition: it records the family's claiming history. " + "Council Tax " "Reduction, Pension Credit and Child Benefit are not among these " "seven: reporting only Pension Credit still leaves this flag true. In " "data-built simulations it is false for every family, so take-up of " From f53a04996af358e69769149201bb2de358b554fd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 01:48:35 -0500 Subject: [PATCH 10/23] Look up the ESA award helper in a module map, keeping own_report_is_paid's signature Co-Authored-By: Claude Opus 5.5 --- .../variables/gov/dwp/_legacy_award_payee.py | 20 ++++++++++++------- .../gov/dwp/is_on_income_related_esa.py | 4 ---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 7b338217dc..fa0d2ba924 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -2,6 +2,13 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.benefit_unit import HALF_PENNY, REPORT_CLOSED +from policyengine_uk.variables.gov.dwp.esa_income import ( + income_related_esa_award_on_reports, +) + +# The award each formula gives on open person reports, for an award with +# closures of its own beyond the legacy closure (REPORT_CLOSED). +AWARD_ON_REPORTS = {"esa_income": income_related_esa_award_on_reports} def is_payee_of_couple_award(person, period, reported, can_claim=None, couple=None): @@ -40,9 +47,7 @@ def is_payee_of_couple_award(person, period, reported, can_claim=None, couple=No return where(person.benunit.any(reports), reports, claimant) -def own_report_is_paid( - person, period, award, works, capital_limit, award_on_reports=None -): +def own_report_is_paid(person, period, award, works, capital_limit): """Whether income-related ESA or income-based JSA (award: "esa_income" or "jsa_income") is paid on this person's own report, for a member who is neither the claimant nor the partner and so claims in their own right. @@ -59,10 +64,10 @@ def own_report_is_paid( Their own legacy benefits must remain open under the existing annual proxy (person_legacy_benefits_closed). A couple's closure does not end this separate claim. An award with closures of its own (REPORT_CLOSED: - for income-related ESA, its abolition, esa_income_report_closed) passes - award_on_reports, the award its formula gives on open person reports. - Where the stored award is that formula's, those closures, which leave - the report out of it, end status too. Where it holds another value, + for income-related ESA, its abolition, esa_income_report_closed) has its + formula's award on open person reports in AWARD_ON_REPORTS. Where the + stored award is that formula's, those closures, which leave the report + out of it, end status too. Where it holds another value, entered directly or held by disable_simulated_benefits in every year, that value decides payment, as it does for the claimant and partner (claimant_or_partner_esa_income), so only the legacy closure ends @@ -82,6 +87,7 @@ def own_report_is_paid( ) ) closed = person("person_legacy_benefits_closed", period) + award_on_reports = AWARD_ON_REPORTS.get(award) if award_on_reports is not None: units = person.simulation.populations["benunit"] report_closed = person(REPORT_CLOSED[award], period) diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py index f0c40d9b5f..24ccc86b0b 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py @@ -3,9 +3,6 @@ is_payee_of_couple_award, own_report_is_paid, ) -from policyengine_uk.variables.gov.dwp.esa_income import ( - income_related_esa_award_on_reports, -) class is_on_income_related_esa(Variable): @@ -60,6 +57,5 @@ def formula(person, period, parameters): "esa_income", works=person("esa_income_claimant_remunerative_work", period), capital_limit=parameters(period).gov.dwp.ESA.income.capital.limit, - award_on_reports=income_related_esa_award_on_reports, ) return where(person("is_claimant_or_partner", period), couple_award, own_award) From 59e8f80258f77f0f116aa365a0a78c8b4e9f7ef7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 02:27:21 -0500 Subject: [PATCH 11/23] Name who acts in the abolition's documentation and test comments Co-Authored-By: Claude Opus 5.5 --- docs/engineering/esa-jsa-report-screening.md | 2 +- .../tests/legacy_award_work_reference.py | 4 ++-- .../family/esa_income/esa_income_abolition.yaml | 17 +++++++++-------- .../test_esa_income_abolition_properties.py | 14 +++++++------- ..._income_universal_credit_claim_properties.py | 4 ++-- .../tests/test_uc_legacy_closure_properties.py | 2 +- .../gov/dwp/esa_income_appointee_saving.py | 16 ++++++++-------- .../gov/dwp/esa_income_payable_share.py | 10 +++++----- .../gov/dwp/esa_income_report_closed.py | 2 +- .../left_pension_route_at_legacy_closure.py | 4 ++-- ...as_mixed_age_couple_pension_credit_saving.py | 2 +- .../uc_limited_capability_for_WRA.py | 2 +- .../esa_income_abolition_reached.py | 13 +++++++------ 13 files changed, 47 insertions(+), 45 deletions(-) diff --git a/docs/engineering/esa-jsa-report-screening.md b/docs/engineering/esa-jsa-report-screening.md index bebb785bb5..2ed94bae26 100644 --- a/docs/engineering/esa-jsa-report-screening.md +++ b/docs/engineering/esa-jsa-report-screening.md @@ -30,7 +30,7 @@ the claim ends the couple's award; outside claims carry on. From 2027–28 `esa_income_report_closed` closes every report and `legacy_benefits_closed` moves the family, except for a claim saved for an appointee (`esa_income_appointee_saving`), which a Universal Credit claim still ends. -Contributory ESA is unchanged. +Contributory ESA carries on as new style ESA. All formula readers use the same original claim groups. A scope mask selects reports; it does not change claimant or partner roles. Claimant-or-partner awards diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 83d0742564..9f9cb76332 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -187,8 +187,8 @@ def esa_abolition_claim(couple, parameters): reports (esa_income_report_closed). The abolition reaches every remaining award on 1 July 2026 in England - (SI 2025/1148 art. 3A), the households' default country, and no award - here is saved for an appointee, so it reaches any award reported in a + (SI 2025/1148 art. 3A), the households' default country, and no member + here has the appointee saving, so it reaches any award reported in a year with less than all of it payable. would_claim_uc_at_legacy_closure keeps its default, true. The conditions are those of the other working-age closures: no Pension Credit report, and not all pension age. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml index b8a0d4eda5..081de72030 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml @@ -16,13 +16,13 @@ # # When the abolition reaches the claimant's or partner's award, the family # claims Universal Credit if would_claim_uc_at_legacy_closure (default true), -# and the claim ends the award. From 2027-28 the award has ended and the -# family is moved off its legacy benefits (legacy_benefits_closed). Cases +# and the claim ends the award. From 2027-28 the award has ended and DWP +# moves the family off its legacy benefits (legacy_benefits_closed). Cases # that test the 2026-27 share set would_claim_uc_at_legacy_closure to false. # # Art. 3A(2) saves an award for an appointee (esa_income_appointee_saving). -# A couple's award is one award, saved when either member who reports it is -# saved. Art. 3A(3): a Universal Credit claim still ends a saved award. A +# A saving for either member who reports a couple's award keeps the whole +# award. Art. 3A(3): a Universal Credit claim still ends a saved award. A # contributory allowance continues as new style ESA (esa_contrib). # # Unless a case says otherwise, nobody has capital, so there is no tariff @@ -472,8 +472,8 @@ members: [claimant, other] region: LONDON output: - # The adult claims in their own right, so their report neither prompts - # the family's claim nor is ended by it. + # The adult claims in their own right, so the family's claim neither + # depends on their report nor ends it. esa_income_abolition_reached: false claims_universal_credit: false esa_income_report_closed: [false, false] @@ -872,8 +872,9 @@ tenure_type: RENT_FROM_COUNCIL rent: 5_200 output: - # The couple claims jointly (UC Regs 2013 reg 3(2)(a)) and is entitled, - # so it leaves the pension-age route and its Housing Benefit ends. + # The couple claims jointly (UC Regs 2013 reg 3(2)(a)) and has a + # positive award, so it leaves the pension-age route and its Housing + # Benefit ends. meets_pension_credit_age_conditions: true esa_income_abolition_reached: true claims_uc_at_legacy_closure: true diff --git a/policyengine_uk/tests/test_esa_income_abolition_properties.py b/policyengine_uk/tests/test_esa_income_abolition_properties.py index 3022cb2d95..87408a20dd 100644 --- a/policyengine_uk/tests/test_esa_income_abolition_properties.py +++ b/policyengine_uk/tests/test_esa_income_abolition_properties.py @@ -5,15 +5,15 @@ remaining old style ESA award in Great Britain (SI 2025/1148 art. 3A, inserted by SI 2026/409), and the Department appointed 1 October 2026 in Northern Ireland (SR 2025/176 art. 3A, inserted by SR 2026/115). Art. 3A(2) -saves an award for an appointee (esa_income_appointee_saving); a couple's -award is one award, saved when either member who reports it is saved. +saves an award for an appointee (esa_income_appointee_saving); a saving +for either member who reports a couple's award keeps the whole award. Art. 3A(3) lets a Universal Credit claim end a saved award. A contributory allowance continues as new style ESA. When the abolition reaches the claimant's or partner's award (esa_income_abolition_reached, from 2026-27), the family claims Universal Credit if would_claim_uc_at_legacy_closure. From 2027-28 the award has ended -and the family is moved off its legacy benefits (legacy_benefits_closed). +and DWP moves the family off its legacy benefits (legacy_benefits_closed). Each generated population runs in one simulation five times over: with the saving and claim flag as drawn; with every award saved; with none saved; @@ -36,7 +36,7 @@ outside the couple whose claim is unsaved is not on income-related ESA, and with no saving nobody gets any. 4. Contributory ESA never changes, whatever is saved or claimed. -5. Before 2026-27 every output is the same whatever is saved or claimed; +5. Before 2026-27 every output matches, whatever the saving and claim flags; the abolition never raises anyone's income-related ESA; and a family that claims nothing and saves nothing gets exactly its jurisdiction's share of the award before the abolition. @@ -268,7 +268,7 @@ def couple_claim_saved(unit): def claim_saved(member, unit): - """Whether the claim this member belongs to is saved.""" + """Whether a saving keeps the claim this member belongs to.""" if member["role"] in COUPLE: return couple_claim_saved(unit) return member["esa_income_reported"] > 0 and member["saved"] @@ -316,7 +316,7 @@ def independent_reading(unit, year): couple_saved = couple_claim_saved(unit) couple_share = 1.0 if couple_saved else share # The abolition reaches an unsaved couple claim from the year less than - # all of it is payable; from the year none is, the family is moved. + # all of it is payable; from the year none is, DWP moves the family. reached = couple_reported > 0 and not couple_saved and share < 1 claims = unit["would_claim_uc"] or (reached and unit["claims_when_moved"]) # A claim ends the couple's reports; the abolition pays the rest for @@ -482,7 +482,7 @@ def test_abolition_invariants(units): ), year assert not np.any(k["esa_income_abolition_reached"]), year assert not np.any(k["legacy_benefits_closed"]), year - # 5. Before 2026-27 every output is the same whatever is saved or claimed. + # 5. Before 2026-27 every output matches, whatever the saving and claim. for year in BEFORE: for name in BENUNIT_OUTPUTS + PERSON_OUTPUTS: for mode in ("drawn", "none", "flipped", "no_claim"): diff --git a/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py b/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py index 42e5bc35aa..5a933ad3a4 100644 --- a/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py +++ b/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py @@ -10,8 +10,8 @@ Invariants, for any generated population of working-age families in 2025 and 2027 (either side of income-related ESA's 1 July 2026 abolition date) -with no other legacy benefit, so none is moved off legacy benefits. Every -award is saved for an appointee (SI 2025/1148 art. 3A(2)), so the abolition +with no other legacy benefit, so DWP moves none off legacy benefits. Every +member has the appointee saving (SI 2025/1148 art. 3A(2)), so the abolition leaves it in place in 2027 and only the claim ends it (art. 3A(3)): 1. Exclusive: a family that claims Universal Credit pays its claimant and diff --git a/policyengine_uk/tests/test_uc_legacy_closure_properties.py b/policyengine_uk/tests/test_uc_legacy_closure_properties.py index 9c4773cc44..c7f6a1ba7f 100644 --- a/policyengine_uk/tests/test_uc_legacy_closure_properties.py +++ b/policyengine_uk/tests/test_uc_legacy_closure_properties.py @@ -3,7 +3,7 @@ Tax credits closed from 6 April 2025 and Income Support and income-based JSA from 1 April 2026. The abolition of income-related ESA reached every remaining award on 1 July 2026 (SI 2025/1148 art. 3A), so a family whose award it -reaches can claim from 2026-27 and is moved from 2027-28, when none of the +reaches can claim from 2026-27, and DWP moves it from 2027-28, when none of the award is payable. DWP moved each family off its legacy benefits with a notice covering all of them. A Universal Credit migration notice ends the awards on a claim (SI 2014/1230 reg 8) or at its deadline (reg 46). A tax credit closure diff --git a/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py index bd5603533d..492e1f42da 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py @@ -6,14 +6,14 @@ class esa_income_appointee_saving(Variable): entity = Person label = "Income-related ESA award saved from abolition for an appointee" documentation = ( - "Whether the old style income-related ESA award on this person's " - "report is saved from the abolition of income-related ESA, so it stays " - "payable after 1 July 2026 (1 October 2026 in Northern Ireland). SI " - "2025/1148 art. 3A(2) saves an award where, immediately before that " - "date, someone is appointed under reg 57 of the Claims and Payments " - "Regulations 2013 to act for a claimant who is unable to act, or where " - "the Secretary of State decided in the six months ending on that date " - "that such an appointment was likely to be needed. SR 2025/176 art. " + "Whether the art. 3A(2) saving keeps the old style income-related ESA " + "award on this person's report payable after the abolition of " + "income-related ESA on 1 July 2026 (1 October 2026 in Northern " + "Ireland). SI 2025/1148 art. 3A(2) saves an award where, immediately " + "before that date, someone holds an appointment under reg 57 of the " + "Claims and Payments Regulations 2013 to act for a claimant who cannot " + "act, or where the Secretary of State decided in the six months ending " + "on that date that the claimant would likely need one. SR 2025/176 art. " "3A(2) does the same in Northern Ireland, with the Department and reg " "52 of the Northern Ireland Claims and Payments Regulations 2016. A " "couple's award is one award: a saving for either member who reports " diff --git a/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py b/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py index b729c1da02..d72b31d09c 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py @@ -23,9 +23,9 @@ class esa_income_payable_share(Variable): "date. This annual share does not track deadlines or claim dates, so " "it pays every award to the general date. A claim saved for an " "appointee has a share of 1 in " - "every year: a couple's claim is saved when either member who reports " - "the award is saved (esa_income_appointee_saving), and saving a member " - "who reports nothing saves nothing. esa_income pays each claim for " + "every year: a saving for either member who reports a couple's award " + "keeps the whole claim (esa_income_appointee_saving), and a saving for " + "a member who reports nothing keeps nothing. esa_income pays each claim for " "this share of the year, and esa_income_report_closed ends its reports " "in a year where it is 0. A contributory allowance continues as new " "style ESA (esa_contrib)." @@ -51,8 +51,8 @@ def formula(person, period, parameters): payable.great_britain, ) # Art. 3A(2) saves an award for an appointee in every year. The - # claimant's and partner's award is one award, saved if either of - # them reports it and is saved; another member's is their own. + # claimant and partner share one award, which a saving for either of + # them who reports it keeps; another member's award is their own. reports_saved = (person("esa_income_reported", period) > 0) & person( "esa_income_appointee_saving", period ) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py index b79349df87..6c02b921d2 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py @@ -30,7 +30,7 @@ class esa_income_report_closed(Variable): "postcode districts named in the commencement orders are not " "modelled. The abolition reaches every other award on 1 July 2026, " "or 1 October 2026 in Northern Ireland (SI 2025/1148 art. 3A; SR " - "2025/176 art. 3A), so the report is closed in a year where none of " + "2025/176 art. 3A), so the model closes the report in a year where none of " "its claim is payable (esa_income_payable_share is 0, from 2027-28). " "In 2026-27 the report stays open and esa_income pays it for the " "share of the year before that date, unless the family claims " diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py index 081f7d8c68..0ecd0991ad 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/left_pension_route_at_legacy_closure.py @@ -30,8 +30,8 @@ class left_pension_route_at_legacy_closure(Variable): ) def formula(benunit, period, parameters): - # A protected mixed-age couple whose income-related ESA award the - # abolition reaches is moved too (esa_income_abolition_reached). + # DWP also moves a protected mixed-age couple whose income-related ESA + # award the abolition reaches (esa_income_abolition_reached). moved = ( benunit("legacy_benefits_closed", period) | benunit("esa_income_abolition_reached", period) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py b/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py index e5d057686a..13c06421e6 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/mixed_age_couples/has_mixed_age_couple_pension_credit_saving.py @@ -11,7 +11,7 @@ class has_mixed_age_couple_pension_credit_saving(Variable): "apply to a mixed-age couple, because on 14 May 2019 the couple was " "entitled, as that couple, to Pension Credit or pension-age Housing " "Benefit, and has been entitled to one of them ever since. Set it as " - "an input where it is known. By default it is inferred from reported " + "an input where it is known. By default the model infers it from reported " "receipt: reported Pension Credit, or Housing Benefit reported by the " "pension-age member without a report of Income Support, income-based " "JSA or income-related ESA (which would have " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py index 38eb0e3d15..a0bc4a4eda 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/disability_element/limited_work_ability/uc_limited_capability_for_WRA.py @@ -14,7 +14,7 @@ class uc_limited_capability_for_WRA(Variable): "and Universal Credit takes the ESA determination (UC Regs 2013 reg " "40(1)(a)(ii)). So a person whose own ESA award (has_own_esa_award), " "current or ended, did not include the support component " - "(esa_includes_support_component) is taken not to have it, whatever " + "(esa_includes_support_component) counts as not having it, whatever " "the flag, though someone " "still in the ESA assessment phase may yet be found to. The model does " "not infer it the other way, from the support component alone. Users " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py b/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py index 60a484fc68..36411ea234 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py @@ -21,12 +21,13 @@ class esa_income_abolition_reached(Variable): "family whose award the abolition reaches claims Universal Credit if " "it would claim when moved (claims_uc_at_legacy_closure), from the " "year its award starts to end. From 2027-28 the award has ended and " - "the family is moved off its legacy benefits (legacy_benefits_closed). " - "A claim saved for an appointee (esa_income_appointee_saving) is not " - "reached. As for the other working-age closures, a family on Pension " - "Credit, or a single claimant, or joint claimants both, over the " - "qualifying age for State Pension Credit, is not moved (SI 2014/1230 " - "reg 8(2B)). This reads reports, the household's country and the " + "DWP moves the family off its legacy benefits (legacy_benefits_closed). " + "The abolition doesn't reach a claim saved for an appointee " + "(esa_income_appointee_saving). As for the other working-age " + "closures, DWP doesn't move a family on Pension Credit, or a single " + "claimant, or joint claimants both, over the qualifying age for State " + "Pension Credit (SI 2014/1230 reg 8(2B)). This reads reports, the " + "household's country and the " "saving, never an award. Reports by other benefit-unit members, who " "claim in their own right, do not count." ) From 68ccc08561d9d0f206f5b17543e6efc9a0ef30ce Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 04:53:20 -0500 Subject: [PATCH 12/23] Name the actor in claims_all_entitled_benefits documentation Co-Authored-By: Claude Opus 5.5 --- .../variables/household/income/claims_all_entitled_benefits.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py b/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py index 266b8f9116..3c7630216e 100644 --- a/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py +++ b/policyengine_uk/variables/household/income/claims_all_entitled_benefits.py @@ -14,7 +14,7 @@ class claims_all_entitled_benefits(Variable): "checks (Child Tax Credit, Working Tax Credit, Universal Credit, " "Housing Benefit, income-based JSA, Income Support and income-related " "ESA), and false where it reports £1 or more, in which case its " - "reported benefits are taken as the complete set. A report counts " + "reported benefits count as the complete set. A report counts " "after its award has ended, as an income-related ESA award does " "after its abolition: it records the family's claiming history. " "Council Tax " From 39002e53287f3e6fa2bf144ad071be9e8e20722d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 10:11:09 -0500 Subject: [PATCH 13/23] Keep Housing Benefit for specified or temporary accommodation through a legacy closure A family that supplies in_specified_or_temporary_accommodation keeps an existing Housing Benefit award when DWP moves it off a closed legacy benefit: SI 2014/1230 reg 46(2) (NI: SR 2016/226 reg 47(2)) keeps such an award at the deadline whichever legacy benefit closed, and SI 2025/1148 art. 7(4)(a) reaches the same cases through reg 6A(2). A Universal Credit claim still ends the award, because the model's Universal Credit pays a housing costs element for these homes. Tests cover the ESA closure and the tax credit closure, each with a control that leaves the status unset, and a family with the status that claims. Co-Authored-By: Claude Opus 5.5 --- .../esa_income/esa_income_abolition.yaml | 90 +++++++++++++++++++ .../housing_benefit_eligible.yaml | 57 ++++++++++++ .../housing_benefit_eligible.py | 44 ++++++--- .../claims_universal_credit.py | 11 ++- .../legacy_benefits_closed.py | 13 +-- 5 files changed, 196 insertions(+), 19 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml index 081de72030..2350579490 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml @@ -19,6 +19,9 @@ # and the claim ends the award. From 2027-28 the award has ended and DWP # moves the family off its legacy benefits (legacy_benefits_closed). Cases # that test the 2026-27 share set would_claim_uc_at_legacy_closure to false. +# The move ends the family's Housing Benefit, except for specified or +# temporary accommodation (SI 2014/1230 reg 46(2)); a claim ends it there +# too, because the model's Universal Credit pays the rent. # # Art. 3A(2) saves an award for an appointee (esa_income_appointee_saving). # A saving for either member who reports a couple's award keeps the whole @@ -627,6 +630,93 @@ housing_benefit_eligible: false housing_benefit: 0 +- name: Great Britain 2027-28, the move off legacy benefits keeps Housing Benefit for specified or temporary accommodation + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + housing_benefit_reported: 5_200 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 5_200 + output: + # SI 2014/1230 reg 46(2): the deadline with no claim ends the + # income-related ESA award but not Housing Benefit for specified or + # temporary accommodation. With no income, the means test pays the + # whole eligible rent. + legacy_benefits_closed: true + claims_universal_credit: false + esa_income: 0 + housing_benefit_eligible: true + housing_benefit: 5_200 + +- name: Great Britain 2027-28, the same family outside that accommodation loses its Housing Benefit + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + housing_benefit_reported: 5_200 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 5_200 + output: + # in_specified_or_temporary_accommodation defaults to false. + legacy_benefits_closed: true + housing_benefit_eligible: false + housing_benefit: 0 + +- name: Great Britain 2026-27, a family in specified or temporary accommodation that claims Universal Credit gets no Housing Benefit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + housing_benefit_reported: 5_200 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 5_200 + output: + # The family claims when the abolition reaches its award. Reg 8(3) + # keeps this Housing Benefit award after the claim, but the model's + # Universal Credit pays the rent through its housing costs element, so + # the model ends the award to avoid paying the rent twice. + claims_universal_credit: true + housing_benefit_eligible: false + housing_benefit: 0 + - name: Great Britain 2027-28, a family the abolition moved claims Universal Credit period: 2027 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml index 3a1990c8a0..dc7b75cd71 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml @@ -37,3 +37,60 @@ LHA_eligible: false output: housing_benefit_eligible: false + +- name: The tax credit closure keeps Housing Benefit for specified or temporary accommodation + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + working_tax_credit_reported: 2_000 + housing_benefit_reported: 5_200 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 5_200 + output: + # Tax credits closed on 6 April 2025, so DWP has moved the family. SI + # 2014/1230 reg 46(2) keeps this Housing Benefit award whichever legacy + # benefit closed. The closure ends Working Tax Credit, so the family has no + # income and the means test pays the whole eligible rent. + legacy_benefits_closed: true + claims_universal_credit: false + working_tax_credit: 0 + housing_benefit_eligible: true + housing_benefit: 5_200 + +- name: The tax credit closure ends Housing Benefit outside that accommodation + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + working_tax_credit_reported: 2_000 + housing_benefit_reported: 5_200 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 5_200 + output: + legacy_benefits_closed: true + housing_benefit_eligible: false + housing_benefit: 0 diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py index 42db8fc878..70d770a690 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py @@ -18,11 +18,22 @@ class housing_benefit_eligible(Variable): "Pension Credit; the model reads reported Working Tax Credit and " "Pension Credit. Any " "other family keeps Housing Benefit only while it continues an " - "existing award (reported Housing Benefit), does not claim " - "Universal Credit and has not been moved off a closed legacy benefit " - "(legacy_benefits_closed). Claims for specified or temporary " - "accommodation " - "are not modelled." + "existing award (reported Housing Benefit) and does not claim " + "Universal Credit. DWP's move off a closed legacy benefit " + "(legacy_benefits_closed) also ends that award, unless the family " + "lives in specified or temporary accommodation " + "(in_specified_or_temporary_accommodation): SI 2014/1230 reg 46(2) " + "(NI: SR 2016/226 reg 47(2)) keeps such an award when the migration " + "notice's deadline passes without a claim, whichever legacy benefit " + "closed. The model leaves out new claims for that accommodation (reg " + "6A(2)) and reg 8(3), which keeps such an award after a Universal " + "Credit claim: the model's Universal Credit pays a housing costs " + "element for these homes, so Housing Benefit beside it would pay the " + "rent twice. SI 2025/1148 art. 7 ends every other working-age award " + "on 1 July 2026 without a claim (SR 2025/176 art. 7: 1 October 2026 " + "in Northern Ireland). The model doesn't apply that date yet, so it " + "pays such an award for the whole of 2026-27 and keeps it until the " + "family claims or DWP moves it; #1910 adds the date." ) definition_period = YEAR reference = ( @@ -34,6 +45,9 @@ class housing_benefit_eligible(Variable): "https://www.legislation.gov.uk/uksi/2019/37/article/4", "https://www.legislation.gov.uk/ukpga/2012/5/section/4", "https://www.legislation.gov.uk/nisr/2016/226/regulation/4A", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/47", + "https://www.legislation.gov.uk/uksi/2025/1148/article/7", + "https://www.legislation.gov.uk/nisr/2025/176/article/7", ) def formula(benunit, period, parameters): @@ -67,16 +81,26 @@ def formula(benunit, period, parameters): # Working-age families, and mixed-age couples without the saving # (since 15 May 2019), claim Universal Credit instead. They keep an # existing award until they claim it (reg 8(2A)), so this route also - # rules out receiving Universal Credit. Working-age awards outside - # specified or temporary accommodation ended on 1 July 2026 in Great - # Britain (SI 2025/1148 art. 7); that is not modelled. + # rules out receiving Universal Credit. SI 2025/1148 art. 7 ends the + # remaining working-age awards on 1 July 2026 without a claim (SR + # 2025/176 art. 7: 1 October 2026 in Northern Ireland), except in + # specified or temporary accommodation (art. 7(4)(a), through reg + # 6A(2); NI reg 4A(2)). The model doesn't apply that date yet, so it + # pays such an award for the whole of 2026-27 and keeps it until the + # family claims or DWP moves it; #1910 adds the date. already_claiming = add(benunit, period, ["housing_benefit_reported"]) > 0 claiming_uc = benunit("claims_universal_credit", period) # Once another of the family's legacy benefits has closed, DWP's # migration notice ends this award even if the family does not claim - # Universal Credit (reg 46(1)(a), (2)). + # Universal Credit (reg 46(1)(a)), but not an award for specified or + # temporary accommodation, whichever legacy benefit closed (reg + # 46(2); NI: SR 2016/226 reg 47(2)). A claim still ends that award + # here: reg 8(3) keeps it, but the model's Universal Credit pays a + # housing costs element for these homes, so keeping it would pay the + # rent twice. migrated = benunit("legacy_benefits_closed", period) - continuing_award = already_claiming & ~claiming_uc & ~migrated + accommodation = benunit("in_specified_or_temporary_accommodation", period) + continuing_award = already_claiming & ~claiming_uc & (~migrated | accommodation) social = benunit.any(person("in_social_housing", period)) lha_eligible = benunit("LHA_eligible", period) pension_age_regulations = benunit( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py index f302195ae4..8d3360979e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py @@ -14,10 +14,13 @@ class claims_universal_credit(Variable): "Benefit continuing award and Child Tax Credit, and the claimant's " "and partner's income-related ESA (esa_income_report_closed). Once " "DWP moves a family off legacy benefits (legacy_benefits_closed) all " - "its legacy awards end; for other claimants the overlap of Universal " - "Credit with Working Tax Credit, Income Support and income-based JSA " - "is not modelled, nor is Housing Benefit for specified or temporary " - "accommodation." + "its legacy awards end, except Housing Benefit for specified or " + "temporary accommodation while the family does not claim " + "(housing_benefit_eligible). For other claimants the model doesn't " + "represent the overlap of Universal Credit with Working Tax Credit, " + "Income Support and income-based JSA, or the Housing Benefit for " + "specified or temporary accommodation that continues beside a claim " + "(SI 2014/1230 reg 8(3))." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py index 2ae04645e4..32d68dfdc6 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/legacy_benefits_closed.py @@ -69,16 +69,19 @@ class legacy_benefits_closed(Variable): "Jobseeker's Allowance from 1 April 2026 (their active parameters). " "The migration notice covers all the family's legacy awards, so they " "all end: on a Universal Credit claim, or at the notice's deadline if " - "the family does not claim. Families DWP sent to Pension Credit " + "the family does not claim. Housing Benefit for specified or " + "temporary accommodation survives the deadline (SI 2014/1230 reg " + "46(2); housing_benefit_eligible). Families DWP sent to Pension Credit " "instead are excluded: those on Pension Credit, and those on Child Tax " "Credit but not Working Tax Credit that meet the Pension Credit age " "conditions. Pension-age and protected mixed-age families on Working " "Tax Credit are included; Income Support and income-based JSA " "closures do not move a wholly pension-age family. Income-related " "Employment and Support Allowance joins this list from 2027-28: the " - "Secretary of State appointed 1 July 2026 (the Department, 1 October " - "2026 in Northern Ireland) for its abolition to reach every remaining " - "award, so from then a reported claim has nothing left payable " + "Secretary of State appointed 1 July 2026 (the Department for " + "Communities, 1 October 2026 in Northern Ireland) for its abolition " + "to reach every remaining award, so from then a reported claim has " + "nothing left payable " "(esa_income_payable_share is 0), unless an appointee saving keeps " "it. The same Pension Credit and pension-age exceptions apply. In " "2026-27 a family whose award the abolition reaches can claim " @@ -86,7 +89,7 @@ class legacy_benefits_closed(Variable): "Housing Benefit joins this list when the model ends its working-age " "awards. Reports by other benefit-unit members do not close or " "shield this award. This annual proxy does not model actual notices, " - "deadlines, run-on periods or Housing Benefit exceptions." + "deadlines or run-on periods." ) definition_period = YEAR reference = ( From a3f8b1104e9349122208b521085db48f1e07df25 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 10:12:00 -0500 Subject: [PATCH 14/23] Apply the ESA report closure to an outside member only when both held amounts are the formula's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit own_report_is_paid now applies the abolition's report closure to an outside member's status only when the stored esa_income and the claimant's and partner's part (claimant_or_partner_esa_income) both match the formula on open reports (holds_formula_award). Under disable_simulated_benefits, an entered award or a replacing reform, a held couple part of £5,000 against a formula's £5,200 identifies the outside adult's held £200, so their status no longer depends on whether the claimant's own report happens to give the held total. The new test holds the awards three ways and varies the claimant's 2027-28 report across £5,199, £5,200 and £5,201. Invariant 12 checks that without a reform both amounts match the formula for every generated benefit unit, so the extra comparison changes no baseline result. Co-Authored-By: Claude Opus 5.5 --- .../test_esa_income_abolition_properties.py | 26 +++- .../test_esa_income_held_outside_award.py | 140 ++++++++++++++++++ .../variables/gov/dwp/_legacy_award_payee.py | 80 +++++++--- 3 files changed, 225 insertions(+), 21 deletions(-) create mode 100644 policyengine_uk/tests/test_esa_income_held_outside_award.py diff --git a/policyengine_uk/tests/test_esa_income_abolition_properties.py b/policyengine_uk/tests/test_esa_income_abolition_properties.py index 87408a20dd..e011c00c81 100644 --- a/policyengine_uk/tests/test_esa_income_abolition_properties.py +++ b/policyengine_uk/tests/test_esa_income_abolition_properties.py @@ -58,6 +58,11 @@ for each £250 or part above it and the £16,000 limit, ESA Regs 2008 regs 110 and 118), the dates and the saving, without the model's code. This bounds a couple's claim by one share and keeps a saved claim whole. +12. Held awards: with no reform holding esa_income, both of its held + amounts, the award and the claimant's and partner's part, match the + formula on open reports for every benefit unit (holds_formula_award), so + the report closure decides each outside member's status in + own_report_is_paid. """ import datetime @@ -68,7 +73,12 @@ from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st +from policyengine_core.periods import period + from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.variables.gov.dwp._legacy_award_payee import ( + holds_formula_award, +) pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -114,6 +124,8 @@ "esa_income_abolition_reached", "legacy_benefits_closed", ] +# Benefit-unit outputs of the payee helpers rather than variables. +HELPER_OUTPUTS = ["holds_formula_award"] PERSON_OUTPUTS = [ "esa_contrib", "esa_income_payable_share", @@ -230,13 +242,18 @@ def situation(units): def calculate(units, reform=None): sim = Simulation(situation=situation(units), reform=reform) - return { + values = { year: { v: np.asarray(sim.calculate(v, year)) for v in BENUNIT_OUTPUTS + PERSON_OUTPUTS } for year in YEARS } + for year in YEARS: + values[year]["holds_formula_award"] = holds_formula_award( + sim.populations["benunit"], period(year), "esa_income" + ) + return values def calculate_each_mode(units, modes=MODES): @@ -248,8 +265,8 @@ def calculate_each_mode(units, modes=MODES): values = calculate(combined) out = {mode: {year: {} for year in YEARS} for mode in modes} for year in YEARS: - for name in BENUNIT_OUTPUTS + PERSON_OUTPUTS: - size = n if name in BENUNIT_OUTPUTS else p + for name in BENUNIT_OUTPUTS + HELPER_OUTPUTS + PERSON_OUTPUTS: + size = n if name not in PERSON_OUTPUTS else p for k, mode in enumerate(modes): out[mode][year][name] = values[year][name][k * size : (k + 1) * size] return out @@ -482,6 +499,9 @@ def test_abolition_invariants(units): ), year assert not np.any(k["esa_income_abolition_reached"]), year assert not np.any(k["legacy_benefits_closed"]), year + # 12. Without a reform, both held amounts match the formula. + for v in (a, k, w, f, n): + assert np.all(v["holds_formula_award"]), year # 5. Before 2026-27 every output matches, whatever the saving and claim. for year in BEFORE: for name in BENUNIT_OUTPUTS + PERSON_OUTPUTS: diff --git a/policyengine_uk/tests/test_esa_income_held_outside_award.py b/policyengine_uk/tests/test_esa_income_held_outside_award.py new file mode 100644 index 0000000000..b937352789 --- /dev/null +++ b/policyengine_uk/tests/test_esa_income_held_outside_award.py @@ -0,0 +1,140 @@ +"""A held outside award keeps its receipt status after the ESA abolition. + +Each benefit unit has a claimant with a saved award (SI 2025/1148 art. 3A(2)) +and an outside adult who claims income-related ESA in their own right with +an unsaved £200 award. The family holds £5,200 of income-related ESA +(esa_income) and the claimant and partner £5,000 of it +(claimant_or_partner_esa_income), so the outside adult holds £200 in every +year. In 2027-28 the abolition has ended the outside adult's report +(esa_income_report_closed). The claimant's own 2027-28 report varies across +the three units: £5,199, £5,200 and £5,201. In the middle unit the formula +on open reports gives £5,200, the held total, from the claimant's report +alone. + +own_report_is_paid applies the report closure to an outside member's status +only when both held amounts match the formula on open reports +(holds_formula_award). The held £5,000 never matches the formula's £5,200, +so in every unit the outside adult stays on income-related ESA, as the held +award decides for the claimant and partner. + +The awards are held three ways: by the disable_simulated_benefits reform +("frozen"), as entered values ("stored"), and by a reform that replaces +esa_income's formula ("replaced"). +""" + +import json + +import numpy as np +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.model_api import YEAR, BenUnit, Reform, Variable +from policyengine_uk.utils.scenario import Scenario + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEARS = (2025, 2026, 2027) +UNITS = ("below", "equal", "above") +CLAIMANT_REPORTS = (5_199, 5_200, 5_201) +HELD_TOTAL = 5_200 +HELD_COUPLE = 5_000 +OUTSIDE_REPORT = 200 +FROZEN = Scenario( + applied_before_data_load=True, + parameter_changes={"gov.contrib.policyengine.disable_simulated_benefits": True}, +) + + +def every_year(value): + return {year: value for year in YEARS} + + +def situation(mode): + people, benunits, households = {}, {}, {} + for unit, claimant_report in zip(UNITS, CLAIMANT_REPORTS): + claimant, outside = f"{unit}_claimant", f"{unit}_outside" + for name, age, couple, reports, saved in ( + ( + claimant, + 40, + True, + {2025: HELD_COUPLE, 2026: HELD_COUPLE, 2027: claimant_report}, + True, + ), + (outside, 30, False, every_year(OUTSIDE_REPORT), False), + ): + people[name] = { + "age": every_year(age), + "is_claimant_or_partner": every_year(couple), + "current_education": every_year("NOT_IN_EDUCATION"), + "receives_carer_benefit": every_year(False), + "weekly_hours": every_year(0), + "employment_income": every_year(0), + "esa_income_reported": reports, + "esa_income_appointee_saving": every_year(saved), + } + benunits[unit] = { + "members": [claimant, outside], + "would_claim_uc": every_year(False), + "would_claim_uc_at_legacy_closure": every_year(False), + "esa_income_assessable_capital": every_year(0), + } + households[unit] = { + "members": [claimant, outside], + "region": every_year("LONDON"), + } + if mode != "frozen": + # Hold what the reform holds: £5,000 for the claimant and partner, + # and nobody moved off their legacy benefits. + benunits[unit]["claimant_or_partner_esa_income"] = every_year(HELD_COUPLE) + benunits[unit]["legacy_benefits_closed"] = every_year(False) + benunits[unit]["esa_income_abolition_reached"] = every_year(False) + for name in (claimant, outside): + people[name]["person_legacy_benefits_closed"] = every_year(False) + if mode == "stored": + benunits[unit]["esa_income"] = every_year(HELD_TOTAL) + return {"people": people, "benunits": benunits, "households": households} + + +class esa_income(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + + def formula(benunit, period, parameters): + return benunit.filled_array(HELD_TOTAL) + + +class HoldTotal(Reform): + def apply(self): + self.update_variable(esa_income) + + +@pytest.mark.parametrize("mode", ("frozen", "stored", "replaced")) +def test_a_held_outside_award_survives_an_equal_formula_total(mode): + simulation = Simulation( + situation=situation(mode), scenario=FROZEN if mode == "frozen" else None + ) + if mode == "replaced": + simulation.apply_reform(HoldTotal) + values = { + name: simulation.calculate(name, 2027).tolist() + for name in ( + "esa_income", + "claimant_or_partner_esa_income", + "person_legacy_benefits_closed", + "esa_income_report_closed", + "is_on_income_related_esa", + ) + } + print(json.dumps({"mode": mode, "values": values}, sort_keys=True)) + np.testing.assert_array_equal(values["esa_income"], [HELD_TOTAL] * 3) + np.testing.assert_array_equal( + values["claimant_or_partner_esa_income"], [HELD_COUPLE] * 3 + ) + np.testing.assert_array_equal(values["person_legacy_benefits_closed"], [False] * 6) + # The abolition has ended each outside adult's unsaved report. + np.testing.assert_array_equal(values["esa_income_report_closed"], [False, True] * 3) + # Each outside adult has the same held award, work, capital and report; + # only the saved claimant's 2027-28 report differs. + assert values["is_on_income_related_esa"] == [True] * 6, (mode, values) diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index fa0d2ba924..da71e4e2eb 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -1,7 +1,11 @@ """Who is "on" a legacy income-related award.""" from policyengine_uk.model_api import * -from policyengine_uk.utils.benefit_unit import HALF_PENNY, REPORT_CLOSED +from policyengine_uk.utils.benefit_unit import ( + CLAIMANT_OR_PARTNER_AWARDS, + HALF_PENNY, + REPORT_CLOSED, +) from policyengine_uk.variables.gov.dwp.esa_income import ( income_related_esa_award_on_reports, ) @@ -63,15 +67,20 @@ def own_report_is_paid(person, period, award, works, capital_limit): Their own legacy benefits must remain open under the existing annual proxy (person_legacy_benefits_closed). A couple's closure does not end - this separate claim. An award with closures of its own (REPORT_CLOSED: - for income-related ESA, its abolition, esa_income_report_closed) has its - formula's award on open person reports in AWARD_ON_REPORTS. Where the - stored award is that formula's, those closures, which leave the report - out of it, end status too. Where it holds another value, - entered directly or held by disable_simulated_benefits in every year, - that value decides payment, as it does for the claimant and partner - (claimant_or_partner_esa_income), so only the legacy closure ends - status. The benefit unit's award must also be in payment: + this separate claim. Income-related ESA also closes reports of its own + (REPORT_CLOSED: its abolition, esa_income_report_closed). For it, + own_report_is_paid compares the held award with the formula on open + reports (AWARD_ON_REPORTS), both the benefit unit's award and the + claimant's and partner's part of it (holds_formula_award). When both + match, it applies the ESA report closure. When either differs, it + applies only the person's legacy closure, and the held award decides + payment, as it does for the claimant and partner. A value entered + directly, a reform that replaces the award, or + disable_simulated_benefits, which holds both amounts in every year, can + make them differ. The comparison can't separate two outside reporters in + one benefit unit when the held total and the couple's part both equal + the formula's: own_report_is_paid then applies each one's report + closure. The benefit unit's award must also be in payment: positive, or nil while its work/capital screen fails and the award is not neutralised. A directly entered or reformed nil removes status while that screen passes; neutralising the award always removes it. These @@ -87,18 +96,53 @@ def own_report_is_paid(person, period, award, works, capital_limit): ) ) closed = person("person_legacy_benefits_closed", period) - award_on_reports = AWARD_ON_REPORTS.get(award) - if award_on_reports is not None: + if award in AWARD_ON_REPORTS: units = person.simulation.populations["benunit"] report_closed = person(REPORT_CLOSED[award], period) - stored = units(award, period) - open_reported = person(f"{award}_reported", period) * ~report_closed - formula_award = award_on_reports(units, period, open_reported) - as_formula = np.isclose( - stored, formula_award.astype(stored.dtype), rtol=0, atol=HALF_PENNY - ) + as_formula = holds_formula_award(units, period, award) closed = where(units.project(as_formula), report_closed, closed) removed = person.simulation.tax_benefit_system.get_variable(award).is_neutralized paid = benunit(award, period) > 0 screen_fails = ~benunit(f"{award}_eligible", period) return own_award & ~closed & (paid | (screen_fails & (not removed))) + + +def holds_formula_award(benunit, period, award): + """Whether each benefit unit holds its formula's award (award: + "esa_income"): both the stored award and the claimant's and partner's + part of it (claimant_or_partner_esa_income) equal the formula in + AWARD_ON_REPORTS on open person reports, compared to within half a + penny at the precision each is stored in. + + benunit is the simulation's benefit-unit population. Under + disable_simulated_benefits the two held amounts differ from the + formula's split even where the held total equals the formula's: with + £5,200 held for the family and £5,000 for the claimant and partner, an + outside adult holds £200, whatever the claimant's own open report gives. + + When the model calculates both, they always match. esa_income applies + the formula to open reports, and claimant_or_partner_award applies the + same formula to the claimant's and partner's open reports. It sets that + part to zero only where the part, or the stored total that bounds it, + falls within half a penny of zero, so the formula's part then falls + within half a penny of the zero too. + """ + award_on_reports = AWARD_ON_REPORTS[award] + person = benunit.members + open_reported = person(f"{award}_reported", period) * ~person( + REPORT_CLOSED[award], period + ) + couple = person("is_claimant_or_partner", period) + held = ( + (benunit(award, period), award_on_reports(benunit, period, open_reported)), + ( + benunit(CLAIMANT_OR_PARTNER_AWARDS[award], period), + award_on_reports(benunit, period, open_reported, couple), + ), + ) + return np.logical_and.reduce( + [ + np.isclose(stored, formula.astype(stored.dtype), rtol=0, atol=HALF_PENNY) + for stored, formula in held + ] + ) From ce6f62f49c7305a7cf5ec5d6d04395f6a9df5896 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 10:40:30 -0500 Subject: [PATCH 15/23] Name what the abolition's reach reads and the planned claim draw; name who acts in its documentation esa_income_abolition_reached lists every input it reads: the claimant's and partner's ESA and Pension Credit reports, the household's country, the saving, the Pension Credit age conditions and mixed-age couple status. would_claim_uc_at_legacy_closure says the certified data (1.58.1) carry no draw and that PolicyEngine/policyengine-uk-data#534 plans one. Documentation and comments introduce the Department for Communities by name and give the model, not a share or a year, the paying verbs. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/esa-jsa-report-screening.md | 5 +++-- .../gov/dwp/ESA/income/awards_payable.yaml | 4 ++-- .../disable_simulated_benefits.py | 7 ++++--- .../esa_income/esa_income_abolition.yaml | 20 ++++++++++--------- .../tests/test_disable_simulated_benefits.py | 7 ++++--- .../test_esa_income_abolition_properties.py | 20 +++++++++---------- .../variables/gov/dwp/esa_income.py | 10 +++++----- .../gov/dwp/esa_income_appointee_saving.py | 12 ++++++----- .../gov/dwp/esa_income_payable_share.py | 18 +++++++++-------- .../esa_income_abolition_reached.py | 14 ++++++++----- .../would_claim_uc_at_legacy_closure.py | 12 ++++++----- 11 files changed, 72 insertions(+), 57 deletions(-) diff --git a/docs/engineering/esa-jsa-report-screening.md b/docs/engineering/esa-jsa-report-screening.md index 2ed94bae26..94e8d27abd 100644 --- a/docs/engineering/esa-jsa-report-screening.md +++ b/docs/engineering/esa-jsa-report-screening.md @@ -15,8 +15,9 @@ raise the total; failure of the couple's own screen cannot erase another eligibl person's separate award. These examples presume separate preassessed claims. The Secretary of State appointed 1 July 2026 for the abolition of income-related -ESA to reach every remaining award in Great Britain, and the Department appointed -1 October 2026 in Northern Ireland (SI 2025/1148 art. 3A; SR 2025/176 art. 3A). +ESA to reach every remaining award in Great Britain, and the Department for +Communities appointed 1 October 2026 in Northern Ireland (SI 2025/1148 art. 3A; +SR 2025/176 art. 3A). An award already due to end at the end of a two-week run-on ends on that schedule instead: with a migration notice deadline of 22 June 2026 and no claim, SI 2014/1230 reg 46(1)(a) ends the award on 5 July. The model has no deadline or diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml index 7d8029ba94..f8fe70b6c6 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml @@ -2,8 +2,8 @@ description: >- Whether old style income-related Employment and Support Allowance awards are still payable (1) or abolished (0). The Secretary of State appointed 1 July 2026 for the Welfare Reform Act 2012 abolition of income-related ESA to - reach every remaining award in Great Britain, and the Department appointed - 1 October 2026 in Northern Ireland. Two kinds of award escape that date: one + reach every remaining award in Great Britain, and the Department for + Communities appointed 1 October 2026 in Northern Ireland. Two kinds of award escape that date: one saved for an appointee (esa_income_appointee_saving), and one already due to end at the end of a two-week run-on, which ends on that schedule instead. A claimant with a migration notice deadline of 22 June 2026 who makes no diff --git a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py index d3e94e56ed..ebf302f7bd 100644 --- a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py +++ b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py @@ -77,9 +77,10 @@ # has the scheme open (gov.dwp.JSA.income.active: to 2025). One who reports # income-related ESA is on it in every year, as the reform pays it in every # year: the abolition closes their report (esa_income_report_closed), but -# the held award, not the formula's, decides their status -# (_legacy_award_payee.own_report_is_paid). Hold the family closure, each -# separate member's own closure, and the abolition's claim. +# _legacy_award_payee.own_report_is_paid uses the reform's held award to +# determine their receipt status, as it does for the claimant and partner. +# Hold the family closure, each separate member's own closure, and the +# abolition's claim. NOT_MOVED = [ "legacy_benefits_closed", "person_legacy_benefits_closed", diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml index 2350579490..8168fbd859 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml @@ -1,16 +1,17 @@ # The Secretary of State appointed 1 July 2026 for the abolition of # income-related ESA (Welfare Reform Act 2012 s.33(1)(b)) to reach every # remaining old style ESA award in Great Britain (SI 2025/1148 art. 3A(1), -# inserted by SI 2026/409). The Department appointed 1 October 2026 in -# Northern Ireland (Welfare Reform (NI) Order 2015 art. 39(1)(b); SR 2025/176 -# art. 3A(1), inserted by SR 2026/115). +# inserted by SI 2026/409). The Department for Communities appointed 1 +# October 2026 in Northern Ireland (Welfare Reform (NI) Order 2015 art. +# 39(1)(b); SR 2025/176 art. 3A(1), inserted by SR 2026/115). # # Each model year is the fiscal year from 6 April to 5 April; 2026-27 has 365 # days. A Great Britain award is payable for the 86 days from 6 April to 30 # June 2026 (25 + 31 + 30) and a Northern Ireland award for the 178 days from -# 6 April to 30 September 2026 (25 + 31 + 30 + 31 + 31 + 30). So 2026-27 pays -# the annual award times 86/365 = 0.235616 or 178/365 = 0.487671, and -# 2027-28 pays nothing. Art. 3A(1)(b) leaves an award already due to end at +# 6 April to 30 September 2026 (25 + 31 + 30 + 31 + 31 + 30). So in 2026-27 +# the model pays the annual award times 86/365 = 0.235616 or 178/365 = +# 0.487671, and it pays no unsaved income-related ESA from 2027-28. Art. +# 3A(1)(b) leaves an award already due to end at # the end of a two-week run-on to that schedule; the annual model pays every # award to the general date. # @@ -535,7 +536,8 @@ members: [claimant, partner] region: LONDON output: - # The couple's award is one award, so both members share its share. + # The claimant and partner share an award, so the model applies the + # reporting member's saving to the whole claim. esa_income_payable_share: [1, 1] esa_income_abolition_reached: false legacy_benefits_closed: false @@ -879,8 +881,8 @@ members: [claimant, partner] region: LONDON output: - # A couple's award is one award: the saving keeps it, the partner's - # report included. + # The claimant and partner share an award, so the model applies the + # claimant's saving to the whole claim, the partner's report included. esa_income_payable_share: [1, 1] esa_income_report_closed: [false, false] esa_income_abolition_reached: false diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 564383444d..31875f8ea3 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -379,9 +379,10 @@ def test_each_person_is_on_their_own_award(reformed, year): ).gov.dwp.JSA.income.active assert jsa_open == (year == DATASET_YEAR) # The reform pays their income-related ESA in every year too, after its - # abolition on 1 July 2026 (SI 2025/1148 art. 3A) included. The award it - # holds, not the abolition's closure of their report, decides whether it - # is payable to them, as for the claimant and partner. + # abolition on 1 July 2026 (SI 2025/1148 art. 3A) included. + # own_report_is_paid uses the reform's held award, not the abolition's + # closure of their report, to determine their receipt status, as it does + # for the claimant and partner. assert not on_esa["excluded_adult_no_award_0"] assert on_esa["excluded_adult_no_award_1"] assert not on_jsa["excluded_adult_no_award_0"] diff --git a/policyengine_uk/tests/test_esa_income_abolition_properties.py b/policyengine_uk/tests/test_esa_income_abolition_properties.py index e011c00c81..3bfbf173e3 100644 --- a/policyengine_uk/tests/test_esa_income_abolition_properties.py +++ b/policyengine_uk/tests/test_esa_income_abolition_properties.py @@ -18,9 +18,9 @@ Each generated population runs in one simulation five times over: with the saving and claim flag as drawn; with every award saved; with none saved; with the saving of every member who reports nothing flipped; and with -nobody claiming when moved. Saving every award pays it in full in every -year, as the rule before the abolition did; a reform that keeps the -parameter at 1 gives the same outputs +nobody claiming when moved. With every award saved, the model pays it in +full in every year, as the rule before the abolition did; a reform that +keeps the parameter at 1 gives the same outputs (test_keeping_awards_payable_is_saving_every_award). Invariants, for any generated population of working-age families in @@ -38,8 +38,8 @@ 4. Contributory ESA never changes, whatever is saved or claimed. 5. Before 2026-27 every output matches, whatever the saving and claim flags; the abolition never raises anyone's income-related ESA; and a family - that claims nothing and saves nothing gets exactly its jurisdiction's - share of the award before the abolition. + that claims nothing and saves nothing gets its jurisdiction's share of + the award before the abolition. 6. Monotonic: turning the saving on for any members never lowers a benefit unit's esa_income or esa. 7. Status: a member outside the couple is on income-related ESA only while @@ -336,8 +336,8 @@ def independent_reading(unit, year): # all of it is payable; from the year none is, DWP moves the family. reached = couple_reported > 0 and not couple_saved and share < 1 claims = unit["would_claim_uc"] or (reached and unit["claims_when_moved"]) - # A claim ends the couple's reports; the abolition pays the rest for - # the claim's share of the year. + # A Universal Credit claim closes the couple's reports; the model pays + # other open reports for each claim's payable share. couple_award = 0.0 if couple_reported > 0 and within_limit and not claims: couple_award = max(0.0, couple_reported - tariff) * couple_share @@ -461,9 +461,9 @@ def test_abolition_invariants(units): v["claimant_or_partner_esa_income"] <= k["claimant_or_partner_esa_income"] + 1e-6 ), year - # 5. With no claim and no saving, each award is the jurisdiction's - # share of the award before the abolition. (The no-claim copy keeps - # the drawn saving, so this reads families with nothing saved.) + # 5. With no claim and no saving, the model pays each earlier award + # at the jurisdiction's payable share. (The no-claim copy keeps the + # drawn saving, so this reads families with nothing saved.) expected = jurisdiction_share(units, year) * k["esa_income"] claims_anyway = np.array([unit["would_claim_uc"] for unit in units]) plain = unsaved_unit & ~claims_anyway diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index 8064e4a446..4bdb566124 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -33,9 +33,9 @@ def income_related_esa_award_on_reports(benunit, period, reported, members=None) own_claim = ~couple & ~works & benunit.project(capital <= limit) tariff = benunit.project(benunit("esa_income_tariff_income", period)) own_awards = where(own_claim, max_(0, reported - tariff), 0) - # Each claim is paid for the share of the year before the abolition of - # income-related ESA reached it (SI 2025/1148 art. 3A; SR 2025/176 art. - # 3A). The claimant and partner share one claim, so one share. + # The model pays each claim for the share of the year before its + # abolition date (SI 2025/1148 art. 3A; SR 2025/176 art. 3A). The + # claimant and partner share one claim, so one share. share = person("esa_income_payable_share", period) couple_share = benunit.max(where(couple, share, 0)) return couple_award * couple_share + benunit.sum(own_awards * share) @@ -51,8 +51,8 @@ class esa_income(Variable): "has ended are left out (esa_income_report_closed): the owner's " "legacy benefits have closed, the owner is the claimant or " "partner of a family that claims Universal Credit, or the abolition " - "of income-related ESA has ended the award. Each claim is paid for " - "the share of the year before that abolition " + "of income-related ESA has ended the award. The model pays each claim " + "for the share of the year before its abolition date " "(esa_income_payable_share). This is not a full entitlement model." ) definition_period = YEAR diff --git a/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py index 492e1f42da..cd8fe9567d 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py @@ -14,11 +14,13 @@ class esa_income_appointee_saving(Variable): "Claims and Payments Regulations 2013 to act for a claimant who cannot " "act, or where the Secretary of State decided in the six months ending " "on that date that the claimant would likely need one. SR 2025/176 art. " - "3A(2) does the same in Northern Ireland, with the Department and reg " - "52 of the Northern Ireland Claims and Payments Regulations 2016. A " - "couple's award is one award: a saving for either member who reports " - "it saves the whole claim (esa_income_payable_share), and a saving for " - "a member who reports nothing saves nothing. A saved claim prompts no " + "3A(2) does the same in Northern Ireland, with the Department for " + "Communities and reg " + "52 of the Northern Ireland Claims and Payments Regulations 2016. The " + "claimant and partner share an award, so the model applies a " + "reporting member's saving to the whole claim " + "(esa_income_payable_share); a saving for a member who reports " + "nothing saves nothing. A saved claim prompts no " "Universal Credit claim and does not move the family " "(esa_income_abolition_reached, legacy_benefits_closed), but a " "Universal Credit claim, or another legacy benefit's closure, still " diff --git a/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py b/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py index d72b31d09c..34ed602acb 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py @@ -11,17 +11,18 @@ class esa_income_payable_share(Variable): "one claim; any other member claims in their own right. The Secretary " "of State appointed 1 July 2026 for the abolition of income-related " "ESA (Welfare Reform Act 2012 s.33(1)(b)) to reach every remaining " - "award (SI 2025/1148 art. 3A(1)), and the Department appointed 1 " - "October 2026 in Northern Ireland (SR 2025/176 art. 3A(1)). So this is " - "the share of the fiscal year before the household's date " - "(gov.dwp.ESA.income.awards_payable): 1 up to 2025-26; in 2026-27, 86 " + "award (SI 2025/1148 art. 3A(1)), and the Department for Communities " + "appointed 1 October 2026 in Northern Ireland (SR 2025/176 art. " + "3A(1)). The model uses the share of the fiscal year before the " + "household's appointed date (gov.dwp.ESA.income.awards_payable): 1 " + "up to 2025-26; in 2026-27, 86 " "of 365 days in Great Britain and 178 of 365 in Northern Ireland; and " "0 from 2027-28. Art. 3A(1)(b) leaves an award already due to end at " "the end of a two-week run-on period (art. 1(2)) to that schedule: " "with a migration notice deadline of 22 June 2026 and no claim, SI " "2014/1230 reg 46(1)(a) ends the award on 5 July, after the general " - "date. This annual share does not track deadlines or claim dates, so " - "it pays every award to the general date. A claim saved for an " + "date. The annual model does not track deadlines or claim dates and " + "pays unsaved awards to the general date. A claim saved for an " "appointee has a share of 1 in " "every year: a saving for either member who reports a couple's award " "keeps the whole claim (esa_income_appointee_saving), and a saving for " @@ -51,8 +52,9 @@ def formula(person, period, parameters): payable.great_britain, ) # Art. 3A(2) saves an award for an appointee in every year. The - # claimant and partner share one award, which a saving for either of - # them who reports it keeps; another member's award is their own. + # claimant and partner share an award, so the model applies a + # reporting member's saving to the whole claim; another member's + # award is their own. reports_saved = (person("esa_income_reported", period) > 0) & person( "esa_income_appointee_saving", period ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py b/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py index 36411ea234..cd4f2ce651 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py @@ -14,7 +14,8 @@ class esa_income_abolition_reached(Variable): "for less than the whole year (esa_income_payable_share below 1), " "from 2026-27. The Secretary of State appointed 1 July 2026 for the " "abolition to reach every remaining award (SI 2025/1148 art. 3A), and " - "the Department appointed 1 October 2026 in Northern Ireland (SR " + "the Department for Communities appointed 1 October 2026 in Northern " + "Ireland (SR " "2025/176 art. 3A). DWP sent migration notices to 879,000 households " "on income-related ESA by December 2025, most of which had claimed " "Universal Credit (LA Welfare Direct 6/2026, paras 51 to 54). So a " @@ -26,10 +27,13 @@ class esa_income_abolition_reached(Variable): "(esa_income_appointee_saving). As for the other working-age " "closures, DWP doesn't move a family on Pension Credit, or a single " "claimant, or joint claimants both, over the qualifying age for State " - "Pension Credit (SI 2014/1230 reg 8(2B)). This reads reports, the " - "household's country and the " - "saving, never an award. Reports by other benefit-unit members, who " - "claim in their own right, do not count." + "Pension Credit (SI 2014/1230 reg 8(2B)). This reads the claimant's " + "and partner's income-related ESA and Pension Credit reports, the " + "household's country, the saving, the Pension Credit age conditions " + "(meets_pension_credit_age_conditions) " + "and mixed-age couple status (is_mixed_age_couple), never a " + "calculated award. Reports by other benefit-unit members, who claim " + "in their own right, do not count." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py index c313e3b437..f8b12e7957 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/would_claim_uc_at_legacy_closure.py @@ -10,11 +10,13 @@ class would_claim_uc_at_legacy_closure(Variable): "means-tested benefit it receives closes (legacy_benefits_closed), " "or once the abolition of income-related ESA reaches the claimant's " "or partner's award (esa_income_abolition_reached, from 2026-27). " - "Datasets draw it at DWP's Move to Universal Credit claim rates by " - "legacy benefit combination. It does not affect years before the " - "closure or the abolition, when would_claim_uc alone decides the " - "Universal Credit claim. Defaults to True, like would_claim_uc, so a " - "family whose data carries no draw claims." + "It does not affect years before the closure or the abolition, when " + "would_claim_uc alone decides the Universal Credit claim. It " + "defaults to True, like would_claim_uc, so a family whose data " + "carries no draw claims. The certified data (policyengine-uk-data " + "1.58.1) carry no draw; PolicyEngine/policyengine-uk-data#534 plans " + "to draw it at DWP's Move to Universal Credit claim rates by legacy " + "benefit combination." ) definition_period = YEAR default_value = True From f82e81f93e4cff3b24ad402b26cb236b850af891 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 10:47:29 -0500 Subject: [PATCH 16/23] Describe the 2027-28 move and the accommodation exception in the changelog Co-Authored-By: Claude Opus 5.5 --- changelog.d/2562.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/2562.fixed.md b/changelog.d/2562.fixed.md index 624c90ec56..ebdb530e9d 100644 --- a/changelog.d/2562.fixed.md +++ b/changelog.d/2562.fixed.md @@ -1 +1 @@ -Model the 2026 income-related ESA abolition and its Universal Credit claims. An award pays to 30 June 2026 in Great Britain and 30 September 2026 in Northern Ireland (86/365 and 178/365 of 2026-27), and a family whose award the abolition reaches claims Universal Credit if it would claim when moved. From 2027-28 the award has ended and DWP has moved the family off its legacy benefits. A new input keeps an award saved for an appointee. +Model income-related ESA payments up to the 2026 abolition dates and Universal Credit claims when a family's award starts to end. The model pays unsaved claims to 30 June 2026 in Great Britain and 30 September 2026 in Northern Ireland, using 86/365 and 178/365 of the 2026-27 year, and from 2027-28 moves each affected family off its legacy benefits. Users can preserve an award with the new appointee-saving input. A family in specified or temporary accommodation now keeps its Housing Benefit when a legacy closure moves it. From 509842ca8601f9178c1c1adaaca58099503fbb9b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 11:20:26 -0500 Subject: [PATCH 17/23] Name who appoints the Housing Benefit dates and what the savings do, in active voice Co-Authored-By: Claude Opus 5.5 --- .../esa_income/esa_income_abolition.yaml | 16 +++---- .../housing_benefit_eligible.yaml | 9 ++-- .../test_esa_income_held_outside_award.py | 6 +-- .../variables/gov/dwp/_legacy_award_payee.py | 10 ++--- .../housing_benefit_eligible.py | 44 ++++++++++--------- 5 files changed, 45 insertions(+), 40 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml index 8168fbd859..52584d127d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml @@ -654,10 +654,10 @@ tenure_type: RENT_FROM_COUNCIL rent: 5_200 output: - # SI 2014/1230 reg 46(2): the deadline with no claim ends the - # income-related ESA award but not Housing Benefit for specified or - # temporary accommodation. With no income, the means test pays the - # whole eligible rent. + # The deadline with no claim ends the income-related ESA award, but + # Housing Benefit for specified or temporary accommodation doesn't + # terminate (SI 2014/1230 reg 46(2)). With no income, the means test + # pays the whole eligible rent. legacy_benefits_closed: true claims_universal_credit: false esa_income: 0 @@ -711,10 +711,10 @@ tenure_type: RENT_FROM_COUNCIL rent: 5_200 output: - # The family claims when the abolition reaches its award. Reg 8(3) - # keeps this Housing Benefit award after the claim, but the model's - # Universal Credit pays the rent through its housing costs element, so - # the model ends the award to avoid paying the rent twice. + # The family claims when the abolition reaches its award. Under reg + # 8(3) this Housing Benefit award continues after the claim, but the + # model's Universal Credit pays the rent through its housing costs + # element, so the model ends the award to avoid paying the rent twice. claims_universal_credit: true housing_benefit_eligible: false housing_benefit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml index dc7b75cd71..81b71f6621 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml @@ -60,10 +60,11 @@ tenure_type: RENT_FROM_COUNCIL rent: 5_200 output: - # Tax credits closed on 6 April 2025, so DWP has moved the family. SI - # 2014/1230 reg 46(2) keeps this Housing Benefit award whichever legacy - # benefit closed. The closure ends Working Tax Credit, so the family has no - # income and the means test pays the whole eligible rent. + # Tax credits closed on 6 April 2025, so DWP has moved the family. This + # Housing Benefit award doesn't terminate, whichever legacy benefit + # closed (SI 2014/1230 reg 46(2)). The closure ends Working Tax Credit, + # so the family has no income and the means test pays the whole + # eligible rent. legacy_benefits_closed: true claims_universal_credit: false working_tax_credit: 0 diff --git a/policyengine_uk/tests/test_esa_income_held_outside_award.py b/policyengine_uk/tests/test_esa_income_held_outside_award.py index b937352789..412d35b1fa 100644 --- a/policyengine_uk/tests/test_esa_income_held_outside_award.py +++ b/policyengine_uk/tests/test_esa_income_held_outside_award.py @@ -17,9 +17,9 @@ so in every unit the outside adult stays on income-related ESA, as the held award decides for the claimant and partner. -The awards are held three ways: by the disable_simulated_benefits reform -("frozen"), as entered values ("stored"), and by a reform that replaces -esa_income's formula ("replaced"). +The test holds the awards three ways: through the disable_simulated_benefits +reform ("frozen"), as entered values ("stored"), and through a reform that +replaces esa_income's formula ("replaced"). """ import json diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index da71e4e2eb..23b011652c 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -74,10 +74,10 @@ def own_report_is_paid(person, period, award, works, capital_limit): claimant's and partner's part of it (holds_formula_award). When both match, it applies the ESA report closure. When either differs, it applies only the person's legacy closure, and the held award decides - payment, as it does for the claimant and partner. A value entered - directly, a reform that replaces the award, or - disable_simulated_benefits, which holds both amounts in every year, can - make them differ. The comparison can't separate two outside reporters in + payment, as it does for the claimant and partner. They can differ when a + user enters esa_income, a reform replaces it, or + disable_simulated_benefits holds both amounts in every year. The + comparison can't separate two outside reporters in one benefit unit when the held total and the couple's part both equal the formula's: own_report_is_paid then applies each one's report closure. The benefit unit's award must also be in payment: @@ -112,7 +112,7 @@ def holds_formula_award(benunit, period, award): "esa_income"): both the stored award and the claimant's and partner's part of it (claimant_or_partner_esa_income) equal the formula in AWARD_ON_REPORTS on open person reports, compared to within half a - penny at the precision each is stored in. + penny at the precision the model stores each in. benunit is the simulation's benefit-unit population. Under disable_simulated_benefits the two held amounts differ from the diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py index 70d770a690..ad693d3cb9 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py @@ -22,16 +22,19 @@ class housing_benefit_eligible(Variable): "Universal Credit. DWP's move off a closed legacy benefit " "(legacy_benefits_closed) also ends that award, unless the family " "lives in specified or temporary accommodation " - "(in_specified_or_temporary_accommodation): SI 2014/1230 reg 46(2) " - "(NI: SR 2016/226 reg 47(2)) keeps such an award when the migration " - "notice's deadline passes without a claim, whichever legacy benefit " - "closed. The model leaves out new claims for that accommodation (reg " - "6A(2)) and reg 8(3), which keeps such an award after a Universal " - "Credit claim: the model's Universal Credit pays a housing costs " + "(in_specified_or_temporary_accommodation): such an award doesn't " + "terminate when the migration notice's deadline passes without a " + "claim, whichever legacy benefit closed (SI 2014/1230 reg 46(2); NI: " + "SR 2016/226 reg 47(2)). The model leaves out new claims for that " + "accommodation (reg " + "6A(2)) and reg 8(3), under which such an award continues after a " + "Universal Credit claim: the model's Universal Credit pays a housing costs " "element for these homes, so Housing Benefit beside it would pay the " - "rent twice. SI 2025/1148 art. 7 ends every other working-age award " - "on 1 July 2026 without a claim (SR 2025/176 art. 7: 1 October 2026 " - "in Northern Ireland). The model doesn't apply that date yet, so it " + "rent twice. The Secretary of State appointed 1 July 2026 for every " + "other working-age award to end without a claim (SI 2025/1148 art. " + "7), and the Department for Communities appointed 1 October 2026 in " + "Northern Ireland (SR 2025/176 art. 7). The model doesn't apply that " + "date yet, so it " "pays such an award for the whole of 2026-27 and keeps it until the " "family claims or DWP moves it; #1910 adds the date." ) @@ -81,23 +84,24 @@ def formula(benunit, period, parameters): # Working-age families, and mixed-age couples without the saving # (since 15 May 2019), claim Universal Credit instead. They keep an # existing award until they claim it (reg 8(2A)), so this route also - # rules out receiving Universal Credit. SI 2025/1148 art. 7 ends the - # remaining working-age awards on 1 July 2026 without a claim (SR - # 2025/176 art. 7: 1 October 2026 in Northern Ireland), except in - # specified or temporary accommodation (art. 7(4)(a), through reg - # 6A(2); NI reg 4A(2)). The model doesn't apply that date yet, so it + # rules out receiving Universal Credit. The Secretary of State + # appointed 1 July 2026 for the remaining working-age awards to end + # without a claim (SI 2025/1148 art. 7; Northern Ireland: 1 October + # 2026, SR 2025/176 art. 7), but not in specified or temporary + # accommodation (art. 7(4)(a), through reg 6A(2); NI reg 4A(2)). The + # model doesn't apply that date yet, so it # pays such an award for the whole of 2026-27 and keeps it until the # family claims or DWP moves it; #1910 adds the date. already_claiming = add(benunit, period, ["housing_benefit_reported"]) > 0 claiming_uc = benunit("claims_universal_credit", period) # Once another of the family's legacy benefits has closed, DWP's # migration notice ends this award even if the family does not claim - # Universal Credit (reg 46(1)(a)), but not an award for specified or - # temporary accommodation, whichever legacy benefit closed (reg - # 46(2); NI: SR 2016/226 reg 47(2)). A claim still ends that award - # here: reg 8(3) keeps it, but the model's Universal Credit pays a - # housing costs element for these homes, so keeping it would pay the - # rent twice. + # Universal Credit (reg 46(1)(a)). An award for specified or + # temporary accommodation doesn't terminate there, whichever legacy + # benefit closed (reg 46(2); NI: SR 2016/226 reg 47(2)). A claim + # still ends that award here. Under reg 8(3) it continues, but the + # model's Universal Credit pays a housing costs element for these + # homes, so keeping it would pay the rent twice. migrated = benunit("legacy_benefits_closed", period) accommodation = benunit("in_specified_or_temporary_accommodation", period) continuing_award = already_claiming & ~claiming_uc & (~migrated | accommodation) From 20afd3a5f0e6050a8003e89a651294154a8c025a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 1 Nov 2026 20:23:46 -0500 Subject: [PATCH 18/23] State the limits of the ESA abolition's housing and status rules Name the benefit cap and the nil claim where the docs say Housing Benefit beside a claim would pay the rent twice, and state that the benefit unit's held award decides an outside member's status. Words only. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2562.fixed.md | 2 +- .../esa_income/esa_income_abolition.yaml | 14 ++++++----- .../housing_benefit_eligible.yaml | 2 +- .../test_esa_income_held_outside_award.py | 6 ++--- .../variables/gov/dwp/_legacy_award_payee.py | 14 ++++++----- .../housing_benefit_eligible.py | 25 ++++++++++--------- .../claims_uc_at_legacy_closure.py | 5 ++-- 7 files changed, 37 insertions(+), 31 deletions(-) diff --git a/changelog.d/2562.fixed.md b/changelog.d/2562.fixed.md index ebdb530e9d..5450b09471 100644 --- a/changelog.d/2562.fixed.md +++ b/changelog.d/2562.fixed.md @@ -1 +1 @@ -Model income-related ESA payments up to the 2026 abolition dates and Universal Credit claims when a family's award starts to end. The model pays unsaved claims to 30 June 2026 in Great Britain and 30 September 2026 in Northern Ireland, using 86/365 and 178/365 of the 2026-27 year, and from 2027-28 moves each affected family off its legacy benefits. Users can preserve an award with the new appointee-saving input. A family in specified or temporary accommodation now keeps its Housing Benefit when a legacy closure moves it. +Model income-related ESA payments up to the 2026 abolition dates and Universal Credit claims when a family's award starts to end. The model pays unsaved claims to 30 June 2026 in Great Britain and 30 September 2026 in Northern Ireland, using 86/365 and 178/365 of the 2026-27 year, and from 2027-28 moves each affected family off its legacy benefits. Users can preserve an award with the new appointee-saving input. A family in specified or temporary accommodation now keeps its Housing Benefit when DWP moves it off its legacy benefits, unless it claims Universal Credit. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml index 52584d127d..35b1e5c439 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml @@ -20,9 +20,10 @@ # and the claim ends the award. From 2027-28 the award has ended and DWP # moves the family off its legacy benefits (legacy_benefits_closed). Cases # that test the 2026-27 share set would_claim_uc_at_legacy_closure to false. -# The move ends the family's Housing Benefit, except for specified or +# DWP's move ends the family's Housing Benefit, except for specified or # temporary accommodation (SI 2014/1230 reg 46(2)); a claim ends it there -# too, because the model's Universal Credit pays the rent. +# too, because the model's Universal Credit pays the rent, subject to the +# benefit cap. # # Art. 3A(2) saves an award for an appointee (esa_income_appointee_saving). # A saving for either member who reports a couple's award keeps the whole @@ -711,10 +712,11 @@ tenure_type: RENT_FROM_COUNCIL rent: 5_200 output: - # The family claims when the abolition reaches its award. Under reg - # 8(3) this Housing Benefit award continues after the claim, but the - # model's Universal Credit pays the rent through its housing costs - # element, so the model ends the award to avoid paying the rent twice. + # The family claims on its award's appointed day. Under reg 8(3) this + # Housing Benefit award continues after the claim, but the model's + # Universal Credit pays the rent through its housing costs element, + # subject to the benefit cap, so the model ends the award to avoid + # paying the rent twice beside an uncapped award. claims_universal_credit: true housing_benefit_eligible: false housing_benefit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml index 81b71f6621..e86a89f6d1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml @@ -60,7 +60,7 @@ tenure_type: RENT_FROM_COUNCIL rent: 5_200 output: - # Tax credits closed on 6 April 2025, so DWP has moved the family. This + # Tax credits ended on 5 April 2025, so DWP has moved the family. This # Housing Benefit award doesn't terminate, whichever legacy benefit # closed (SI 2014/1230 reg 46(2)). The closure ends Working Tax Credit, # so the family has no income and the means test pays the whole diff --git a/policyengine_uk/tests/test_esa_income_held_outside_award.py b/policyengine_uk/tests/test_esa_income_held_outside_award.py index 412d35b1fa..7637fe94b1 100644 --- a/policyengine_uk/tests/test_esa_income_held_outside_award.py +++ b/policyengine_uk/tests/test_esa_income_held_outside_award.py @@ -13,9 +13,9 @@ own_report_is_paid applies the report closure to an outside member's status only when both held amounts match the formula on open reports -(holds_formula_award). The held £5,000 never matches the formula's £5,200, -so in every unit the outside adult stays on income-related ESA, as the held -award decides for the claimant and partner. +(holds_formula_award). The held £5,000 never matches the formula's couple +part (£5,199, £5,200 or £5,201), so in every unit the outside adult stays on +income-related ESA while the benefit unit's held award stays positive. The test holds the awards three ways: through the disable_simulated_benefits reform ("frozen"), as entered values ("stored"), and through a reform that diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 23b011652c..85339ff44e 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -73,13 +73,15 @@ def own_report_is_paid(person, period, award, works, capital_limit): reports (AWARD_ON_REPORTS), both the benefit unit's award and the claimant's and partner's part of it (holds_formula_award). When both match, it applies the ESA report closure. When either differs, it - applies only the person's legacy closure, and the held award decides - payment, as it does for the claimant and partner. They can differ when a + applies only the person's legacy closure, and the benefit unit's held + award decides payment. That award covers the whole unit, so an outside + member with an open report stays paid while the unit's award stays + positive, even if it leaves them nothing. The amounts can differ when a user enters esa_income, a reform replaces it, or disable_simulated_benefits holds both amounts in every year. The - comparison can't separate two outside reporters in - one benefit unit when the held total and the couple's part both equal - the formula's: own_report_is_paid then applies each one's report + comparison can't separate two or more outside reporters in one benefit + unit when the held total and the couple's part both equal the + formula's: own_report_is_paid then applies each one's report closure. The benefit unit's award must also be in payment: positive, or nil while its work/capital screen fails and the award is not neutralised. A directly entered or reformed nil removes status while @@ -125,7 +127,7 @@ def holds_formula_award(benunit, period, award): same formula to the claimant's and partner's open reports. It sets that part to zero only where the part, or the stored total that bounds it, falls within half a penny of zero, so the formula's part then falls - within half a penny of the zero too. + within half a penny of zero too. """ award_on_reports = AWARD_ON_REPORTS[award] person = benunit.members diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py index ad693d3cb9..0f0333a689 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py @@ -19,9 +19,9 @@ class housing_benefit_eligible(Variable): "Pension Credit. Any " "other family keeps Housing Benefit only while it continues an " "existing award (reported Housing Benefit) and does not claim " - "Universal Credit. DWP's move off a closed legacy benefit " - "(legacy_benefits_closed) also ends that award, unless the family " - "lives in specified or temporary accommodation " + "Universal Credit. When DWP moves the family off a closed legacy " + "benefit (legacy_benefits_closed), the move also ends that award, " + "unless the family lives in specified or temporary accommodation " "(in_specified_or_temporary_accommodation): such an award doesn't " "terminate when the migration notice's deadline passes without a " "claim, whichever legacy benefit closed (SI 2014/1230 reg 46(2); NI: " @@ -29,14 +29,14 @@ class housing_benefit_eligible(Variable): "accommodation (reg " "6A(2)) and reg 8(3), under which such an award continues after a " "Universal Credit claim: the model's Universal Credit pays a housing costs " - "element for these homes, so Housing Benefit beside it would pay the " - "rent twice. The Secretary of State appointed 1 July 2026 for every " + "element for these homes, subject to the benefit cap, so Housing " + "Benefit beside an uncapped award would pay the rent twice. The " + "Secretary of State appointed 1 July 2026 for every " "other working-age award to end without a claim (SI 2025/1148 art. " "7), and the Department for Communities appointed 1 October 2026 in " "Northern Ireland (SR 2025/176 art. 7). The model doesn't apply that " - "date yet, so it " - "pays such an award for the whole of 2026-27 and keeps it until the " - "family claims or DWP moves it; #1910 adds the date." + "date, so it pays such an award for the whole of 2026-27 and keeps it " + "until the family claims or DWP moves it." ) definition_period = YEAR reference = ( @@ -89,9 +89,9 @@ def formula(benunit, period, parameters): # without a claim (SI 2025/1148 art. 7; Northern Ireland: 1 October # 2026, SR 2025/176 art. 7), but not in specified or temporary # accommodation (art. 7(4)(a), through reg 6A(2); NI reg 4A(2)). The - # model doesn't apply that date yet, so it - # pays such an award for the whole of 2026-27 and keeps it until the - # family claims or DWP moves it; #1910 adds the date. + # model doesn't apply that date, so it pays such an award for the + # whole of 2026-27 and keeps it until the family claims or DWP moves + # it. already_claiming = add(benunit, period, ["housing_benefit_reported"]) > 0 claiming_uc = benunit("claims_universal_credit", period) # Once another of the family's legacy benefits has closed, DWP's @@ -101,7 +101,8 @@ def formula(benunit, period, parameters): # benefit closed (reg 46(2); NI: SR 2016/226 reg 47(2)). A claim # still ends that award here. Under reg 8(3) it continues, but the # model's Universal Credit pays a housing costs element for these - # homes, so keeping it would pay the rent twice. + # homes, subject to the benefit cap, so keeping it beside an uncapped + # award would pay the rent twice. migrated = benunit("legacy_benefits_closed", period) accommodation = benunit("in_specified_or_temporary_accommodation", period) continuing_award = already_claiming & ~claiming_uc & (~migrated | accommodation) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py index f36ae782db..0a73a527ca 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py @@ -14,8 +14,9 @@ class claims_uc_at_legacy_closure(Variable): "the income-related ESA award (esa_income_report_closed) and the " "Housing Benefit award (housing_benefit_eligible). A family that does " "not claim keeps its income-related ESA for the part of 2026-27 " - "before the abolition reached it, and its Housing Benefit, until " - "legacy_benefits_closed moves it in 2027-28." + "before the abolition reached it, and its Housing Benefit until " + "legacy_benefits_closed moves it in 2027-28, or beyond that in " + "specified or temporary accommodation." ) definition_period = YEAR reference = ( From 5a5ac5fb1c7547b08a0b6dc042c4e8e253050abf Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 2 Nov 2026 00:33:25 -0500 Subject: [PATCH 19/23] Limit the claim gate invariant to members who report an award A member who reports nothing has no ESA report to close, so esa_income_report_closed can differ from person_legacy_benefits_closed for them with no effect on any award. Co-Authored-By: Claude Opus 5.5 --- ...est_esa_income_universal_credit_claim_properties.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py b/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py index 5a933ad3a4..601d50019a 100644 --- a/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py +++ b/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py @@ -18,8 +18,8 @@ partner no income-related ESA (claimant_or_partner_esa_income), and neither of them is on it (is_on_income_related_esa). So Universal Credit and the claimant's or partner's income-related ESA are never both paid. - Without a claim the gate is inert: esa_income_report_closed is - person_legacy_benefits_closed. + Without a claim the gate is inert: for each member who reports an award, + esa_income_report_closed is person_legacy_benefits_closed. 2. Differential: claiming Universal Credit gives the same income-related ESA outcomes (esa_income, claimant_or_partner_esa_income, esa_income_eligible and each member's is_on_income_related_esa) as not claiming with the @@ -178,7 +178,11 @@ def test_a_universal_credit_claim_ends_the_couples_income_related_esa(units): ), year paid_uc = v["universal_credit"] > 0 assert not np.any(paid_uc & (v["claimant_or_partner_esa_income"] > 0)), year - not_claiming = ~claims[benunit] + # A member who reports nothing has no award for the gate to end. + reports = np.array( + [m["esa_income_reported"] > 0 for unit in units for m in unit["members"]] + ) + not_claiming = ~claims[benunit] & reports assert np.array_equal( v["esa_income_report_closed"][not_claiming], v["person_legacy_benefits_closed"][not_claiming], From 7fe7360fa9008437670055067761db88d8bfc94e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:49:55 +0000 Subject: [PATCH 20/23] Keep Housing Benefit for specified or temporary accommodation beside Universal Credit Universal Credit leaves that rent out of its housing costs element (UC Regs 2013 Sch 1 paras 3(h), 3(i), 3A and 3B; SR 2016/216 Sch 1 paras 3(e), 3(ea), 4 and 4A). Housing Benefit for it survives a Universal Credit claim (SI 2014/1230 reg 8(3); SR 2016/226 reg 6(3)) and a migration deadline (reg 46(2); reg 47(2)), and a family there can make a new claim (reg 6A(2); reg 4A(2)). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6JY861guYKTfTZajLq --- changelog.d/2562.fixed.md | 2 +- .../esa_income/esa_income_abolition.yaml | 160 +++++++++++++++- .../housing_benefit_eligible.yaml | 119 ++++++++++++ ...ing_benefit_universal_credit_passport.yaml | 7 +- .../uc_housing_costs_element.yaml | 22 +++ .../work_allowance/uc_work_allowance.yaml | 56 ++++++ .../gov/dwp/benefit_cap_by_scheme.yaml | 4 +- .../test_benefit_cap_uc_and_hb_properties.py | 7 +- ..._benefit_accommodation_dates_properties.py | 179 ++++++++++++++++++ ...it_universal_credit_passport_properties.py | 8 +- .../gov/dwp/benefit_cap_welfare_benefits.py | 13 +- ...it_benefit_cap_exempt_specified_benefit.py | 9 +- .../housing_benefit_eligible.py | 45 +++-- ...in_specified_or_temporary_accommodation.py | 8 +- .../claims_uc_at_legacy_closure.py | 4 +- .../claims_universal_credit.py | 14 +- .../uc_housing_costs_element.py | 25 ++- 17 files changed, 625 insertions(+), 57 deletions(-) diff --git a/changelog.d/2562.fixed.md b/changelog.d/2562.fixed.md index 5450b09471..cbc0afbeeb 100644 --- a/changelog.d/2562.fixed.md +++ b/changelog.d/2562.fixed.md @@ -1 +1 @@ -Model income-related ESA payments up to the 2026 abolition dates and Universal Credit claims when a family's award starts to end. The model pays unsaved claims to 30 June 2026 in Great Britain and 30 September 2026 in Northern Ireland, using 86/365 and 178/365 of the 2026-27 year, and from 2027-28 moves each affected family off its legacy benefits. Users can preserve an award with the new appointee-saving input. A family in specified or temporary accommodation now keeps its Housing Benefit when DWP moves it off its legacy benefits, unless it claims Universal Credit. +Model income-related ESA payments up to the 2026 abolition dates and Universal Credit claims when a family's award starts to end. The model pays unsaved claims to 30 June 2026 in Great Britain and 30 September 2026 in Northern Ireland, using 86/365 and 178/365 of the 2026-27 year, and from 2027-28 moves each affected family off its legacy benefits. Users can preserve an award with the new appointee-saving input. A family in specified or temporary accommodation now keeps or can newly claim Housing Benefit for that rent beside a Universal Credit claim or DWP's move off legacy benefits, and Universal Credit leaves the rent out of its housing costs element. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml index 35b1e5c439..36d6a329d4 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml @@ -691,7 +691,7 @@ housing_benefit_eligible: false housing_benefit: 0 -- name: Great Britain 2026-27, a family in specified or temporary accommodation that claims Universal Credit gets no Housing Benefit +- name: Great Britain 2026-27, a family in specified or temporary accommodation that claims Universal Credit keeps its Housing Benefit period: 2026 absolute_error_margin: 0.01 input: @@ -712,14 +712,162 @@ tenure_type: RENT_FROM_COUNCIL rent: 5_200 output: - # The family claims on its award's appointed day. Under reg 8(3) this - # Housing Benefit award continues after the claim, but the model's - # Universal Credit pays the rent through its housing costs element, - # subject to the benefit cap, so the model ends the award to avoid - # paying the rent twice beside an uncapped award. + # The family claims on its award's appointed day. The claim doesn't + # terminate Housing Benefit for specified or temporary accommodation + # (SI 2014/1230 reg 8(3)). Universal Credit Regs 2013 Sch 1 paras 3(h) + # and (i) exclude this rent from rent payments, so the housing costs + # element is nil and Universal Credit pays the standard allowance alone: + # 12 x £424.90 = £5,098.80. Universal Credit passports the Housing + # Benefit means test, which pays the whole £5,200 rent. + claims_universal_credit: true + housing_benefit_eligible: true + housing_benefit: 5_200 + uc_housing_costs_element: 0 + universal_credit: 5_098.80 + +- name: Great Britain 2026-27, a London tenant in temporary accommodation the abolition moves to Universal Credit keeps Housing Benefit for £30,000 of rent + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + housing_benefit_reported: 30_000 + # The work-related activity group: no support component. + esa_includes_support_component: false + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + council_tax: 1_500 + output: + # The abolition reaches the unsaved award (SI 2025/1148 art. 3A) and the + # family claims Universal Credit, which ends the income-related ESA + # award. Housing Benefit for council-arranged homelessness accommodation + # survives the claim (SI 2014/1230 reg 8(3)), and Universal Credit + # leaves that rent out (UC Regs 2013 Sch 1 paras 3(i) and 3B). + # Universal Credit: the standard allowance alone, 12 x £424.90 = + # £5,098.80. The Universal Credit cap counts Housing Benefit as nil (UC + # Regs 2013 reg 80(2A)): £5,098.80 falls below London's £16,967 single + # cap, so no reduction. Housing Benefit: Universal Credit passports the + # means test, so it pays the whole £30,000 rent, and the Housing Benefit + # cap doesn't apply to a claimant receiving Universal Credit (HB Regs + # 2006 reg 75F(1)(g)). + # Household net income: £30,000 + £5,098.80 + £100 Household Energy + # Payment (band A to D in England, 2026-27) - £180 TV licence - £1,500 + # council tax = £33,518.80. + esa_income_abolition_reached: true + claims_universal_credit: true + esa_income: 0 + housing_benefit_eligible: true + housing_benefit: 30_000 + uc_housing_costs_element: 0 + uc_benefit_cap_reduction: 0 + housing_benefit_benefit_cap_reduction: 0 + universal_credit: 5_098.80 + household_net_income: 33_518.80 + +- name: Great Britain 2026-27, the same London tenant outside that accommodation gets capped Universal Credit and no Housing Benefit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + housing_benefit_reported: 30_000 + esa_includes_support_component: false + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + council_tax: 1_500 + output: + # The claim ends Housing Benefit after its run-on (SI 2014/1230 reg + # 8(2A)). Universal Credit before the cap: £5,098.80 standard allowance + # + £30,000 social rent = £35,098.80. The cap reduces it by the excess + # over London's £16,967 single cap: £35,098.80 - £16,967 = £18,131.80, + # leaving £16,967. Household net income: £16,967 + £100 - £180 - £1,500 + # = £15,387. claims_universal_credit: true housing_benefit_eligible: false housing_benefit: 0 + uc_housing_costs_element: 30_000 + uc_benefit_cap_reduction: 18_131.80 + universal_credit: 16_967 + household_net_income: 15_387 + +- name: Great Britain 2026-27, the benefit cap reduces Universal Credit and leaves temporary accommodation Housing Benefit whole + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 35 + esa_income_reported: 5_000 + housing_benefit_reported: 15_000 + esa_includes_support_component: false + child_1: + age: 10 + child_2: + age: 8 + child_3: + age: 6 + child_4: + age: 4 + benunits: + benunit: + members: [claimant, child_1, child_2, child_3, child_4] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant, child_1, child_2, child_3, child_4] + region: NORTH_WEST + tenure_type: RENT_FROM_COUNCIL + rent: 15_000 + output: + # The lone parent claims when the abolition reaches the award. Universal + # Credit before the cap: the £5,098.80 standard allowance, and child + # elements of 12 x (£351.88 for the eldest, born before 6 April 2017, + + # 3 x £303.94) = £15,164.40, with no housing costs element (UC Regs 2013 + # Sch 1 paras 3(i) and 3B): £20,263.20. Child Benefit: (£27.05 + 3 x + # £17.90) x 52 = £4,199.00. The Universal Credit cap counts Housing + # Benefit as nil (reg 80(2A)), so the welfare benefits come to £20,263.20 + # + £4,199.00 = £24,462.20, which exceeds the £22,020 cap outside London + # for a lone parent by £2,442.20. Universal Credit: £20,263.20 - + # £2,442.20 = £17,821.00. Housing Benefit pays the whole £15,000 rent, + # passported by Universal Credit, and its own cap doesn't apply (HB Regs + # 2006 reg 75F(1)(g)). benefit_cap_welfare_benefits counts Housing + # Benefit at its amount before the cap: £24,462.20 + £15,000 = + # £39,462.20. + claims_universal_credit: true + uc_housing_costs_element: 0 + universal_credit_pre_benefit_cap: 20_263.20 + child_benefit: 4_199 + benefit_cap_welfare_benefits: 39_462.20 + uc_benefit_cap_reduction: 2_442.20 + universal_credit: 17_821 + is_housing_benefit_benefit_cap_exempt_specified_benefit: true + housing_benefit_benefit_cap_reduction: 0 + housing_benefit: 15_000 - name: Great Britain 2027-28, a family the abolition moved claims Universal Credit period: 2027 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml index e86a89f6d1..24c1632f63 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml @@ -95,3 +95,122 @@ legacy_benefits_closed: true housing_benefit_eligible: false housing_benefit: 0 + +# SI 2014/1230 reg 6A(2): the bar on new Housing Benefit claims doesn't apply +# to a claim in respect of specified or temporary accommodation. UC Regs 2013 +# Sch 1 paras 3(h) and (i) exclude that rent from rent payments, so Universal +# Credit has no housing costs element for it. In 2026-27 the standard +# allowance for a single claimant aged 25 or over comes to 12 x £424.90 = +# £5,098.80. +- name: A family in temporary accommodation with no award makes a new Housing Benefit claim beside Universal Credit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + claims_all_entitled_benefits: true + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 10_000 + output: + # Universal Credit passports the Housing Benefit means test, so Housing + # Benefit pays the whole £10,000 rent; reg 75F(1)(g) lifts its cap. + claims_universal_credit: true + housing_benefit_eligible: true + housing_benefit: 10_000 + uc_housing_costs_element: 0 + universal_credit: 5_098.80 + +- name: The same family that doesn't take Housing Benefit up gets no help with the rent + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + claims_all_entitled_benefits: false + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 10_000 + output: + # The family can claim (housing_benefit_eligible) but doesn't + # (would_claim_housing_benefit), and Universal Credit still leaves the + # rent out. + housing_benefit_eligible: true + would_claim_housing_benefit: false + housing_benefit: 0 + uc_housing_costs_element: 0 + universal_credit: 5_098.80 + +# Northern Ireland: SR 2016/226 reg 6(3) keeps Housing Benefit for specified or +# temporary accommodation after a Universal Credit claim, and SR 2016/216 Sch 1 +# paras 3(e) and (ea) exclude that rent from rent payments. +- name: Northern Ireland, a Housing Benefit award in temporary accommodation continues after a Universal Credit claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: NORTHERN_IRELAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + claims_universal_credit: true + housing_benefit_eligible: true + housing_benefit: 6_000 + uc_housing_costs_element: 0 + universal_credit: 5_098.80 + +- name: Northern Ireland, the same claim outside that accommodation ends Housing Benefit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: NORTHERN_IRELAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + # SR 2016/226 reg 6(2A) ends the award; Universal Credit pays the social + # rent: £5,098.80 + £6,000 = £11,098.80, under the £14,753 single cap + # outside London. + housing_benefit_eligible: false + housing_benefit: 0 + uc_housing_costs_element: 6_000 + universal_credit: 11_098.80 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_universal_credit_passport.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_universal_credit_passport.yaml index 7218062a7b..d592a4d037 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_universal_credit_passport.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_universal_credit_passport.yaml @@ -11,9 +11,10 @@ # In law working-age Housing Benefit is paid alongside Universal Credit for # specified or temporary accommodation (UC (Transitional Provisions) Regs 2014 # reg 5(2)(a)), whose rent is excluded from the Universal Credit housing costs -# element (UC Regs 2013 Sch 1 para 3(h)-(i)). The model has no such route yet -# (PolicyEngine/policyengine-uk#1910 adds one), so these cases supply -# housing_benefit_eligible and a nil uc_housing_costs_element as inputs. +# element (UC Regs 2013 Sch 1 para 3(h)-(i)). The model takes that route for a +# family with in_specified_or_temporary_accommodation; these cases supply +# housing_benefit_eligible and a nil uc_housing_costs_element as inputs instead, +# so that they test the passport alone. # # Expected values are hand-computed from the regulations: council tenancies # (no LHA), no non-dependants, 2025-26 amounts. Universal Credit: standard diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/uc_housing_costs_element.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/uc_housing_costs_element.yaml index f9f9582ad9..12f0967efe 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/uc_housing_costs_element.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/uc_housing_costs_element.yaml @@ -122,3 +122,25 @@ members: person output: uc_housing_costs_element: 0 + +- name: Specified or temporary accommodation brings no element + period: 2026 + absolute_error_margin: 0 + input: + people: + person: {} + benunits: + benunit: + benunit_rent: 1_000 + uc_non_dep_deductions: 0 + uc_LHA_cap: 5_000 + in_specified_or_temporary_accommodation: true + members: person + households: + household: + tenure_type: RENT_PRIVATELY + members: person + output: + # UC Regs 2013 Sch 1 paras 3(h), 3(i), 3A and 3B; NI: SR 2016/216 Sch 1 + # paras 3(e), 3(ea), 4 and 4A. + uc_housing_costs_element: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/uc_work_allowance.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/uc_work_allowance.yaml index 73c3a77058..6aed8b21c3 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/uc_work_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/uc_work_allowance.yaml @@ -29,3 +29,59 @@ uc_limited_capability_for_WRA: true output: uc_work_allowance: 5_124 # 427 * 12 + +# UC Regs 2013 Sch 1 paras 3(h) and (i) leave rent for specified or temporary +# accommodation out of rent payments, so the award has no housing costs element +# and the higher work allowance applies (reg 22(2)(a)). +- name: A claimant with earnings and limited capability in temporary accommodation gets the higher work allowance, 2026-27 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + employment_income: 12_000 + uc_limited_capability_for_WRA: true + housing_benefit_reported: 8_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: NORTH_WEST + tenure_type: RENT_FROM_COUNCIL + rent: 8_000 + output: + uc_housing_costs_element: 0 + uc_work_allowance: 8_520 # 710 * 12 + # Housing Benefit pays the rent beside Universal Credit (SI 2014/1230 + # reg 8(3)), passported by Universal Credit. + housing_benefit: 8_000 + +- name: The same claimant outside that accommodation gets the lower work allowance, 2026-27 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + employment_income: 12_000 + uc_limited_capability_for_WRA: true + housing_benefit_reported: 8_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: NORTH_WEST + tenure_type: RENT_FROM_COUNCIL + rent: 8_000 + output: + uc_housing_costs_element: 8_000 + uc_work_allowance: 5_124 # 427 * 12 + housing_benefit: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml index 53e746bdf2..ddfdec6d7d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml @@ -377,8 +377,8 @@ housing_benefit: 20 # A family paid Housing Benefit alongside Universal Credit (specified or -# temporary accommodation). The model's Housing Benefit eligibility rules this -# out for a Universal Credit claimant, so both awards are supplied. +# temporary accommodation). The case supplies both awards so that it tests the +# cap alone. - name: UC reg 80(2A) and HB reg 75F(1)(g) - a family receiving both is capped only through UC, with its Housing Benefit counted as nil period: 2025 input: diff --git a/policyengine_uk/tests/test_benefit_cap_uc_and_hb_properties.py b/policyengine_uk/tests/test_benefit_cap_uc_and_hb_properties.py index 41d110eb87..f99202d07b 100644 --- a/policyengine_uk/tests/test_benefit_cap_uc_and_hb_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_uc_and_hb_properties.py @@ -7,9 +7,10 @@ temporary accommodation) is therefore capped only through Universal Credit, on its other welfare benefits. -The model's Housing Benefit eligibility rules out a family that claims -Universal Credit (housing_benefit_eligible), so both awards are supplied here -as inputs (universal_credit_pre_benefit_cap, housing_benefit_pre_benefit_cap). +The model pays both awards to a family in specified or temporary accommodation +(housing_benefit_eligible, uc_housing_costs_element). This file supplies both +awards as inputs (universal_credit_pre_benefit_cap, +housing_benefit_pre_benefit_cap) so that it can draw any pair of amounts. Invariants, for every family: diff --git a/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py b/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py index 283823a670..5f7e1dbdc8 100644 --- a/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py @@ -18,6 +18,25 @@ D5. Day count: 2026-27 has 182 days before 5 October and 183 from that date, totalling 365; inspect production segments, not just a calendar. +Routing the rent of specified or temporary accommodation, for generated +families with in_specified_or_temporary_accommodation (one or two adults, up +to four children, any tenure, rent, capital, earnings, region and reported +award, with or without a Universal Credit claim or a closed legacy benefit), +in 2025-26 to 2027-28: + +R1. Universal Credit leaves the rent out: uc_housing_costs_element is nil + (UC Regs 2013 Sch 1 paras 3(h), 3(i), 3A and 3B; NI: SR 2016/216 Sch 1 + paras 3(e), 3(ea), 4 and 4A). +R2. Housing Benefit eligibility doesn't depend on whether the family claims + Universal Credit or DWP closed its legacy benefits (SI 2014/1230 regs + 6A(2), 8(3) and 46(2); NI: SR 2016/226 regs 4A(2), 6(3) and 47(2)): it + equals the tenure and capital tests alone. +R3. With both awards, the Universal Credit cap reads Housing Benefit as nil + (UC Regs 2013 reg 80(2A)): the reduction equals that of the same family + with its Housing Benefit set to nil. The Housing Benefit cap makes no + reduction (HB Regs 2006 reg 75F(1)(g)), so Housing Benefit equals its + amount before the cap. + Fixture-scale populations hold annual incomes, rent and circumstances constant. The direct oracle implements the earnings cap, residual childcare limit, additional earnings test, income floor, threshold and award floor @@ -334,3 +353,163 @@ def test_d5_day_count(units): atol=TOLERANCE, rtol=0, ) + + +ROUTE_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +ROUTE_YEARS = (2025, 2026, 2027) +ROUTE_REGIONS = ["LONDON", "NORTH_WEST", "SCOTLAND", "WALES", "NORTHERN_IRELAND"] +ROUTE_TENURES = [ + "RENT_FROM_COUNCIL", + "RENT_FROM_HA", + "RENT_PRIVATELY", + "OWNED_OUTRIGHT", +] + + +@st.composite +def accommodation_families(draw): + # The draws lean towards renting families on low incomes with children, + # so that most receive both awards and some reach the benefit cap. + return dict( + adults=draw(st.integers(1, 2)), + children=draw(st.sampled_from([0, 1, 3, 4, 4, 5])), + age=draw(st.sampled_from([22, 30, 30, 45, 63, 70])), + region=draw(st.sampled_from(ROUTE_REGIONS)), + tenure=draw(st.sampled_from(ROUTE_TENURES + ROUTE_TENURES[:2] * 2)), + rent=draw(st.sampled_from([0, 4_000, 12_000, 12_000, 30_000])), + capital=draw(st.sampled_from([0, 0, 0, 7_000, 15_999, 16_001, 30_000])), + earnings=draw(st.sampled_from([0, 0, 6_000, 20_000])), + hb_reported=draw(st.sampled_from([0, 5_000])), + esa_reported=draw(st.sampled_from([0, 5_000])), + wtc_reported=draw(st.sampled_from([0, 2_000])), + would_claim_uc=draw(st.booleans()), + claim_at_closure=draw(st.booleans()), + claims_all=draw(st.booleans()), + ) + + +ROUTE_POPULATIONS = st.lists(accommodation_families(), min_size=1, max_size=12) + + +def accommodation_simulation(units, year, benunit_overrides=None): + """One household per family, each in specified or temporary accommodation. + + benunit_overrides maps a benefit unit variable to a value every family + receives as an input, in place of the formula. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + members = [] + for j in range(unit["adults"] + unit["children"]): + name = f"r{i}_{j}" + adult = j < unit["adults"] + people[name] = { + "age": {year: unit["age"] if adult else 6 + j}, + "employment_income": {year: unit["earnings"] if j == 0 else 0}, + "housing_benefit_reported": { + year: unit["hb_reported"] if j == 0 else 0 + }, + "esa_income_reported": {year: unit["esa_reported"] if j == 0 else 0}, + "working_tax_credit_reported": { + year: unit["wtc_reported"] if j == 0 else 0 + }, + } + members.append(name) + benunit = { + "members": members, + "in_specified_or_temporary_accommodation": {year: True}, + "would_claim_uc": {year: unit["would_claim_uc"]}, + "would_claim_uc_at_legacy_closure": {year: unit["claim_at_closure"]}, + "claims_all_entitled_benefits": {year: unit["claims_all"]}, + } + for variable, value in (benunit_overrides or {}).items(): + benunit[variable] = {year: value} + benunits[f"b{i}"] = benunit + households[f"h{i}"] = { + "members": list(members), + "region": {year: unit["region"]}, + "tenure_type": {year: unit["tenure"]}, + "rent": {year: unit["rent"]}, + "savings": {year: unit["capital"]}, + } + return Simulation( + tax_benefit_system=system, + situation=dict(people=people, benunits=benunits, households=households), + ) + + +@ROUTE_SETTINGS +@given(units=ROUTE_POPULATIONS, year=st.sampled_from(ROUTE_YEARS)) +def test_r1_universal_credit_leaves_the_rent_out(units, year): + sim = accommodation_simulation(units, year) + np.testing.assert_array_equal( + sim.calculate("uc_housing_costs_element", year), np.zeros(len(units)) + ) + + +@ROUTE_SETTINGS +@given(units=ROUTE_POPULATIONS, year=st.sampled_from(ROUTE_YEARS)) +def test_r2_eligibility_ignores_the_claim_and_the_closure(units, year): + natural = accommodation_simulation(units, year) + tenure_and_capital = natural.calculate("housing_benefit_eligible", year) + # The tenure and capital tests, read from the same simulation. + social = natural.populations["benunit"].any( + natural.calculate("in_social_housing", year) + ) + lha = natural.calculate("LHA_eligible", year) + capital = natural.calculate("housing_benefit_assessable_capital", year) + pension_regulations = natural.calculate( + "housing_benefit_pension_age_regulations_apply", year + ) + hb_capital = system.parameters(year).gov.dwp.housing_benefit.means_test.capital + limit = np.where( + pension_regulations, + hb_capital.pension_age.limit, + hb_capital.working_age.limit, + ) + np.testing.assert_array_equal( + tenure_and_capital, (social | lha) & (capital <= limit) + ) + for claims in (False, True): + for closed in (False, True): + forced = accommodation_simulation( + units, + year, + { + "claims_universal_credit": claims, + "legacy_benefits_closed": closed, + }, + ) + np.testing.assert_array_equal( + forced.calculate("housing_benefit_eligible", year), + tenure_and_capital, + ) + + +@ROUTE_SETTINGS +@given(units=ROUTE_POPULATIONS, year=st.sampled_from(ROUTE_YEARS)) +def test_r3_with_both_awards_only_the_universal_credit_cap_applies(units, year): + sim = accommodation_simulation(units, year) + uc = sim.calculate("universal_credit_pre_benefit_cap", year) + hb = sim.calculate("housing_benefit_pre_benefit_cap", year) + both = (uc > 0) & (hb > 0) + without_hb = accommodation_simulation( + units, year, {"housing_benefit_pre_benefit_cap": 0.0} + ) + np.testing.assert_allclose( + sim.calculate("uc_benefit_cap_reduction", year)[both], + without_hb.calculate("uc_benefit_cap_reduction", year)[both], + atol=TOLERANCE, + rtol=0, + ) + np.testing.assert_array_equal( + sim.calculate("housing_benefit_benefit_cap_reduction", year)[both], 0 + ) + np.testing.assert_allclose( + sim.calculate("housing_benefit", year)[both], hb[both], atol=TOLERANCE, rtol=0 + ) diff --git a/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py b/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py index caab1a85b0..b6c9207caf 100644 --- a/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py @@ -11,9 +11,11 @@ rent, capped at the LHA rate for LHA tenants, less non-dependant deductions (SSCBA 1992 s.130(3)(a); reg 70). -The model has no route yet to Housing Benefit alongside Universal Credit -(specified or temporary accommodation, UC (Transitional Provisions) Regs 2014 -reg 5(2)(a)), so each claimant benefit unit supplies housing_benefit_eligible. +The model pays Housing Benefit alongside Universal Credit for specified or +temporary accommodation (UC (Transitional Provisions) Regs 2014 reg 5(2)(a)) +through in_specified_or_temporary_accommodation. Each claimant benefit unit +here supplies housing_benefit_eligible instead, so the passport is tested +without the Universal Credit housing costs element changing. Invariants, for any generated population of renting families (some with a non-dependant in the household), each on Universal Credit (a supplied amount diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py b/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py index dc0af1081b..c0f188cf7c 100644 --- a/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py @@ -19,11 +19,14 @@ # Benefit as nil (UC Regs 2013 reg. 80(2A)): uc_benefit_cap_reduction takes it # out. Universal Credit before the cap is in the total too; a family receiving # it is outside the Housing Benefit cap (HB Regs 2006 reg. 75F(1)(g)). With -# those two adjustments one total serves both schemes. Reg. 75C(2)(a) counts -# Housing Benefit for specified accommodation as nil; the model's input -# in_specified_or_temporary_accommodation does not separate specified from -# temporary accommodation, so that Housing Benefit is counted at its amount -# before the cap. +# those two adjustments one total serves both schemes. A family in specified +# or temporary accommodation can receive both awards (housing_benefit_eligible), +# and the Universal Credit cap then counts its Housing Benefit as nil. Where the +# Housing Benefit cap applies, to a family without Universal Credit, reg. +# 75C(2)(a) counts Housing Benefit for specified accommodation as nil; the +# model's input in_specified_or_temporary_accommodation does not separate +# specified from temporary accommodation, so the formula counts that Housing +# Benefit at its amount before the cap. CLAIMANT_OR_PARTNER_BENEFITS = [ "jsa_contrib", "esa_contrib", diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py index 2481b490be..4f629c776a 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py @@ -136,10 +136,11 @@ def received_by(benefits, members): # Benefit before the cap; it is then capped through Universal Credit, # which counts its Housing Benefit as nil (UC Regs 2013 reg 80(2A)). # A family on Universal Credit alone keeps the exceptions above, so - # the two schemes' exceptions can be compared for it. Housing Benefit - # eligibility (housing_benefit_eligible) rules out a family that - # claims Universal Credit, so this applies only where both awards are - # supplied. + # the two schemes' exceptions can be compared for it. A family + # receives both awards in specified or temporary accommodation, where + # Housing Benefit continues beside a Universal Credit claim or opens + # on a new claim (housing_benefit_eligible), or where a user supplies + # both amounts. receiving_universal_credit = ( benunit("universal_credit_pre_benefit_cap", period) > 0 ) & (benunit("housing_benefit_pre_benefit_cap", period) > 0) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py index 0f0333a689..5ee143a610 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py @@ -20,17 +20,18 @@ class housing_benefit_eligible(Variable): "other family keeps Housing Benefit only while it continues an " "existing award (reported Housing Benefit) and does not claim " "Universal Credit. When DWP moves the family off a closed legacy " - "benefit (legacy_benefits_closed), the move also ends that award, " - "unless the family lives in specified or temporary accommodation " - "(in_specified_or_temporary_accommodation): such an award doesn't " - "terminate when the migration notice's deadline passes without a " - "claim, whichever legacy benefit closed (SI 2014/1230 reg 46(2); NI: " - "SR 2016/226 reg 47(2)). The model leaves out new claims for that " - "accommodation (reg " - "6A(2)) and reg 8(3), under which such an award continues after a " - "Universal Credit claim: the model's Universal Credit pays a housing costs " - "element for these homes, subject to the benefit cap, so Housing " - "Benefit beside an uncapped award would pay the rent twice. The " + "benefit (legacy_benefits_closed), the move also ends that award. " + "A family in specified or temporary accommodation " + "(in_specified_or_temporary_accommodation) can receive Housing " + "Benefit for it whatever its age, its Universal Credit claim or " + "DWP's move: it can make a new claim (SI 2014/1230 reg 6A(2); NI: " + "SR 2016/226 reg 4A(2)), and an existing award doesn't terminate on a " + "Universal Credit claim (reg 8(3); NI reg 6(3)) or when the " + "migration notice's deadline passes without one, whichever legacy " + "benefit closed (reg 46(2); NI reg 47(2)). Universal Credit then " + "leaves that rent out (uc_housing_costs_element), so the two awards " + "don't pay it twice. The tenure and capital tests still apply, and " + "would_claim_housing_benefit decides whether the family claims. The " "Secretary of State appointed 1 July 2026 for every " "other working-age award to end without a claim (SI 2025/1148 art. " "7), and the Department for Communities appointed 1 October 2026 in " @@ -48,6 +49,7 @@ class housing_benefit_eligible(Variable): "https://www.legislation.gov.uk/uksi/2019/37/article/4", "https://www.legislation.gov.uk/ukpga/2012/5/section/4", "https://www.legislation.gov.uk/nisr/2016/226/regulation/4A", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", "https://www.legislation.gov.uk/nisr/2016/226/regulation/47", "https://www.legislation.gov.uk/uksi/2025/1148/article/7", "https://www.legislation.gov.uk/nisr/2025/176/article/7", @@ -96,16 +98,19 @@ def formula(benunit, period, parameters): claiming_uc = benunit("claims_universal_credit", period) # Once another of the family's legacy benefits has closed, DWP's # migration notice ends this award even if the family does not claim - # Universal Credit (reg 46(1)(a)). An award for specified or - # temporary accommodation doesn't terminate there, whichever legacy - # benefit closed (reg 46(2); NI: SR 2016/226 reg 47(2)). A claim - # still ends that award here. Under reg 8(3) it continues, but the - # model's Universal Credit pays a housing costs element for these - # homes, subject to the benefit cap, so keeping it beside an uncapped - # award would pay the rent twice. + # Universal Credit (reg 46(1)(a)). migrated = benunit("legacy_benefits_closed", period) + continuing_award = already_claiming & ~claiming_uc & ~migrated + # Specified or temporary accommodation: the bar on new claims doesn't + # apply (reg 6A(2); NI: SR 2016/226 reg 4A(2)), and an existing award + # terminates neither on a Universal Credit claim (reg 8(3); NI reg + # 6(3)) nor when the deadline passes without one (reg 46(2); NI reg + # 47(2)). The route reads the supplied status, not an award, so it + # adds no read of Universal Credit or Housing Benefit amounts. + # Universal Credit excludes this rent from its housing costs element + # (UC Regs 2013 Sch 1 paras 3(h), 3(i), 3A and 3B), so only Housing + # Benefit pays it. accommodation = benunit("in_specified_or_temporary_accommodation", period) - continuing_award = already_claiming & ~claiming_uc & (~migrated | accommodation) social = benunit.any(person("in_social_housing", period)) lha_eligible = benunit("LHA_eligible", period) pension_age_regulations = benunit( @@ -119,7 +124,7 @@ def formula(benunit, period, parameters): hb_capital.working_age.limit, ) return ( - (pension_age | continuing_award) + (pension_age | continuing_award | accommodation) & (social | lha_eligible) & (capital <= limit) ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py b/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py index 263e66ec9d..651f08f40e 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py @@ -17,7 +17,13 @@ class in_specified_or_temporary_accommodation(Variable): "from tenure or a generic supported-housing label. The working-age " "Housing Benefit earnings disregard for specified or temporary " "accommodation (SI 2006/213 Sch 4 para 18; NI SR 2006/405 Sch 5 para " - "18), which refers to the same paragraphs, reads it. " + "18), which refers to the same paragraphs, reads it. Housing Benefit " + "eligibility reads it for new claims and for awards that continue " + "after a Universal Credit claim or DWP's move off legacy benefits " + "(SI 2014/1230 regs 6A(2), 8(3) and 46(2); NI: SR 2016/226 regs " + "4A(2), 6(3) and 47(2)), and the Universal Credit housing costs " + "element is nil for it (UC Regs 2013 Sch 1 paras 3(h) and 3(i); NI: " + "SR 2016/216 Sch 1 paras 3(e) and 3(ea)). " "It defaults to False when absent from household or dataset inputs." ) definition_period = YEAR diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py index 0a73a527ca..55f2d96385 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py @@ -12,7 +12,9 @@ class claims_uc_at_legacy_closure(Variable): "partner's award (esa_income_abolition_reached, from 2026-27), and it " "would claim then (would_claim_uc_at_legacy_closure). The claim ends " "the income-related ESA award (esa_income_report_closed) and the " - "Housing Benefit award (housing_benefit_eligible). A family that does " + "Housing Benefit award (housing_benefit_eligible), except Housing " + "Benefit for specified or temporary accommodation (SI 2014/1230 reg " + "8(3)). A family that does " "not claim keeps its income-related ESA for the part of 2026-27 " "before the abolition reached it, and its Housing Benefit until " "legacy_benefits_closed moves it in 2027-28, or beyond that in " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py index 8d3360979e..6e40134ddf 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/claims_universal_credit.py @@ -14,13 +14,13 @@ class claims_universal_credit(Variable): "Benefit continuing award and Child Tax Credit, and the claimant's " "and partner's income-related ESA (esa_income_report_closed). Once " "DWP moves a family off legacy benefits (legacy_benefits_closed) all " - "its legacy awards end, except Housing Benefit for specified or " - "temporary accommodation while the family does not claim " - "(housing_benefit_eligible). For other claimants the model doesn't " - "represent the overlap of Universal Credit with Working Tax Credit, " - "Income Support and income-based JSA, or the Housing Benefit for " - "specified or temporary accommodation that continues beside a claim " - "(SI 2014/1230 reg 8(3))." + "its legacy awards end. Housing Benefit for specified or temporary " + "accommodation survives both a claim and the move (SI 2014/1230 regs " + "8(3) and 46(2); housing_benefit_eligible), and Universal Credit " + "then leaves that rent out (uc_housing_costs_element). For other " + "claimants the model doesn't represent the overlap of Universal " + "Credit with Working Tax Credit, Income Support and income-based " + "JSA." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/uc_housing_costs_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/uc_housing_costs_element.py index 62f7c4d102..011e4399a0 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/uc_housing_costs_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/uc_housing_costs_element.py @@ -5,8 +5,26 @@ class uc_housing_costs_element(Variable): value_type = float entity = BenUnit label = "Universal Credit housing costs element" + documentation = ( + "The Universal Credit housing costs element for a renter: eligible " + "rent, limited for a private renter by the Local Housing Allowance " + "(uc_LHA_cap), less non-dependant deductions. The Secretary of State " + "excludes payments for specified or temporary accommodation from " + "rent payments (UC Regs 2013 Sch 1 paras 3(h), 3(i), 3A and 3B; NI: " + "SR 2016/216 Sch 1 paras 3(e), 3(ea), 4 and 4A), so the element is " + "nil for a family with in_specified_or_temporary_accommodation; " + "Housing Benefit pays that rent instead (housing_benefit_eligible)." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/1/paragraph/3", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/1/paragraph/3A", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/1/paragraph/3B", + "https://www.legislation.gov.uk/nisr/2016/216/schedule/1/paragraph/3", + "https://www.legislation.gov.uk/nisr/2016/216/schedule/1/paragraph/4", + "https://www.legislation.gov.uk/nisr/2016/216/schedule/1/paragraph/4A", + ) def formula(benunit, period, parameters): # The family's own tenure: a boarder or lodger is a private renter @@ -28,4 +46,9 @@ def formula(benunit, period, parameters): default=0, ) non_dependent_deductions = benunit("uc_non_dep_deductions", period) - return max_(max_housing_costs - non_dependent_deductions, 0) + # Payments for specified or temporary accommodation aren't rent + # payments (Sch 1 para 3(h) and (i); NI: SR 2016/216 Sch 1 para 3(e) + # and (ea)), so they bring no housing costs element. + excluded = benunit("in_specified_or_temporary_accommodation", period) + element = max_(max_housing_costs - non_dependent_deductions, 0) + return where(excluded, 0, element) From 84935ccefc963b09dd78ec8f0a6fcc4db0e20093 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:50:23 +0000 Subject: [PATCH 21/23] Give hand-entered families in specified or temporary accommodation Housing Benefit take-up A hand-entered family with in_specified_or_temporary_accommodation now claims Housing Benefit whatever else it reports, since Universal Credit leaves that rent out of its housing costs element. Data-built simulations keep their reporters-only rule. Adds YAML cases for take-up, the tenure and capital tests on the route, properties R5 and R6, a data-built test, and names an actor in the branch's comments that made a regulation act. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KjKZVLNEzacp8jh34 --- changelog.d/2562.fixed.md | 2 +- .../gov/dwp/ESA/income/awards_payable.yaml | 4 +- .../esa_income/esa_income_abolition.yaml | 84 ++++++--- .../family/esa_income/esa_income_capital.yaml | 12 +- .../esa_income_remunerative_work.yaml | 4 +- .../housing_benefit_eligible.yaml | 162 ++++++++++++++++-- .../would_claim_housing_benefit.yaml | 21 +++ .../council_tax_reduction_passports.yaml | 24 +-- ...using_benefit_pension_age_regulations.yaml | 8 +- .../pension_credit_earnings_disregard.yaml | 8 +- .../work_allowance/uc_work_allowance.yaml | 6 +- .../test_esa_income_abolition_properties.py | 2 +- ...ncome_universal_credit_claim_properties.py | 2 +- .../test_esa_permitted_work_nlw_reforms.py | 4 +- ..._benefit_accommodation_dates_properties.py | 51 +++++- ...enefit_accommodation_take_up_data_built.py | 115 +++++++++++++ ...it_universal_credit_passport_properties.py | 4 +- .../gov/dwp/benefit_cap_welfare_benefits.py | 8 +- .../gov/dwp/esa_income_appointee_saving.py | 23 +-- .../gov/dwp/esa_income_payable_share.py | 19 +- ...in_specified_or_temporary_accommodation.py | 4 +- .../would_claim_housing_benefit.py | 22 ++- 22 files changed, 478 insertions(+), 111 deletions(-) create mode 100644 policyengine_uk/tests/test_housing_benefit_accommodation_take_up_data_built.py diff --git a/changelog.d/2562.fixed.md b/changelog.d/2562.fixed.md index cbc0afbeeb..6c74821041 100644 --- a/changelog.d/2562.fixed.md +++ b/changelog.d/2562.fixed.md @@ -1 +1 @@ -Model income-related ESA payments up to the 2026 abolition dates and Universal Credit claims when a family's award starts to end. The model pays unsaved claims to 30 June 2026 in Great Britain and 30 September 2026 in Northern Ireland, using 86/365 and 178/365 of the 2026-27 year, and from 2027-28 moves each affected family off its legacy benefits. Users can preserve an award with the new appointee-saving input. A family in specified or temporary accommodation now keeps or can newly claim Housing Benefit for that rent beside a Universal Credit claim or DWP's move off legacy benefits, and Universal Credit leaves the rent out of its housing costs element. +Model income-related ESA payments up to the 2026 abolition dates and Universal Credit claims when a family's award starts to end. The model pays unsaved claims to 30 June 2026 in Great Britain and 30 September 2026 in Northern Ireland, using 86/365 and 178/365 of the 2026-27 year, and from 2027-28 moves each affected family off its legacy benefits. Users can preserve an award with the new appointee-saving input. A family in specified or temporary accommodation now keeps or can newly claim Housing Benefit for that rent beside a Universal Credit claim or DWP's move off legacy benefits, and Universal Credit leaves the rent out of its housing costs element; a hand-entered family with that status takes Housing Benefit up. diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml index f8fe70b6c6..2d935abcfa 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml @@ -15,7 +15,7 @@ description: >- day-weighted average from 6 April to 5 April: 2026-27 carries 86 of its 365 days in Great Britain and 178 in Northern Ireland. great_britain: - description: Whether old style income-related Employment and Support Allowance awards are still payable in Great Britain. SI 2025/1148 art. 3A(1) appoints 1 July 2026 for section 33(1)(b) of the Welfare Reform Act 2012 to come into force for every award it has not yet reached, except an award due to end at the end of a two-week run-on, which ends on that schedule. + description: Whether old style income-related Employment and Support Allowance awards are still payable in Great Britain. The Secretary of State appointed 1 July 2026 for section 33(1)(b) of the Welfare Reform Act 2012 to come into force for every award it has not yet reached (SI 2025/1148 art. 3A(1)), except an award due to end at the end of a two-week run-on, which ends on that schedule. values: 2008-10-27: 1 2026-07-01: 0 @@ -36,7 +36,7 @@ great_britain: - title: Welfare Reform Act 2012 s. 33(1)(b) href: https://www.legislation.gov.uk/ukpga/2012/5/section/33 northern_ireland: - description: Whether old style income-related Employment and Support Allowance awards are still payable in Northern Ireland. SR 2025/176 art. 3A(1) appoints 1 October 2026 for Article 39(1)(b) of the Welfare Reform (Northern Ireland) Order 2015 to come into operation for every award it has not yet reached, except an award due to end at the end of a two-week run-on, which ends on that schedule. + description: Whether old style income-related Employment and Support Allowance awards are still payable in Northern Ireland. The Department for Communities appointed 1 October 2026 for Article 39(1)(b) of the Welfare Reform (Northern Ireland) Order 2015 to come into operation for every award it has not yet reached (SR 2025/176 art. 3A(1)), except an award due to end at the end of a two-week run-on, which ends on that schedule. values: 2008-10-27: 1 2026-10-01: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml index 36d6a329d4..a37a5e22d2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml @@ -10,22 +10,22 @@ # June 2026 (25 + 31 + 30) and a Northern Ireland award for the 178 days from # 6 April to 30 September 2026 (25 + 31 + 30 + 31 + 31 + 30). So in 2026-27 # the model pays the annual award times 86/365 = 0.235616 or 178/365 = -# 0.487671, and it pays no unsaved income-related ESA from 2027-28. Art. -# 3A(1)(b) leaves an award already due to end at -# the end of a two-week run-on to that schedule; the annual model pays every -# award to the general date. +# 0.487671, and it pays no unsaved income-related ESA from 2027-28. Under +# art. 3A(1)(b), an award already due to end at the end of a two-week run-on +# ends on that schedule; the annual model pays every award to the general +# date. # # When the abolition reaches the claimant's or partner's award, the family # claims Universal Credit if would_claim_uc_at_legacy_closure (default true), # and the claim ends the award. From 2027-28 the award has ended and DWP # moves the family off its legacy benefits (legacy_benefits_closed). Cases # that test the 2026-27 share set would_claim_uc_at_legacy_closure to false. -# DWP's move ends the family's Housing Benefit, except for specified or -# temporary accommodation (SI 2014/1230 reg 46(2)); a claim ends it there -# too, because the model's Universal Credit pays the rent, subject to the -# benefit cap. +# DWP's move ends the family's Housing Benefit, and so does a Universal +# Credit claim, except for specified or temporary accommodation (SI +# 2014/1230 regs 8(3) and 46(2)), whose rent Universal Credit leaves out. # -# Art. 3A(2) saves an award for an appointee (esa_income_appointee_saving). +# Under art. 3A(2), an award saved for an appointee stays payable +# (esa_income_appointee_saving). # A saving for either member who reports a couple's award keeps the whole # award. Art. 3A(3): a Universal Credit claim still ends a saved award. A # contributory allowance continues as new style ESA (esa_contrib). @@ -272,8 +272,8 @@ members: [claimant] region: LONDON output: - # Art. 3A(2) treats the abolition as not in force for this award, so it - # neither prompts a claim nor moves the family. + # Under art. 3A(2) the abolition doesn't reach this award, so the + # family neither claims nor moves. esa_income_payable_share: 1 esa_income_abolition_reached: false legacy_benefits_closed: false @@ -324,8 +324,8 @@ members: [claimant] region: LONDON output: - # Art. 3A(3): the saving does not stop a claim for Universal Credit from - # bringing the abolition into force. + # Art. 3A(3): a claim for Universal Credit brings the abolition into + # force for a saved award too. esa_income_payable_share: 1 claims_universal_credit: true esa_income_report_closed: true @@ -714,9 +714,9 @@ output: # The family claims on its award's appointed day. The claim doesn't # terminate Housing Benefit for specified or temporary accommodation - # (SI 2014/1230 reg 8(3)). Universal Credit Regs 2013 Sch 1 paras 3(h) - # and (i) exclude this rent from rent payments, so the housing costs - # element is nil and Universal Credit pays the standard allowance alone: + # (SI 2014/1230 reg 8(3)). The Secretary of State excludes this rent + # from rent payments (UC Regs 2013 Sch 1 paras 3(h) and (i)), so the + # housing costs element is nil and Universal Credit pays the standard allowance alone: # 12 x £424.90 = £5,098.80. Universal Credit passports the Housing # Benefit means test, which pays the whole £5,200 rent. claims_universal_credit: true @@ -756,8 +756,8 @@ # survives the claim (SI 2014/1230 reg 8(3)), and Universal Credit # leaves that rent out (UC Regs 2013 Sch 1 paras 3(i) and 3B). # Universal Credit: the standard allowance alone, 12 x £424.90 = - # £5,098.80. The Universal Credit cap counts Housing Benefit as nil (UC - # Regs 2013 reg 80(2A)): £5,098.80 falls below London's £16,967 single + # £5,098.80. DWP counts Housing Benefit as nil in the Universal Credit + # cap (UC Regs 2013 reg 80(2A)): £5,098.80 falls below London's £16,967 single # cap, so no reduction. Housing Benefit: Universal Credit passports the # means test, so it pays the whole £30,000 rent, and the Housing Benefit # cap doesn't apply to a claimant receiving Universal Credit (HB Regs @@ -776,6 +776,44 @@ universal_credit: 5_098.80 household_net_income: 33_518.80 +- name: Great Britain 2026-27, the same London tenant without a Housing Benefit report takes Housing Benefit up for the rent + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + esa_includes_support_component: false + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + council_tax: 1_500 + output: + # The family reports income-related ESA, so claims_all_entitled_benefits + # is false, but the accommodation status makes it claim Housing Benefit + # (would_claim_housing_benefit): Universal Credit leaves the rent out. + # The figures match the tenant who reports Housing Benefit: Universal + # Credit £5,098.80, Housing Benefit £30,000, and household net income + # £30,000 + £5,098.80 + £100 - £180 - £1,500 = £33,518.80. + claims_all_entitled_benefits: false + would_claim_housing_benefit: true + claims_universal_credit: true + housing_benefit_eligible: true + housing_benefit: 30_000 + uc_housing_costs_element: 0 + universal_credit: 5_098.80 + household_net_income: 33_518.80 + - name: Great Britain 2026-27, the same London tenant outside that accommodation gets capped Universal Credit and no Housing Benefit period: 2026 absolute_error_margin: 0.01 @@ -801,7 +839,7 @@ output: # The claim ends Housing Benefit after its run-on (SI 2014/1230 reg # 8(2A)). Universal Credit before the cap: £5,098.80 standard allowance - # + £30,000 social rent = £35,098.80. The cap reduces it by the excess + # + £30,000 social rent = £35,098.80. DWP reduces it by the excess # over London's £16,967 single cap: £35,098.80 - £16,967 = £18,131.80, # leaving £16,967. Household net income: £16,967 + £100 - £180 - £1,500 # = £15,387. @@ -813,7 +851,7 @@ universal_credit: 16_967 household_net_income: 15_387 -- name: Great Britain 2026-27, the benefit cap reduces Universal Credit and leaves temporary accommodation Housing Benefit whole +- name: Great Britain 2026-27, DWP caps Universal Credit and leaves temporary accommodation Housing Benefit whole period: 2026 absolute_error_margin: 0.01 input: @@ -849,8 +887,8 @@ # elements of 12 x (£351.88 for the eldest, born before 6 April 2017, + # 3 x £303.94) = £15,164.40, with no housing costs element (UC Regs 2013 # Sch 1 paras 3(i) and 3B): £20,263.20. Child Benefit: (£27.05 + 3 x - # £17.90) x 52 = £4,199.00. The Universal Credit cap counts Housing - # Benefit as nil (reg 80(2A)), so the welfare benefits come to £20,263.20 + # £17.90) x 52 = £4,199.00. DWP counts Housing Benefit as nil in the + # Universal Credit cap (reg 80(2A)), so the welfare benefits come to £20,263.20 # + £4,199.00 = £24,462.20, which exceeds the £22,020 cap outside London # for a lone parent by £2,442.20. Universal Credit: £20,263.20 - # £2,442.20 = £17,821.00. Housing Benefit pays the whole £15,000 rent, @@ -1010,7 +1048,7 @@ claims_universal_credit: true esa_income: 0 -- name: The claimant's saving keeps the couple's whole award after the abolition +- name: The couple's whole award stays payable after the abolition under the claimant's saving period: 2027 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml index 6ad40f2aab..93cff38dce 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml @@ -30,8 +30,8 @@ claimant: age: 40 esa_income_reported: 1_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true benunits: benunit: @@ -53,8 +53,8 @@ claimant: age: 40 esa_income_reported: 1_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true benunits: benunit: @@ -77,8 +77,8 @@ claimant: age: 40 esa_income_reported: 3_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true benunits: benunit: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml index cde3e92417..9404559493 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml @@ -1305,8 +1305,8 @@ age: 30 is_claimant_or_partner: true esa_income_reported: 5_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true partner: age: 30 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml index 24c1632f63..5da82795b5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml @@ -97,11 +97,11 @@ housing_benefit: 0 # SI 2014/1230 reg 6A(2): the bar on new Housing Benefit claims doesn't apply -# to a claim in respect of specified or temporary accommodation. UC Regs 2013 -# Sch 1 paras 3(h) and (i) exclude that rent from rent payments, so Universal -# Credit has no housing costs element for it. In 2026-27 the standard -# allowance for a single claimant aged 25 or over comes to 12 x £424.90 = -# £5,098.80. +# to a claim in respect of specified or temporary accommodation. The +# Secretary of State excludes that rent from rent payments (UC Regs 2013 Sch 1 +# paras 3(h) and (i)), so Universal Credit has no housing costs element for +# it. In 2026-27 the standard allowance for a single claimant aged 25 or over +# comes to 12 x £424.90 = £5,098.80. - name: A family in temporary accommodation with no award makes a new Housing Benefit claim beside Universal Credit period: 2026 absolute_error_margin: 0.01 @@ -123,7 +123,9 @@ rent: 10_000 output: # Universal Credit passports the Housing Benefit means test, so Housing - # Benefit pays the whole £10,000 rent; reg 75F(1)(g) lifts its cap. + # Benefit pays the whole £10,000 rent, and the council applies no + # Housing Benefit cap to a claimant receiving Universal Credit (reg + # 75F(1)(g)). claims_universal_credit: true housing_benefit_eligible: true housing_benefit: 10_000 @@ -141,7 +143,7 @@ benunit: members: [claimant] would_claim_uc: true - claims_all_entitled_benefits: false + would_claim_housing_benefit: false in_specified_or_temporary_accommodation: true households: household: @@ -150,18 +152,19 @@ tenure_type: RENT_FROM_COUNCIL rent: 10_000 output: - # The family can claim (housing_benefit_eligible) but doesn't - # (would_claim_housing_benefit), and Universal Credit still leaves the - # rent out. + # The family can claim (housing_benefit_eligible), but the user says it + # doesn't (would_claim_housing_benefit), and Universal Credit still + # leaves the rent out. housing_benefit_eligible: true would_claim_housing_benefit: false housing_benefit: 0 uc_housing_costs_element: 0 universal_credit: 5_098.80 -# Northern Ireland: SR 2016/226 reg 6(3) keeps Housing Benefit for specified or -# temporary accommodation after a Universal Credit claim, and SR 2016/216 Sch 1 -# paras 3(e) and (ea) exclude that rent from rent payments. +# Northern Ireland: a Universal Credit claim doesn't end Housing Benefit for +# specified or temporary accommodation (SR 2016/226 reg 6(3)), and the +# Department for Communities excludes that rent from rent payments (SR +# 2016/216 Sch 1 paras 3(e) and (ea)). - name: Northern Ireland, a Housing Benefit award in temporary accommodation continues after a Universal Credit claim period: 2026 absolute_error_margin: 0.01 @@ -207,10 +210,139 @@ tenure_type: RENT_FROM_COUNCIL rent: 6_000 output: - # SR 2016/226 reg 6(2A) ends the award; Universal Credit pays the social - # rent: £5,098.80 + £6,000 = £11,098.80, under the £14,753 single cap + # The claim ends the award (SR 2016/226 reg 6(2A)); Universal Credit pays + # the social rent: £5,098.80 + £6,000 = £11,098.80, under the £14,753 single cap # outside London. housing_benefit_eligible: false housing_benefit: 0 uc_housing_costs_element: 6_000 universal_credit: 11_098.80 + +# A hand-entered family that reports Universal Credit but not Housing Benefit +# reports a benefit, so claims_all_entitled_benefits is false. The +# accommodation status still makes it claim Housing Benefit +# (would_claim_housing_benefit), since Universal Credit leaves the rent out. +# Universal Credit: the standard allowance alone, 12 x £424.90 = £5,098.80. +# Housing Benefit: Universal Credit passports the means test, so it pays the +# whole £10,000 rent, and the Housing Benefit cap doesn't apply to a claimant +# receiving Universal Credit (HB Regs 2006 reg 75F(1)(g)). Household net +# income: £10,000 + £5,098.80 + £100 Household Energy Payment - £180 TV +# licence - £1,500 council tax = £13,518.80. +- name: A family in temporary accommodation that reports only Universal Credit takes Housing Benefit up for the rent + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + universal_credit_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 10_000 + council_tax: 1_500 + output: + claims_all_entitled_benefits: false + would_claim_housing_benefit: true + claims_universal_credit: true + housing_benefit_eligible: true + housing_benefit: 10_000 + uc_housing_costs_element: 0 + universal_credit: 5_098.80 + household_net_income: 13_518.80 + +# The control without the status: Universal Credit pays the social rent, +# £5,098.80 + £10,000 = £15,098.80, under London's £16,967 single cap. +# Household net income: £15,098.80 + £100 - £180 - £1,500 = £13,518.80, the +# same as with the status. +- name: The same family outside that accommodation gets the rent through Universal Credit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + universal_credit_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + in_specified_or_temporary_accommodation: false + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 10_000 + council_tax: 1_500 + output: + would_claim_housing_benefit: false + housing_benefit_eligible: false + housing_benefit: 0 + uc_housing_costs_element: 10_000 + universal_credit: 15_098.80 + household_net_income: 13_518.80 + +# Capital above the limit: the status lifts the transitional bars on the +# claim, not the working-age capital limit the formula reads +# (gov.dwp.housing_benefit.means_test.capital), so £20,000 of savings leaves +# the family without Housing Benefit, and Universal Credit still leaves the +# rent out. +- name: A family in temporary accommodation with capital above the limit gets no Housing Benefit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + claims_all_entitled_benefits: true + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 10_000 + savings: 20_000 + output: + would_claim_housing_benefit: true + housing_benefit_eligible: false + housing_benefit: 0 + uc_housing_costs_element: 0 + +# Tenure: an owner occupier is neither a social nor a Local Housing Allowance +# renter, so the status doesn't open Housing Benefit. +- name: An owner occupier with the status gets no Housing Benefit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + claims_all_entitled_benefits: true + in_specified_or_temporary_accommodation: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: OWNED_OUTRIGHT + output: + would_claim_housing_benefit: true + housing_benefit_eligible: false + housing_benefit: 0 + uc_housing_costs_element: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/would_claim_housing_benefit.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/would_claim_housing_benefit.yaml index 084b4cd9ff..6696159e4c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/would_claim_housing_benefit.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/would_claim_housing_benefit.yaml @@ -27,3 +27,24 @@ housing_benefit_entitlement: 0 output: would_claim_housing_benefit: false + +- name: Ineligible with the accommodation status set false + period: 2025 + input: + claims_all_entitled_benefits: false + housing_benefit_reported: 0 + in_specified_or_temporary_accommodation: false + output: + would_claim_housing_benefit: false + +# Universal Credit leaves the rent of specified or temporary accommodation out +# of its housing costs element, so a hand-entered family with the status +# claims Housing Benefit whatever else it reports. +- name: The accommodation status claims without a report + period: 2025 + input: + claims_all_entitled_benefits: false + housing_benefit_reported: 0 + in_specified_or_temporary_accommodation: true + output: + would_claim_housing_benefit: true diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/council_tax_reduction_passports.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/council_tax_reduction_passports.yaml index f806f0d93d..abef0f69fc 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/council_tax_reduction_passports.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/council_tax_reduction_passports.yaml @@ -152,8 +152,8 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 4_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true benunits: applicant_unit: @@ -181,8 +181,8 @@ is_claimant_or_partner: true current_education: NOT_IN_EDUCATION esa_income_reported: 4_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true other: age: 25 @@ -215,8 +215,8 @@ is_claimant_or_partner: true current_education: NOT_IN_EDUCATION esa_income_reported: 4_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true partner: age: 29 @@ -254,8 +254,8 @@ is_claimant_or_partner: true current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true partner: age: 29 @@ -302,16 +302,16 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 200 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true other: age: 25 is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 3_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true benunits: applicant_unit: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_pension_age_regulations.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_pension_age_regulations.yaml index 81e2354de2..30429dd3a9 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_pension_age_regulations.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_pension_age_regulations.yaml @@ -19,8 +19,8 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true benunits: benunit: @@ -69,8 +69,8 @@ age: 50 is_claimant_or_partner: true esa_income_reported: 5_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true benunits: benunit: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/pension_credit_earnings_disregard.yaml index 0023db9681..379692d36d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/pension_credit_earnings_disregard.yaml @@ -24,8 +24,8 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true benunits: benunit: @@ -85,8 +85,8 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 - # SI 2025/1148 art. 3A(2) saves this award, so it stays payable - # for the whole year after the 1 July 2026 abolition. + # Under SI 2025/1148 art. 3A(2), this award stays payable for the + # whole year after the 1 July 2026 abolition. esa_income_appointee_saving: true benunits: benunit: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/uc_work_allowance.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/uc_work_allowance.yaml index 6aed8b21c3..ee32ed1435 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/uc_work_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/work_allowance/uc_work_allowance.yaml @@ -30,9 +30,9 @@ output: uc_work_allowance: 5_124 # 427 * 12 -# UC Regs 2013 Sch 1 paras 3(h) and (i) leave rent for specified or temporary -# accommodation out of rent payments, so the award has no housing costs element -# and the higher work allowance applies (reg 22(2)(a)). +# The Secretary of State excludes rent for specified or temporary accommodation +# from rent payments (UC Regs 2013 Sch 1 paras 3(h) and (i)), so the award has +# no housing costs element and the higher work allowance applies (reg 22(2)(a)). - name: A claimant with earnings and limited capability in temporary accommodation gets the higher work allowance, 2026-27 period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_esa_income_abolition_properties.py b/policyengine_uk/tests/test_esa_income_abolition_properties.py index 3bfbf173e3..118d34d13c 100644 --- a/policyengine_uk/tests/test_esa_income_abolition_properties.py +++ b/policyengine_uk/tests/test_esa_income_abolition_properties.py @@ -285,7 +285,7 @@ def couple_claim_saved(unit): def claim_saved(member, unit): - """Whether a saving keeps the claim this member belongs to.""" + """Whether the claim this member belongs to stays payable under a saving.""" if member["role"] in COUPLE: return couple_claim_saved(unit) return member["esa_income_reported"] > 0 and member["saved"] diff --git a/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py b/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py index 601d50019a..6ae49a0ce9 100644 --- a/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py +++ b/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py @@ -126,7 +126,7 @@ def situation(units, would_claim_uc=None, drop_couple_reports=False): "esa_income_reported": every_year(report), "employment_income": every_year(member["employment_income"]), "weekly_hours": every_year(member["weekly_hours"]), - # Art. 3A(2) keeps the award open after the abolition. + # Under art. 3A(2), the award stays open after the abolition. "esa_income_appointee_saving": every_year(True), } names.append(name) diff --git a/policyengine_uk/tests/test_esa_permitted_work_nlw_reforms.py b/policyengine_uk/tests/test_esa_permitted_work_nlw_reforms.py index 5b98074ec7..80ac91ce05 100644 --- a/policyengine_uk/tests/test_esa_permitted_work_nlw_reforms.py +++ b/policyengine_uk/tests/test_esa_permitted_work_nlw_reforms.py @@ -24,8 +24,8 @@ def _reported_claimants(): - # SI 2025/1148 art. 3A(2) saves both awards, so each stays payable for the - # whole of 2026-27 after the 1 July 2026 abolition of income-related ESA. + # Under SI 2025/1148 art. 3A(2), both awards stay payable for the whole + # of 2026-27 after the 1 July 2026 abolition of income-related ESA. return { "claimant": { "age": 50, diff --git a/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py b/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py index 5f7e1dbdc8..0d629a968a 100644 --- a/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py @@ -36,6 +36,16 @@ with its Housing Benefit set to nil. The Housing Benefit cap makes no reduction (HB Regs 2006 reg 75F(1)(g)), so Housing Benefit equals its amount before the cap. +R5. Take-up: in a hand-entered situation, a family with the status claims + Housing Benefit (would_claim_housing_benefit) whatever else it reports, + since Universal Credit leaves the rent out. With the status set false, + take-up equals claims_all_entitled_benefits or a Housing Benefit report. +R6. No lost help: a family with the status that passes the tenure and + capital tests (housing_benefit_eligible) never ends up with neither + Housing Benefit nor a housing costs element, except where the Housing + Benefit means test takes its entitlement to nil + (housing_benefit_entitlement). The Housing Benefit cap can't take it to + nil: it leaves the 50 pence weekly minimum. Fixture-scale populations hold annual incomes, rent and circumstances constant. The direct oracle implements the earnings cap, residual childcare @@ -396,8 +406,9 @@ def accommodation_families(draw): ROUTE_POPULATIONS = st.lists(accommodation_families(), min_size=1, max_size=12) -def accommodation_simulation(units, year, benunit_overrides=None): - """One household per family, each in specified or temporary accommodation. +def accommodation_simulation(units, year, benunit_overrides=None, status=True): + """One household per family, each in specified or temporary accommodation + unless status is False. benunit_overrides maps a benefit unit variable to a value every family receives as an input, in place of the formula. @@ -422,7 +433,7 @@ def accommodation_simulation(units, year, benunit_overrides=None): members.append(name) benunit = { "members": members, - "in_specified_or_temporary_accommodation": {year: True}, + "in_specified_or_temporary_accommodation": {year: status}, "would_claim_uc": {year: unit["would_claim_uc"]}, "would_claim_uc_at_legacy_closure": {year: unit["claim_at_closure"]}, "claims_all_entitled_benefits": {year: unit["claims_all"]}, @@ -513,3 +524,37 @@ def test_r3_with_both_awards_only_the_universal_credit_cap_applies(units, year): np.testing.assert_allclose( sim.calculate("housing_benefit", year)[both], hb[both], atol=TOLERANCE, rtol=0 ) + + +@ROUTE_SETTINGS +@given(units=ROUTE_POPULATIONS, year=st.sampled_from(ROUTE_YEARS)) +def test_r5_the_status_takes_housing_benefit_up(units, year): + with_status = accommodation_simulation(units, year) + np.testing.assert_array_equal( + with_status.calculate("would_claim_housing_benefit", year), + np.ones(len(units), dtype=bool), + ) + without = accommodation_simulation(units, year, status=False) + reported = ( + without.populations["benunit"].sum( + without.calculate("housing_benefit_reported", year) + ) + > 0 + ) + np.testing.assert_array_equal( + without.calculate("would_claim_housing_benefit", year), + without.calculate("claims_all_entitled_benefits", year) | reported, + ) + + +@ROUTE_SETTINGS +@given(units=ROUTE_POPULATIONS, year=st.sampled_from(ROUTE_YEARS)) +def test_r6_an_eligible_family_keeps_help_with_the_rent(units, year): + sim = accommodation_simulation(units, year) + eligible = sim.calculate("housing_benefit_eligible", year) + # The one exception: the means test takes Housing Benefit to nil. + means_tested = sim.calculate("housing_benefit_entitlement", year) > 0 + helped = (sim.calculate("housing_benefit", year) > 0) | ( + sim.calculate("uc_housing_costs_element", year) > 0 + ) + assert not np.any(eligible & means_tested & ~helped) diff --git a/policyengine_uk/tests/test_housing_benefit_accommodation_take_up_data_built.py b/policyengine_uk/tests/test_housing_benefit_accommodation_take_up_data_built.py new file mode 100644 index 0000000000..b60cc420b7 --- /dev/null +++ b/policyengine_uk/tests/test_housing_benefit_accommodation_take_up_data_built.py @@ -0,0 +1,115 @@ +"""Housing Benefit take-up with the accommodation status in data-built runs. + +A hand-entered family with in_specified_or_temporary_accommodation claims +Housing Benefit (would_claim_housing_benefit). A simulation built from data +keeps its reporters-only rule: the status doesn't make a family claim, so a +data-built family with the status and no Housing Benefit report gets no help +with the rent. No dataset sets the status, so this pins the data branch +without survey data. +""" + +import pandas as pd +import pytest + +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset +from policyengine_uk.utils.data_source import built_from_data + +YEAR = 2026 +RENT = 10_000 + + +def dataset(housing_benefit_reported): + return UKMultiYearDataset( + datasets=[ + UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [40], + "housing_benefit_reported": [housing_benefit_reported], + } + ), + benunit=pd.DataFrame( + { + "benunit_id": [1], + "in_specified_or_temporary_accommodation": [True], + "would_claim_uc": [True], + } + ), + household=pd.DataFrame( + { + "household_id": [1], + "region": ["LONDON"], + "tenure_type": ["RENT_FROM_COUNCIL"], + "rent": [RENT], + "council_tax": [1_500], + } + ), + fiscal_year=YEAR, + ) + ] + ) + + +SITUATION = { + "people": { + "claimant": { + "age": {YEAR: 40}, + "housing_benefit_reported": {YEAR: 0}, + } + }, + "benunits": { + "benunit": { + "members": ["claimant"], + "in_specified_or_temporary_accommodation": {YEAR: True}, + "would_claim_uc": {YEAR: True}, + "claims_all_entitled_benefits": {YEAR: False}, + } + }, + "households": { + "household": { + "members": ["claimant"], + "region": {YEAR: "LONDON"}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: RENT}, + "council_tax": {YEAR: 1_500}, + } + }, +} + + +@pytest.mark.parametrize("simulation_class", [Simulation, Microsimulation]) +def test_data_built_family_with_the_status_and_no_report_does_not_claim( + simulation_class, +): + simulation = simulation_class(dataset=dataset(housing_benefit_reported=0)) + assert built_from_data(simulation) + assert not simulation.calculate("would_claim_housing_benefit", YEAR)[0] + # The family can claim, but doesn't, and Universal Credit leaves the rent + # out, so nothing pays it. + assert simulation.calculate("housing_benefit_eligible", YEAR)[0] + assert simulation.calculate("housing_benefit", YEAR)[0] == 0 + assert simulation.calculate("uc_housing_costs_element", YEAR)[0] == 0 + + +@pytest.mark.parametrize("simulation_class", [Simulation, Microsimulation]) +def test_data_built_family_with_the_status_and_a_report_claims(simulation_class): + simulation = simulation_class(dataset=dataset(housing_benefit_reported=5_000)) + assert simulation.calculate("would_claim_housing_benefit", YEAR)[0] + # Universal Credit passports the Housing Benefit means test, so Housing + # Benefit pays the whole rent. + assert simulation.calculate("housing_benefit", YEAR)[0] == pytest.approx( + RENT, abs=0.01 + ) + + +def test_the_same_family_hand_entered_claims(): + simulation = Simulation(situation=SITUATION) + assert not built_from_data(simulation) + assert simulation.calculate("would_claim_housing_benefit", YEAR)[0] + assert simulation.calculate("housing_benefit", YEAR)[0] == pytest.approx( + RENT, abs=0.01 + ) diff --git a/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py b/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py index b6c9207caf..eb53c8b968 100644 --- a/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py @@ -14,8 +14,8 @@ The model pays Housing Benefit alongside Universal Credit for specified or temporary accommodation (UC (Transitional Provisions) Regs 2014 reg 5(2)(a)) through in_specified_or_temporary_accommodation. Each claimant benefit unit -here supplies housing_benefit_eligible instead, so the passport is tested -without the Universal Credit housing costs element changing. +here supplies housing_benefit_eligible instead, so the test checks the +passport without the Universal Credit housing costs element changing. Invariants, for any generated population of renting families (some with a non-dependant in the household), each on Universal Credit (a supplied amount diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py b/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py index c0f188cf7c..042f380a3b 100644 --- a/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py @@ -21,10 +21,10 @@ # it is outside the Housing Benefit cap (HB Regs 2006 reg. 75F(1)(g)). With # those two adjustments one total serves both schemes. A family in specified # or temporary accommodation can receive both awards (housing_benefit_eligible), -# and the Universal Credit cap then counts its Housing Benefit as nil. Where the -# Housing Benefit cap applies, to a family without Universal Credit, reg. -# 75C(2)(a) counts Housing Benefit for specified accommodation as nil; the -# model's input in_specified_or_temporary_accommodation does not separate +# and DWP then counts its Housing Benefit as nil in the Universal Credit cap. +# Where the Housing Benefit cap applies, to a family without Universal Credit, +# the council counts Housing Benefit for specified accommodation as nil (reg. +# 75C(2)(a)); the model's input in_specified_or_temporary_accommodation does not separate # specified from temporary accommodation, so the formula counts that Housing # Benefit at its amount before the cap. CLAIMANT_OR_PARTNER_BENEFITS = [ diff --git a/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py index cd8fe9567d..acb28e2d47 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py @@ -6,21 +6,22 @@ class esa_income_appointee_saving(Variable): entity = Person label = "Income-related ESA award saved from abolition for an appointee" documentation = ( - "Whether the art. 3A(2) saving keeps the old style income-related ESA " - "award on this person's report payable after the abolition of " - "income-related ESA on 1 July 2026 (1 October 2026 in Northern " - "Ireland). SI 2025/1148 art. 3A(2) saves an award where, immediately " - "before that date, someone holds an appointment under reg 57 of the " + "Whether the old style income-related ESA award on this person's " + "report stays payable under the art. 3A(2) saving after the abolition " + "of income-related ESA on 1 July 2026 (1 October 2026 in Northern " + "Ireland). Under SI 2025/1148 art. 3A(2), an award stays payable " + "where, immediately before that date, someone holds an appointment under reg 57 of the " "Claims and Payments Regulations 2013 to act for a claimant who cannot " "act, or where the Secretary of State decided in the six months ending " - "on that date that the claimant would likely need one. SR 2025/176 art. " - "3A(2) does the same in Northern Ireland, with the Department for " - "Communities and reg " - "52 of the Northern Ireland Claims and Payments Regulations 2016. The " + "on that date that the claimant would likely need one. The same saving " + "applies in Northern Ireland (SR 2025/176 art. 3A(2)), where the " + "Department for Communities decides and reg 52 of the Northern " + "Ireland Claims and Payments Regulations 2016 governs the " + "appointment. The " "claimant and partner share an award, so the model applies a " "reporting member's saving to the whole claim " - "(esa_income_payable_share); a saving for a member who reports " - "nothing saves nothing. A saved claim prompts no " + "(esa_income_payable_share), and ignores a saving for a member who " + "reports nothing. A saved claim prompts no " "Universal Credit claim and does not move the family " "(esa_income_abolition_reached, legacy_benefits_closed), but a " "Universal Credit claim, or another legacy benefit's closure, still " diff --git a/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py b/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py index 34ed602acb..e11e58de93 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py @@ -17,16 +17,16 @@ class esa_income_payable_share(Variable): "household's appointed date (gov.dwp.ESA.income.awards_payable): 1 " "up to 2025-26; in 2026-27, 86 " "of 365 days in Great Britain and 178 of 365 in Northern Ireland; and " - "0 from 2027-28. Art. 3A(1)(b) leaves an award already due to end at " - "the end of a two-week run-on period (art. 1(2)) to that schedule: " - "with a migration notice deadline of 22 June 2026 and no claim, SI " - "2014/1230 reg 46(1)(a) ends the award on 5 July, after the general " - "date. The annual model does not track deadlines or claim dates and " + "0 from 2027-28. Under art. 3A(1)(b), an award already due to end at " + "the end of a two-week run-on period (art. 1(2)) ends on that " + "schedule: with a migration notice deadline of 22 June 2026 and no " + "claim, the award ends on 5 July (SI 2014/1230 reg 46(1)(a)), after " + "the general date. The annual model does not track deadlines or claim dates and " "pays unsaved awards to the general date. A claim saved for an " "appointee has a share of 1 in " - "every year: a saving for either member who reports a couple's award " - "keeps the whole claim (esa_income_appointee_saving), and a saving for " - "a member who reports nothing keeps nothing. esa_income pays each claim for " + "every year: the model keeps the whole claim for a saving for either " + "member who reports a couple's award (esa_income_appointee_saving), " + "and ignores a saving for a member who reports nothing. esa_income pays each claim for " "this share of the year, and esa_income_report_closed ends its reports " "in a year where it is 0. A contributory allowance continues as new " "style ESA (esa_contrib)." @@ -51,7 +51,8 @@ def formula(person, period, parameters): payable.northern_ireland, payable.great_britain, ) - # Art. 3A(2) saves an award for an appointee in every year. The + # Under art. 3A(2), an award saved for an appointee stays payable in + # every year. The # claimant and partner share an award, so the model applies a # reporting member's saving to the whole claim; another member's # award is their own. diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py b/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py index 651f08f40e..7af9d509b7 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py @@ -23,7 +23,9 @@ class in_specified_or_temporary_accommodation(Variable): "(SI 2014/1230 regs 6A(2), 8(3) and 46(2); NI: SR 2016/226 regs " "4A(2), 6(3) and 47(2)), and the Universal Credit housing costs " "element is nil for it (UC Regs 2013 Sch 1 paras 3(h) and 3(i); NI: " - "SR 2016/216 Sch 1 paras 3(e) and 3(ea)). " + "SR 2016/216 Sch 1 paras 3(e) and 3(ea)). A hand-entered family " + "with this status takes Housing Benefit up unless a situation sets " + "would_claim_housing_benefit. " "It defaults to False when absent from household or dataset inputs." ) definition_period = YEAR diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py index c07780f754..2226d80c78 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py @@ -9,15 +9,27 @@ class would_claim_housing_benefit(Variable): documentation = ( "Whether this family would claim Housing Benefit if eligible. " "Data-built simulations default to reporting families only; hand-entered " - "situations also claim when claims_all_entitled_benefits is true. " - "Datasets and situations can override this directly." + "situations also claim when claims_all_entitled_benefits is true, " + "and when the family has in_specified_or_temporary_accommodation: " + "the Secretary of State excludes that rent from Universal Credit's " + "housing costs element (UC Regs 2013 Sch 1 paras 3(h) and (i)), so " + "only Housing Benefit helps with it, and a family's report of another " + "benefit doesn't show whether it claims Housing Benefit. Datasets " + "and situations can override this directly." ) definition_period = YEAR def formula(benunit, period, parameters): reported_hb = add(benunit, period, ["housing_benefit_reported"]) > 0 - # d1455 applies d1390's survey reporters-only rule to HB. Hand-entered - # situations retain main's per-family take-up rule (PR 1831 review). + # A data-built simulation claims only for families that report + # Housing Benefit. A hand-entered family also claims under the + # per-family take-up rule, or when it has the accommodation status, + # since Universal Credit then pays nothing towards that rent. if built_from_data(benunit.simulation): return reported_hb - return benunit("claims_all_entitled_benefits", period) | reported_hb + accommodation = benunit("in_specified_or_temporary_accommodation", period) + return ( + benunit("claims_all_entitled_benefits", period) + | reported_hb + | accommodation + ) From 3c9890a19abb714575050f8d8699ed9968a577e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:56:47 +0000 Subject: [PATCH 22/23] Name the Secretary of State as the actor in the accommodation route's comment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KjKZVLNEzacp8jh34 --- .../gov/dwp/housing_benefit/housing_benefit_eligible.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py index 5ee143a610..f3cea7f86f 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_eligible.py @@ -107,9 +107,9 @@ def formula(benunit, period, parameters): # 6(3)) nor when the deadline passes without one (reg 46(2); NI reg # 47(2)). The route reads the supplied status, not an award, so it # adds no read of Universal Credit or Housing Benefit amounts. - # Universal Credit excludes this rent from its housing costs element - # (UC Regs 2013 Sch 1 paras 3(h), 3(i), 3A and 3B), so only Housing - # Benefit pays it. + # The Secretary of State excludes this rent from Universal Credit's + # rent payments (UC Regs 2013 Sch 1 paras 3(h), 3(i), 3A and 3B), so + # only Housing Benefit pays it. accommodation = benunit("in_specified_or_temporary_accommodation", period) social = benunit.any(person("in_social_housing", period)) lha_eligible = benunit("LHA_eligible", period) From dc69f3ada6b6bee045ce4db88406311ba5701fcd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 03:22:55 +0000 Subject: [PATCH 23/23] Keep the hand-entered Housing Benefit take-up rule for specified or temporary accommodation would_claim_housing_benefit no longer reads in_specified_or_temporary_accommodation: a hand-entered family claims when claims_all_entitled_benefits is true or it reports Housing Benefit, and a data-built one when it reports Housing Benefit. The documentation of both variables now says that a hand-entered family with the status that reports another means-tested benefit and no Housing Benefit gets no help with the rent, and that setting housing_benefit_reported or would_claim_housing_benefit models the claim. The cases and properties R5 to R7 pin both results. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KQ9PJBdyEd9CqHdqK --- changelog.d/2562.fixed.md | 2 +- .../esa_income/esa_income_abolition.yaml | 59 +++++++++-- .../housing_benefit_eligible.yaml | 57 ++++++++--- .../would_claim_housing_benefit.yaml | 17 +++- ..._benefit_accommodation_dates_properties.py | 97 +++++++++++++------ ...enefit_accommodation_take_up_data_built.py | 60 ++++++++++-- ...in_specified_or_temporary_accommodation.py | 8 +- .../would_claim_housing_benefit.py | 24 ++--- 8 files changed, 243 insertions(+), 81 deletions(-) diff --git a/changelog.d/2562.fixed.md b/changelog.d/2562.fixed.md index 6c74821041..cbc0afbeeb 100644 --- a/changelog.d/2562.fixed.md +++ b/changelog.d/2562.fixed.md @@ -1 +1 @@ -Model income-related ESA payments up to the 2026 abolition dates and Universal Credit claims when a family's award starts to end. The model pays unsaved claims to 30 June 2026 in Great Britain and 30 September 2026 in Northern Ireland, using 86/365 and 178/365 of the 2026-27 year, and from 2027-28 moves each affected family off its legacy benefits. Users can preserve an award with the new appointee-saving input. A family in specified or temporary accommodation now keeps or can newly claim Housing Benefit for that rent beside a Universal Credit claim or DWP's move off legacy benefits, and Universal Credit leaves the rent out of its housing costs element; a hand-entered family with that status takes Housing Benefit up. +Model income-related ESA payments up to the 2026 abolition dates and Universal Credit claims when a family's award starts to end. The model pays unsaved claims to 30 June 2026 in Great Britain and 30 September 2026 in Northern Ireland, using 86/365 and 178/365 of the 2026-27 year, and from 2027-28 moves each affected family off its legacy benefits. Users can preserve an award with the new appointee-saving input. A family in specified or temporary accommodation now keeps or can newly claim Housing Benefit for that rent beside a Universal Credit claim or DWP's move off legacy benefits, and Universal Credit leaves the rent out of its housing costs element. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml index a37a5e22d2..9a286ee288 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml @@ -776,7 +776,7 @@ universal_credit: 5_098.80 household_net_income: 33_518.80 -- name: Great Britain 2026-27, the same London tenant without a Housing Benefit report takes Housing Benefit up for the rent +- name: Great Britain 2026-27, the same London tenant without a Housing Benefit report claims only the benefits it reports and gets no help with the rent period: 2026 absolute_error_margin: 0.01 input: @@ -799,14 +799,57 @@ rent: 30_000 council_tax: 1_500 output: - # The family reports income-related ESA, so claims_all_entitled_benefits - # is false, but the accommodation status makes it claim Housing Benefit - # (would_claim_housing_benefit): Universal Credit leaves the rent out. - # The figures match the tenant who reports Housing Benefit: Universal - # Credit £5,098.80, Housing Benefit £30,000, and household net income - # £30,000 + £5,098.80 + £100 - £180 - £1,500 = £33,518.80. + # The family reports £5,000 of income-related ESA, at least £1 across the + # seven means-tested benefits, so claims_all_entitled_benefits is false + # and the family claims only what it reports: with no Housing Benefit + # report, would_claim_housing_benefit is false. The accommodation status + # doesn't change take-up. The claim on the appointed day ends the ESA + # award, and Universal Credit leaves the rent out (UC Regs 2013 Sch 1 + # paras 3(h) and (i)), so it pays the standard allowance alone, 12 x + # £424.90 = £5,098.80, and nothing helps with the £30,000 rent. + # Household net income: £5,098.80 + £100 Household Energy Payment - £180 + # TV licence - £1,500 council tax = £3,518.80. claims_all_entitled_benefits: false - would_claim_housing_benefit: true + would_claim_housing_benefit: false + claims_universal_credit: true + housing_benefit_eligible: true + housing_benefit: 0 + uc_housing_costs_element: 0 + universal_credit: 5_098.80 + household_net_income: 3_518.80 + +- name: Great Britain 2026-27, the same London tenant gets Housing Benefit for the rent when the user sets would_claim_housing_benefit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + esa_income_reported: 5_000 + esa_includes_support_component: false + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + in_specified_or_temporary_accommodation: true + would_claim_housing_benefit: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 30_000 + council_tax: 1_500 + output: + # The user sets the claim, so Housing Benefit pays the rent. Universal + # Credit passports the Housing Benefit means test, so Housing Benefit + # pays the whole £30,000, and the council applies no Housing Benefit cap + # to a claimant receiving Universal Credit (HB Regs 2006 reg 75F(1)(g)). + # Universal Credit: the standard allowance alone, 12 x £424.90 = + # £5,098.80, below London's £16,967 single cap, where DWP counts Housing + # Benefit as nil (UC Regs 2013 reg 80(2A)). Household net income: + # £30,000 + £5,098.80 + £100 - £180 - £1,500 = £33,518.80. claims_universal_credit: true housing_benefit_eligible: true housing_benefit: 30_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml index 5da82795b5..713938e19a 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_eligible.yaml @@ -219,16 +219,14 @@ universal_credit: 11_098.80 # A hand-entered family that reports Universal Credit but not Housing Benefit -# reports a benefit, so claims_all_entitled_benefits is false. The -# accommodation status still makes it claim Housing Benefit -# (would_claim_housing_benefit), since Universal Credit leaves the rent out. -# Universal Credit: the standard allowance alone, 12 x £424.90 = £5,098.80. -# Housing Benefit: Universal Credit passports the means test, so it pays the -# whole £10,000 rent, and the Housing Benefit cap doesn't apply to a claimant -# receiving Universal Credit (HB Regs 2006 reg 75F(1)(g)). Household net -# income: £10,000 + £5,098.80 + £100 Household Energy Payment - £180 TV -# licence - £1,500 council tax = £13,518.80. -- name: A family in temporary accommodation that reports only Universal Credit takes Housing Benefit up for the rent +# reports at least £1 across the seven means-tested benefits, so +# claims_all_entitled_benefits is false and the family claims only what it +# reports: would_claim_housing_benefit is false, and the accommodation status +# doesn't change that. Universal Credit leaves the rent out, so it pays the +# standard allowance alone, 12 x £424.90 = £5,098.80, and nothing helps with +# the £10,000 rent. Household net income: £5,098.80 + £100 Household Energy +# Payment - £180 TV licence - £1,500 council tax = £3,518.80. +- name: A family in temporary accommodation that reports only Universal Credit claims no Housing Benefit and gets no help with the rent period: 2026 absolute_error_margin: 0.01 input: @@ -250,7 +248,42 @@ council_tax: 1_500 output: claims_all_entitled_benefits: false - would_claim_housing_benefit: true + would_claim_housing_benefit: false + claims_universal_credit: true + housing_benefit_eligible: true + housing_benefit: 0 + uc_housing_costs_element: 0 + universal_credit: 5_098.80 + household_net_income: 3_518.80 + +# The same family with would_claim_housing_benefit set by the user. Universal +# Credit passports the Housing Benefit means test, so Housing Benefit pays the +# whole £10,000 rent, and the council applies no Housing Benefit cap to a +# claimant receiving Universal Credit (HB Regs 2006 reg 75F(1)(g)). Universal +# Credit: £5,098.80. Household net income: £10,000 + £5,098.80 + £100 - £180 - +# £1,500 = £13,518.80, the same as the control outside that accommodation. +- name: The same family gets Housing Benefit for the rent when the user sets would_claim_housing_benefit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + universal_credit_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + in_specified_or_temporary_accommodation: true + would_claim_housing_benefit: true + households: + household: + members: [claimant] + region: LONDON + tenure_type: RENT_FROM_COUNCIL + rent: 10_000 + council_tax: 1_500 + output: claims_universal_credit: true housing_benefit_eligible: true housing_benefit: 10_000 @@ -261,7 +294,7 @@ # The control without the status: Universal Credit pays the social rent, # £5,098.80 + £10,000 = £15,098.80, under London's £16,967 single cap. # Household net income: £15,098.80 + £100 - £180 - £1,500 = £13,518.80, the -# same as with the status. +# same as with the status and would_claim_housing_benefit set. - name: The same family outside that accommodation gets the rent through Universal Credit period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/would_claim_housing_benefit.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/would_claim_housing_benefit.yaml index 6696159e4c..e751b7ae19 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/would_claim_housing_benefit.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/would_claim_housing_benefit.yaml @@ -37,14 +37,23 @@ output: would_claim_housing_benefit: false -# Universal Credit leaves the rent of specified or temporary accommodation out -# of its housing costs element, so a hand-entered family with the status -# claims Housing Benefit whatever else it reports. -- name: The accommodation status claims without a report +# The accommodation status doesn't change take-up: a hand-entered family +# claims Housing Benefit when claims_all_entitled_benefits is true or it +# reports Housing Benefit. +- name: The accommodation status doesn't make a family claim without a report period: 2025 input: claims_all_entitled_benefits: false housing_benefit_reported: 0 in_specified_or_temporary_accommodation: true + output: + would_claim_housing_benefit: false + +- name: A family with the accommodation status claims when it claims all entitled benefits + period: 2025 + input: + claims_all_entitled_benefits: true + housing_benefit_reported: 0 + in_specified_or_temporary_accommodation: true output: would_claim_housing_benefit: true diff --git a/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py b/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py index 0d629a968a..065e8198f8 100644 --- a/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py @@ -36,16 +36,20 @@ with its Housing Benefit set to nil. The Housing Benefit cap makes no reduction (HB Regs 2006 reg 75F(1)(g)), so Housing Benefit equals its amount before the cap. -R5. Take-up: in a hand-entered situation, a family with the status claims - Housing Benefit (would_claim_housing_benefit) whatever else it reports, - since Universal Credit leaves the rent out. With the status set false, +R5. Take-up: in a hand-entered situation, the status never changes + would_claim_housing_benefit. With the status true, false or unset, take-up equals claims_all_entitled_benefits or a Housing Benefit report. -R6. No lost help: a family with the status that passes the tenure and - capital tests (housing_benefit_eligible) never ends up with neither - Housing Benefit nor a housing costs element, except where the Housing - Benefit means test takes its entitlement to nil - (housing_benefit_entitlement). The Housing Benefit cap can't take it to - nil: it leaves the 50 pence weekly minimum. +R6. No lost help with the claim: a family with the status and + would_claim_housing_benefit that passes the tenure and capital tests + (housing_benefit_eligible) never ends up with neither Housing Benefit + nor a housing costs element, except where the Housing Benefit means test + takes its entitlement to nil (housing_benefit_entitlement). The Housing + Benefit cap can't take it to nil: it leaves the 50 pence weekly minimum. +R7. No help without the claim: a hand-entered family with the status that + reports one of the seven means-tested benefits and no Housing Benefit, + with take-up left to the formula, gets neither Housing Benefit nor a + housing costs element, since it claims only what it reports and + Universal Credit leaves the rent out. Fixture-scale populations hold annual incomes, rent and circumstances constant. The direct oracle implements the earnings cap, residual childcare @@ -408,10 +412,12 @@ def accommodation_families(draw): def accommodation_simulation(units, year, benunit_overrides=None, status=True): """One household per family, each in specified or temporary accommodation - unless status is False. + unless status is False. With status None, the situation leaves the input + unset. benunit_overrides maps a benefit unit variable to a value every family - receives as an input, in place of the formula. + receives as an input, in place of the formula. A family whose + claims_all is None leaves claims_all_entitled_benefits to its formula. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -433,11 +439,13 @@ def accommodation_simulation(units, year, benunit_overrides=None, status=True): members.append(name) benunit = { "members": members, - "in_specified_or_temporary_accommodation": {year: status}, "would_claim_uc": {year: unit["would_claim_uc"]}, "would_claim_uc_at_legacy_closure": {year: unit["claim_at_closure"]}, - "claims_all_entitled_benefits": {year: unit["claims_all"]}, } + if status is not None: + benunit["in_specified_or_temporary_accommodation"] = {year: status} + if unit["claims_all"] is not None: + benunit["claims_all_entitled_benefits"] = {year: unit["claims_all"]} for variable, value in (benunit_overrides or {}).items(): benunit[variable] = {year: value} benunits[f"b{i}"] = benunit @@ -528,29 +536,25 @@ def test_r3_with_both_awards_only_the_universal_credit_cap_applies(units, year): @ROUTE_SETTINGS @given(units=ROUTE_POPULATIONS, year=st.sampled_from(ROUTE_YEARS)) -def test_r5_the_status_takes_housing_benefit_up(units, year): - with_status = accommodation_simulation(units, year) - np.testing.assert_array_equal( - with_status.calculate("would_claim_housing_benefit", year), - np.ones(len(units), dtype=bool), - ) - without = accommodation_simulation(units, year, status=False) - reported = ( - without.populations["benunit"].sum( - without.calculate("housing_benefit_reported", year) +def test_r5_the_status_never_changes_take_up(units, year): + for status in (True, False, None): + sim = accommodation_simulation(units, year, status=status) + reported = ( + sim.populations["benunit"].sum( + sim.calculate("housing_benefit_reported", year) + ) + > 0 + ) + np.testing.assert_array_equal( + sim.calculate("would_claim_housing_benefit", year), + sim.calculate("claims_all_entitled_benefits", year) | reported, ) - > 0 - ) - np.testing.assert_array_equal( - without.calculate("would_claim_housing_benefit", year), - without.calculate("claims_all_entitled_benefits", year) | reported, - ) @ROUTE_SETTINGS @given(units=ROUTE_POPULATIONS, year=st.sampled_from(ROUTE_YEARS)) -def test_r6_an_eligible_family_keeps_help_with_the_rent(units, year): - sim = accommodation_simulation(units, year) +def test_r6_an_eligible_family_that_claims_keeps_help_with_the_rent(units, year): + sim = accommodation_simulation(units, year, {"would_claim_housing_benefit": True}) eligible = sim.calculate("housing_benefit_eligible", year) # The one exception: the means test takes Housing Benefit to nil. means_tested = sim.calculate("housing_benefit_entitlement", year) > 0 @@ -558,3 +562,34 @@ def test_r6_an_eligible_family_keeps_help_with_the_rent(units, year): sim.calculate("uc_housing_costs_element", year) > 0 ) assert not np.any(eligible & means_tested & ~helped) + + +@ROUTE_SETTINGS +@given(units=ROUTE_POPULATIONS, year=st.sampled_from(ROUTE_YEARS)) +def test_r7_a_family_that_reports_another_benefit_gets_no_help_with_the_rent( + units, year +): + # Each family reports income-related ESA or Working Tax Credit and no + # Housing Benefit, and claims_all_entitled_benefits and + # would_claim_housing_benefit follow their formulas. + units = [ + { + **unit, + "hb_reported": 0, + "claims_all": None, + "esa_reported": unit["esa_reported"] + or (0 if unit["wtc_reported"] else 5_000), + } + for unit in units + ] + sim = accommodation_simulation(units, year) + np.testing.assert_array_equal( + sim.calculate("would_claim_housing_benefit", year), + np.zeros(len(units), dtype=bool), + ) + np.testing.assert_array_equal( + sim.calculate("housing_benefit", year), np.zeros(len(units)) + ) + np.testing.assert_array_equal( + sim.calculate("uc_housing_costs_element", year), np.zeros(len(units)) + ) diff --git a/policyengine_uk/tests/test_housing_benefit_accommodation_take_up_data_built.py b/policyengine_uk/tests/test_housing_benefit_accommodation_take_up_data_built.py index b60cc420b7..8965bf2772 100644 --- a/policyengine_uk/tests/test_housing_benefit_accommodation_take_up_data_built.py +++ b/policyengine_uk/tests/test_housing_benefit_accommodation_take_up_data_built.py @@ -1,11 +1,13 @@ """Housing Benefit take-up with the accommodation status in data-built runs. -A hand-entered family with in_specified_or_temporary_accommodation claims -Housing Benefit (would_claim_housing_benefit). A simulation built from data -keeps its reporters-only rule: the status doesn't make a family claim, so a -data-built family with the status and no Housing Benefit report gets no help -with the rent. No dataset sets the status, so this pins the data branch -without survey data. +in_specified_or_temporary_accommodation doesn't change Housing Benefit +take-up (would_claim_housing_benefit). A simulation built from data keeps its +reporters-only rule, so a data-built family with the status and no Housing +Benefit report gets no help with the rent. A hand-entered family claims when +claims_all_entitled_benefits is true or it reports Housing Benefit, so the +same family hand-entered with claims_all_entitled_benefits false gets none +either. Setting would_claim_housing_benefit models the claim in both. No +dataset sets the status, so this pins the data branch without survey data. """ import pandas as pd @@ -19,7 +21,7 @@ RENT = 10_000 -def dataset(housing_benefit_reported): +def dataset(housing_benefit_reported, benunit_inputs=None): return UKMultiYearDataset( datasets=[ UKSingleYearDataset( @@ -37,6 +39,10 @@ def dataset(housing_benefit_reported): "benunit_id": [1], "in_specified_or_temporary_accommodation": [True], "would_claim_uc": [True], + **{ + name: [value] + for name, value in (benunit_inputs or {}).items() + }, } ), household=pd.DataFrame( @@ -106,10 +112,46 @@ def test_data_built_family_with_the_status_and_a_report_claims(simulation_class) ) -def test_the_same_family_hand_entered_claims(): +@pytest.mark.parametrize("simulation_class", [Simulation, Microsimulation]) +def test_data_built_family_with_the_status_claims_when_the_input_says_so( + simulation_class, +): + simulation = simulation_class( + dataset=dataset( + housing_benefit_reported=0, + benunit_inputs={"would_claim_housing_benefit": True}, + ) + ) + assert built_from_data(simulation) + assert simulation.calculate("would_claim_housing_benefit", YEAR)[0] + assert simulation.calculate("housing_benefit", YEAR)[0] == pytest.approx( + RENT, abs=0.01 + ) + + +def test_the_same_family_hand_entered_does_not_claim(): simulation = Simulation(situation=SITUATION) assert not built_from_data(simulation) - assert simulation.calculate("would_claim_housing_benefit", YEAR)[0] + # claims_all_entitled_benefits is false and the family reports no Housing + # Benefit, so it doesn't claim, and Universal Credit leaves the rent out. + assert not simulation.calculate("would_claim_housing_benefit", YEAR)[0] + assert simulation.calculate("housing_benefit_eligible", YEAR)[0] + assert simulation.calculate("housing_benefit", YEAR)[0] == 0 + assert simulation.calculate("uc_housing_costs_element", YEAR)[0] == 0 + + +def test_the_same_family_hand_entered_claims_when_the_input_says_so(): + situation = { + **SITUATION, + "benunits": { + "benunit": { + **SITUATION["benunits"]["benunit"], + "would_claim_housing_benefit": {YEAR: True}, + } + }, + } + simulation = Simulation(situation=situation) + assert not built_from_data(simulation) assert simulation.calculate("housing_benefit", YEAR)[0] == pytest.approx( RENT, abs=0.01 ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py b/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py index 7af9d509b7..17eac892b2 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/in_specified_or_temporary_accommodation.py @@ -23,8 +23,12 @@ class in_specified_or_temporary_accommodation(Variable): "(SI 2014/1230 regs 6A(2), 8(3) and 46(2); NI: SR 2016/226 regs " "4A(2), 6(3) and 47(2)), and the Universal Credit housing costs " "element is nil for it (UC Regs 2013 Sch 1 paras 3(h) and 3(i); NI: " - "SR 2016/216 Sch 1 paras 3(e) and 3(ea)). A hand-entered family " - "with this status takes Housing Benefit up unless a situation sets " + "SR 2016/216 Sch 1 paras 3(e) and 3(ea)). Universal Credit pays " + "nothing towards this rent, so a family gets help with it only " + "through Housing Benefit. A hand-entered family that reports another " + "of the seven means-tested benefits and no Housing Benefit claims " + "only what it reports (would_claim_housing_benefit), and gets none; " + "to model the claim, set housing_benefit_reported or " "would_claim_housing_benefit. " "It defaults to False when absent from household or dataset inputs." ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py index 2226d80c78..02bc6541d3 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/would_claim_housing_benefit.py @@ -9,12 +9,14 @@ class would_claim_housing_benefit(Variable): documentation = ( "Whether this family would claim Housing Benefit if eligible. " "Data-built simulations default to reporting families only; hand-entered " - "situations also claim when claims_all_entitled_benefits is true, " - "and when the family has in_specified_or_temporary_accommodation: " - "the Secretary of State excludes that rent from Universal Credit's " - "housing costs element (UC Regs 2013 Sch 1 paras 3(h) and (i)), so " - "only Housing Benefit helps with it, and a family's report of another " - "benefit doesn't show whether it claims Housing Benefit. Datasets " + "situations also claim when claims_all_entitled_benefits is true. " + "in_specified_or_temporary_accommodation doesn't change take-up. " + "Universal Credit pays nothing towards the rent of that accommodation " + "(UC Regs 2013 Sch 1 paras 3(h) and (i)), so a family gets help with " + "it only through Housing Benefit; a hand-entered family that reports " + "another of the seven means-tested benefits and no Housing Benefit " + "claims only what it reports, and gets none. To model the claim, set " + "housing_benefit_reported or would_claim_housing_benefit. Datasets " "and situations can override this directly." ) definition_period = YEAR @@ -23,13 +25,7 @@ def formula(benunit, period, parameters): reported_hb = add(benunit, period, ["housing_benefit_reported"]) > 0 # A data-built simulation claims only for families that report # Housing Benefit. A hand-entered family also claims under the - # per-family take-up rule, or when it has the accommodation status, - # since Universal Credit then pays nothing towards that rent. + # per-family take-up rule. if built_from_data(benunit.simulation): return reported_hb - accommodation = benunit("in_specified_or_temporary_accommodation", period) - return ( - benunit("claims_all_entitled_benefits", period) - | reported_hb - | accommodation - ) + return benunit("claims_all_entitled_benefits", period) | reported_hb