diff --git a/changelog.d/2562.fixed.md b/changelog.d/2562.fixed.md new file mode 100644 index 0000000000..cbc0afbeeb --- /dev/null +++ b/changelog.d/2562.fixed.md @@ -0,0 +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. diff --git a/docs/engineering/esa-jsa-report-screening.md b/docs/engineering/esa-jsa-report-screening.md index 1994eb87ad..94e8d27abd 100644 --- a/docs/engineering/esa-jsa-report-screening.md +++ b/docs/engineering/esa-jsa-report-screening.md @@ -14,6 +14,25 @@ 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 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 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 +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 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 exclude outside claims, including in Income Support and an alternate benefit-cap 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..2d935abcfa --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/awards_payable.yaml @@ -0,0 +1,59 @@ +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 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 + 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. 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 + 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: 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. 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 + 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/reforms/policyengine/disable_simulated_benefits.py b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py index 6c4ef3aeec..ebf302f7bd 100644 --- a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py +++ b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py @@ -60,21 +60,32 @@ # 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). -# 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 +# _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", + "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/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 318019bded..9f9cb76332 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 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. + """ + 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 new file mode 100644 index 0000000000..9a286ee288 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_abolition.yaml @@ -0,0 +1,1206 @@ +# 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 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 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. 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, 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. +# +# 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). +# +# Unless a case says otherwise, nobody has capital, so there is no tariff +# income and the annual award is the reported amount. + +- 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 + 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 + # 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 + 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 + 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 + would_claim_uc_at_legacy_closure: 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 + would_claim_uc_at_legacy_closure: 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 + 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 + 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 + 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 + 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 + would_claim_uc_at_legacy_closure: 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: + # 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 + 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_abolition_reached: false + claims_universal_credit: false + 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): 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 + 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 + would_claim_uc_at_legacy_closure: 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 + would_claim_uc_at_legacy_closure: 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: + # 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] + # 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: + # 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 + 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: + # 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 + 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, 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: + # 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 + 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 keeps its 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 on its award's appointed day. The claim doesn't + # terminate Housing Benefit for specified or temporary accommodation + # (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 + 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. 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 + # 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 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: + 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 £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: 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 + 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 + 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. 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. + 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, DWP caps 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. 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, + # 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 + 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 couple's whole award stays payable after the abolition under the claimant's saving + 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: + # 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 + 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 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 + 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_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml index 8a01aa7799..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,6 +30,9 @@ claimant: age: 40 esa_income_reported: 1_000 + # 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: members: claimant @@ -50,6 +53,9 @@ claimant: age: 40 esa_income_reported: 1_000 + # 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: members: claimant @@ -71,6 +77,9 @@ claimant: age: 40 esa_income_reported: 3_000 + # 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: 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..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 @@ -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] @@ -1299,6 +1305,9 @@ age: 30 is_claimant_or_partner: true esa_income_reported: 5_000 + # 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 is_claimant_or_partner: true 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..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 @@ -37,3 +37,345 @@ 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 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 + # 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 + +# 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. 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 + 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, 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 + 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 + would_claim_housing_benefit: 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 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: 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 + 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: + # 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 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: + 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: 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 + 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 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 + 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/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/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..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 @@ -27,3 +27,33 @@ 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 + +# 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/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..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,6 +152,9 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 4_000 + # 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: members: [applicant] @@ -178,6 +181,9 @@ is_claimant_or_partner: true current_education: NOT_IN_EDUCATION esa_income_reported: 4_000 + # 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 @@ -209,6 +215,9 @@ is_claimant_or_partner: true current_education: NOT_IN_EDUCATION esa_income_reported: 4_000 + # 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 is_claimant_or_partner: true @@ -245,6 +254,9 @@ is_claimant_or_partner: true current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 + # 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 is_claimant_or_partner: true @@ -290,11 +302,17 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 200 + # 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 + # 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: 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..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,6 +19,9 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 + # 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: members: [claimant, other] @@ -66,6 +69,9 @@ age: 50 is_claimant_or_partner: true esa_income_reported: 5_000 + # 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: 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..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,6 +24,9 @@ is_claimant_or_partner: false current_education: NOT_IN_EDUCATION esa_income_reported: 5_000 + # 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: members: [claimant, other] @@ -82,6 +85,9 @@ age: 60 is_claimant_or_partner: true esa_income_reported: 5_000 + # 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: members: [claimant, partner] 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/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..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 @@ -29,3 +29,59 @@ uc_limited_capability_for_WRA: true output: uc_work_allowance: 5_124 # 427 * 12 + +# 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 + 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/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_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_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 46ae03d233..31875f8ea3 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,6 +378,11 @@ def test_each_person_is_on_their_own_award(reformed, year): str(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. + # 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 new file mode 100644 index 0000000000..118d34d13c --- /dev/null +++ b/policyengine_uk/tests/test_esa_income_abolition_properties.py @@ -0,0 +1,536 @@ +"""Property-based tests for the abolition of income-related ESA (#2562). + +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 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 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; +with the saving of every member who reports nothing flipped; and with +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 +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; 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 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 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 + 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. +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 +import math + +import numpy as np +import pytest +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") + +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"], +} +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", +] +# Benefit-unit outputs of the payee helpers rather than variables. +HELPER_OUTPUTS = ["holds_formula_award"] +PERSON_OUTPUTS = [ + "esa_contrib", + "esa_income_payable_share", + "esa_income_report_closed", + "is_on_income_related_esa", + "is_claimant_or_partner", + "person_legacy_benefits_closed", +] +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, work=True): + 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 and work + else 0.0 + ), + weekly_hours=( + draw(st.sampled_from([0, 10, 30])) if adult and work 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, 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()), + ) + + +def every_year(value): + return {year: value for year in YEARS} + + +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, + 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 + ] + + +def situation(units): + 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 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"]), + "employment_income": every_year(member["employment_income"]), + "weekly_hours": every_year(member["weekly_hours"]), + "esa_income_appointee_saving": every_year(member["saved"]), + } + names.append(name) + 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}"] = { + "members": names, + "region": every_year(unit["region"]), + "savings": every_year(unit["savings"]), + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(units, reform=None): + sim = Simulation(situation=situation(units), reform=reform) + 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): + """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 modes for u in in_mode(units, mode)] + values = calculate(combined) + out = {mode: {year: {} for year in YEARS} for mode in modes} + for year in YEARS: + 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 + + +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 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 stays payable under a saving.""" + if member["role"] in COUPLE: + return couple_claim_saved(unit) + return member["esa_income_reported"] > 0 and member["saved"] + + +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): + """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 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, 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 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 + 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") +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 + ) + + +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.""" + 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, + 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=False, + claims_when_moved=True, + ) + ] + kept = calculate(units, reform=KEEP_AWARDS_PAYABLE) + 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) + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=8)) +def test_abolition_invariants(units): + values = calculate_each_mode(units) + benunit = members_of(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, 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_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. + 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, 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 + 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, 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 + # 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 + # 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: + 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] + 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 & ~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_esa_income_held_outside_award.py b/policyengine_uk/tests/test_esa_income_held_outside_award.py new file mode 100644 index 0000000000..7637fe94b1 --- /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 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 +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/tests/test_esa_income_universal_credit_claim_properties.py b/policyengine_uk/tests/test_esa_income_universal_credit_claim_properties.py index 6fc6e412da..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 @@ -9,16 +9,17 @@ 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 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 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 @@ -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"]), + # Under art. 3A(2), the award stays 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 @@ -175,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], 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..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,11 +24,14 @@ def _reported_claimants(): + # 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, "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_housing_benefit_accommodation_dates_properties.py b/policyengine_uk/tests/test_housing_benefit_accommodation_dates_properties.py index 283823a670..065e8198f8 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,39 @@ 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. +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 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 limit, additional earnings test, income floor, threshold and award floor @@ -334,3 +367,229 @@ 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, status=True): + """One household per family, each in specified or temporary accommodation + 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. A family whose + claims_all is None leaves claims_all_entitled_benefits to its 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, + "would_claim_uc": {year: unit["would_claim_uc"]}, + "would_claim_uc_at_legacy_closure": {year: unit["claim_at_closure"]}, + } + 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 + 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 + ) + + +@ROUTE_SETTINGS +@given(units=ROUTE_POPULATIONS, year=st.sampled_from(ROUTE_YEARS)) +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, + ) + + +@ROUTE_SETTINGS +@given(units=ROUTE_POPULATIONS, year=st.sampled_from(ROUTE_YEARS)) +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 + helped = (sim.calculate("housing_benefit", year) > 0) | ( + 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 new file mode 100644 index 0000000000..8965bf2772 --- /dev/null +++ b/policyengine_uk/tests/test_housing_benefit_accommodation_take_up_data_built.py @@ -0,0 +1,157 @@ +"""Housing Benefit take-up with the accommodation status in data-built runs. + +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 +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, benunit_inputs=None): + 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], + **{ + name: [value] + for name, value in (benunit_inputs or {}).items() + }, + } + ), + 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 + ) + + +@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) + # 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/tests/test_housing_benefit_universal_credit_passport_properties.py b/policyengine_uk/tests/test_housing_benefit_universal_credit_passport_properties.py index caab1a85b0..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 @@ -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 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/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_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_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] 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..c7f6a1ba7f 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 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 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 cee5d2c28a..85339ff44e 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -1,6 +1,18 @@ """Who is "on" a legacy income-related award.""" from policyengine_uk.model_api import * +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, +) + +# 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): @@ -55,7 +67,22 @@ 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. 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 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 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 that screen passes; neutralising the award always removes it. These @@ -71,7 +98,53 @@ def own_report_is_paid(person, period, award, works, capital_limit): ) ) closed = person("person_legacy_benefits_closed", period) + if award in AWARD_ON_REPORTS: + units = person.simulation.populations["benunit"] + report_closed = person(REPORT_CLOSED[award], period) + 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 the model stores each 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 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 + ] + ) 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..042f380a3b 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 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 = [ "jsa_contrib", "esa_contrib", 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.py b/policyengine_uk/variables/gov/dwp/esa_income.py index f8bd1ff885..4bdb566124 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) + # 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) 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. 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 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..acb28e2d47 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/esa_income_appointee_saving.py @@ -0,0 +1,40 @@ +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 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. 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), 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 " + "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 + 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..e11e58de93 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/esa_income_payable_share.py @@ -0,0 +1,64 @@ +from policyengine_uk.model_api import * + + +class esa_income_payable_share(Variable): + value_type = float + entity = Person + 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 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 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. 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: 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)." + ) + 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", + ) + + 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, + ) + # 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. + 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 dd087317c8..6c02b921d2 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,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." + "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 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 " + "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 = ( @@ -43,6 +53,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 ) 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/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 42db8fc878..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 @@ -18,11 +18,26 @@ 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. When DWP moves the family off a closed legacy " + "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 " + "Northern Ireland (SR 2025/176 art. 7). The 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." ) definition_period = YEAR reference = ( @@ -34,6 +49,10 @@ 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", ) def formula(benunit, period, parameters): @@ -67,16 +86,31 @@ 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. 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, 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 # 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)). 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. + # 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) pension_age_regulations = benunit( @@ -90,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..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 @@ -17,7 +17,19 @@ 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)). 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." ) 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..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 @@ -10,14 +10,22 @@ class would_claim_housing_benefit(Variable): "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." + "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 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. if built_from_data(benunit.simulation): return reported_hb return benunit("claims_all_entitled_benefits", period) | reported_hb 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..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 @@ -26,7 +26,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), 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 " 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..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 @@ -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 - ) + # 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) + ) & 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/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..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,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 the model infers it 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/claims_uc_at_legacy_closure.py b/policyengine_uk/variables/gov/dwp/universal_credit/claims_uc_at_legacy_closure.py index 2ee3f940cb..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 @@ -7,13 +7,28 @@ 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), 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 " + "specified or temporary accommodation." ) 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..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 @@ -8,15 +8,19 @@ 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 " "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. 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/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..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 @@ -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) 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 " "can set it directly." 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..cd4f2ce651 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/esa_income_abolition_reached.py @@ -0,0 +1,64 @@ +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 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 " + "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 " + "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 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 = ( + "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/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) 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) 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..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 @@ -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) ) @@ -51,16 +69,27 @@ 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. 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 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 " + "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 or run-on periods." ) definition_period = YEAR reference = ( @@ -71,6 +100,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 +111,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..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 @@ -7,11 +7,16 @@ 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). " - "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." + "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). " + "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 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..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,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 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 " "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 "