From ac43212a0c0e478e687777235640d1ea571a836c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:25:13 +0100 Subject: [PATCH 1/2] Scope legacy-benefit closure to each award owner --- changelog.d/2395.fixed.md | 1 + .../tests/legacy_award_work_reference.py | 62 +++--- .../esa_income_remunerative_work.yaml | 45 ++++- .../jsa_income_remunerative_work.yaml | 51 ++++- .../person_legacy_benefits_closed.yaml | 147 ++++++++++++++ .../test_legacy_award_readers_properties.py | 5 +- .../test_legacy_award_work_properties.py | 26 ++- .../tests/test_legacy_closure_scope.py | 190 ++++++++++++++++++ policyengine_uk/utils/benefit_unit.py | 9 +- .../variables/gov/dwp/_legacy_award_payee.py | 35 +--- .../variables/gov/dwp/esa_income.py | 6 +- .../variables/gov/dwp/esa_income_eligible.py | 18 +- .../gov/dwp/is_on_income_based_jsa.py | 7 +- .../gov/dwp/is_on_income_related_esa.py | 7 +- .../variables/gov/dwp/jsa_income.py | 6 +- .../variables/gov/dwp/jsa_income_eligible.py | 17 +- .../legacy_benefits_closed.py | 88 ++++---- .../person_legacy_benefits_closed.py | 37 ++++ 18 files changed, 599 insertions(+), 158 deletions(-) create mode 100644 changelog.d/2395.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml create mode 100644 policyengine_uk/tests/test_legacy_closure_scope.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py diff --git a/changelog.d/2395.fixed.md b/changelog.d/2395.fixed.md new file mode 100644 index 0000000000..7d3dce9d6d --- /dev/null +++ b/changelog.d/2395.fixed.md @@ -0,0 +1 @@ +Keep annual legacy-benefit closure scoped to the claimant and partner or a separate member's own award, so unrelated reports cannot close or shield each other's awards. Preserve the existing migration-date proxy and bounded work and capital tests. diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 8ff44c9cf0..367f3102ee 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -33,9 +33,9 @@ (2)(g)), so the limit is 24 either way. - The claimant is a member who reports the award; when the claimant or partner reports one, only they are candidates. -- Both screens also end the award once the family's legacy benefits have - closed (legacy_benefits_closed, from PolicyEngine/policyengine-uk#2119), - read here as the model applies it; see legacy_benefits_closed below. +- Reports whose own award is closed are excluded before selecting claimants. + The existing annual proxy is read separately for the couple and for each + other adult; it is not a notice or deadline simulation. """ import math @@ -143,19 +143,13 @@ def esa_claimant_in_remunerative_work(adult, parameters): def legacy_benefits_closed(couple, others, parameters): - """legacy_benefits_closed for a benefit unit whose members (couple + - others, every member who may report) report no tax credits. - - Once Income Support or income-based JSA has closed (their active - parameters), a report of it closes the family's legacy benefits, unless - someone reports Pension Credit or the claimant and partner have all - reached the qualifying age for it (SI 2014/1230 reg 8(2B)). This is the - model's reading, not the law's: the model reads every member's reports - and ends every member's award, where the migration notice goes to the - claimant and partner (reg 44(1), (4)) and ends their awards (reg 46(1)). - When the result depends on the claimant's and partner's ages, their ages - must keep clear of 65 to 69, where the qualifying age moves; an assertion - checks this.""" + """Annual closure proxy for the claimant's and partner's own award. + + Reports by other members neither close nor shield it (SI 2014/1230 + regs 44(1), (4) and 46(1)). This is the existing annual proxy, not a + notice/deadline model. No tax-credit reports are generated here. Ages + avoid the changing Pension Credit qualifying-age boundary. + """ dwp = parameters.gov.dwp members = couple + others assert not any( @@ -165,7 +159,7 @@ def legacy_benefits_closed(couple, others, parameters): ), "tax credit closures are not read here" def reports(variable): - return any(member.get(variable, 0) > 0 for member in members) + return any(member.get(variable, 0) > 0 for member in couple) closed_report = ( reports("income_support_reported") and not dwp.income_support.active @@ -176,14 +170,24 @@ def reports(variable): return not all(adult["age"] >= 70 for adult in couple) -def candidates(couple, others, reported): +def candidates(couple, others, reported, parameters): """Indexes (into couple + others) of members who could be the claimant.""" members = couple + others - couple_reports = any(adult.get(reported, 0) > 0 for adult in couple) + couple_closed = legacy_benefits_closed(couple, [], parameters) + open_reports = [ + member.get(reported, 0) > 0 + and not ( + couple_closed + if i < len(couple) + else legacy_benefits_closed([member], [], parameters) + ) + for i, member in enumerate(members) + ] + couple_reports = any(open_reports[: len(couple)]) return [ i - for i, member in enumerate(members) - if member.get(reported, 0) > 0 and (i < len(couple) or not couple_reports) + for i, reports in enumerate(open_reports) + if reports and (i < len(couple) or not couple_reports) ] @@ -206,13 +210,9 @@ def other_member_works(i): passes = any( not esa_claimant_in_remunerative_work(members[i], parameters) and not other_member_works(i) - for i in candidates(couple, others, "esa_income_reported") - ) - return ( - passes - and capital <= ESA.capital.limit - and not legacy_benefits_closed(couple, others, parameters) + for i in candidates(couple, others, "esa_income_reported", parameters) ) + return passes and capital <= ESA.capital.limit def jsa_screen(couple, others, capital, parameters): @@ -234,10 +234,6 @@ def other_member_works(i): passes = any( weekly_hours(members[i]) < WORK.claimant_hours and not other_member_works(i) - for i in candidates(couple, others, "jsa_income_reported") - ) - return ( - passes - and capital <= JSA.income.capital.limit - and not legacy_benefits_closed(couple, others, parameters) + for i in candidates(couple, others, "jsa_income_reported", parameters) ) + return passes and capital <= JSA.income.capital.limit 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 3fbc09d789..c57c401bbf 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 @@ -1179,7 +1179,7 @@ # The lodger's own claim passes, so they are on it; the claimant is not. is_on_income_related_esa: [false, true] -- name: A non-dependent adult's own ESA ends once the family's legacy benefits have closed +- name: A non-dependent adult's own ESA remains open when only the couple's legacy benefits close period: 2025 absolute_error_margin: 0 input: @@ -1205,15 +1205,13 @@ members: [claimant, lodger, child] output: legacy_benefits_closed: true - # The lodger does not work, so the screen fails only on the closure. + # The separate non-working claim remains open under the annual proxy. esa_income_claimant_remunerative_work: [false, false, false] - esa_income_eligible: false - esa_income: 0 - # The model ends every member's award once the family's legacy benefits - # close, the lodger's own claim included, so nobody is on it. That is a - # model simplification: in law the migration notice reaches only the - # claimant and partner (SI 2014/1230 regs 44(1), (4) and 46(1)). - is_on_income_related_esa: [false, false, false] + esa_income_eligible: true + esa_income: 5000 + claimant_or_partner_esa_income: 0 + # Only the claimant's own award closes; the lodger's own claim passes. + is_on_income_related_esa: [false, true, false] # The Income Support gate reads the screened award (s.124(1)(h)). @@ -1248,3 +1246,32 @@ # Without the work test the partner's award would count as income-related # ESA of the other member of the couple and bar the claim. income_support_eligible: true + +- name: Issue 2395 outside closed IS report leaves the couple ESA award open + period: 2027 + absolute_error_margin: 0 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + esa_income_reported: 5_000 + partner: + age: 30 + is_claimant_or_partner: true + outside: + age: 40 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + income_support_reported: 1_000 + benunits: + unit: + members: [claimant, partner, outside] + esa_income_assessable_capital: 0 + households: + household: + members: [claimant, partner, outside] + output: + legacy_benefits_closed: false + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml index ce4b1ee145..daeb1b21c8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml @@ -445,7 +445,7 @@ # The lodger's own claim passes, so they are on it; the claimant is not. is_on_income_based_jsa: [false, true] -- name: A non-dependent adult's own JSA ends once the family's legacy benefits have closed +- name: A non-dependent adult's own JSA remains open when only the couple's legacy benefits close period: 2025 absolute_error_margin: 0 input: @@ -471,15 +471,13 @@ members: [claimant, lodger, child] output: legacy_benefits_closed: true - # The lodger does not work, so the screen fails only on the closure. + # The separate non-working claim remains open under the annual proxy. jsa_remunerative_work_hours: [0, 0, 0] - jsa_income_eligible: false - jsa_income: 0 - # The model ends every member's award once the family's legacy benefits - # close, the lodger's own claim included, so nobody is on it. That is a - # model simplification: in law the migration notice reaches only the - # claimant and partner (SI 2014/1230 regs 44(1), (4) and 46(1)). - is_on_income_based_jsa: [false, false, false] + jsa_income_eligible: true + jsa_income: 3000 + claimant_or_partner_jsa_income: 0 + # Only the claimant's own award closes; the lodger's own claim passes. + is_on_income_based_jsa: [false, true, false] # The Income Support gate reads the screened award (s.124(1)(f)). @@ -508,3 +506,38 @@ jsa_income_eligible: false jsa_income: 0 income_support_eligible: true + +- name: Issue 2395 couple closure leaves a separate outside JSA award open + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 30 + is_claimant_or_partner: true + is_parent: true + child_tax_credit_reported: 2_000 + partner: + age: 30 + is_claimant_or_partner: true + is_parent: true + outside: + age: 40 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 5_000 + child: + age: 5 + is_claimant_or_partner: false + benunits: + unit: + members: [claimant, partner, outside, child] + jsa_income_assessable_capital: 0 + households: + household: + members: [claimant, partner, outside, child] + output: + legacy_benefits_closed: true + jsa_income: 5_000 + claimant_or_partner_jsa_income: 0 + is_on_income_based_jsa: [false, false, true, false] 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 new file mode 100644 index 0000000000..2f4f43bbfb --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/person_legacy_benefits_closed.yaml @@ -0,0 +1,147 @@ +[ + { + "name": "Case 1, outside closed report leaves the couple open.", + "period": 2027, + "input": { + "people": { + "claimant": { + "age": 30, + "is_claimant_or_partner": true, + "esa_income_reported": 5000 + }, + "partner": { + "age": 30, + "is_claimant_or_partner": true + }, + "outside": { + "age": 40, + "is_claimant_or_partner": false, + "income_support_reported": 1000 + } + }, + "benunits": { + "unit": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + }, + "households": { + "household": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + } + }, + "output": { + "person_legacy_benefits_closed": [ + false, + false, + true + ], + "legacy_benefits_closed": false + } + }, + { + "name": "Case 2, couple closure leaves the outside award open.", + "period": 2025, + "input": { + "people": { + "claimant": { + "age": 30, + "is_claimant_or_partner": true, + "child_tax_credit_reported": 2000 + }, + "partner": { + "age": 30, + "is_claimant_or_partner": true + }, + "outside": { + "age": 40, + "is_claimant_or_partner": false, + "esa_income_reported": 5000 + } + }, + "benunits": { + "unit": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + }, + "households": { + "household": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + } + }, + "output": { + "person_legacy_benefits_closed": [ + true, + true, + false + ], + "legacy_benefits_closed": true + } + }, + { + "name": "Case 3, outside Pension Credit age exception uses their own input.", + "period": 2027, + "input": { + "people": { + "claimant": { + "age": 30, + "is_claimant_or_partner": true + }, + "partner": { + "age": 30, + "is_claimant_or_partner": true + }, + "outside": { + "age": 40, + "is_claimant_or_partner": false, + "income_support_reported": 1000, + "has_attained_state_pension_credit_qualifying_age": true, + "is_SP_age": false + } + }, + "benunits": { + "unit": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + }, + "households": { + "household": { + "members": [ + "claimant", + "partner", + "outside" + ] + } + } + }, + "output": { + "person_legacy_benefits_closed": [ + false, + false, + false + ], + "legacy_benefits_closed": false + } + } +] diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index ef44e02ecc..7850e3a79a 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -61,9 +61,8 @@ (esa_income_eligible and jsa_income_eligible, read independently from the law in legacy_award_work_reference). The screens also end the award once the family's legacy benefits have closed (legacy_benefits_closed, #2119), - and the model then ends every member's status too; no family here reaches - that in 2025, when Income Support and income-based JSA are open and - nobody reports tax credits; + for their own award only; no family here reaches that in 2025, when + Income Support and income-based JSA are open and nobody reports tax credits; - a person is on an award exactly when they are the payee of their couple's positive award (the claimant or partner who reports it, or the claimant) or, for anyone else, when their own claim passes: they are not engaged in diff --git a/policyengine_uk/tests/test_legacy_award_work_properties.py b/policyengine_uk/tests/test_legacy_award_work_properties.py index 2e2ecc3f14..1779a07305 100644 --- a/policyengine_uk/tests/test_legacy_award_work_properties.py +++ b/policyengine_uk/tests/test_legacy_award_work_properties.py @@ -23,10 +23,9 @@ JSA regulations have no exception for carers doing unrelated paid work); - when the claimant or partner reports the award, adding a member outside the family (a non-dependent adult, with any award and any work) never - changes either screen's work and capital tests. It can change a screen - only through the closure, which the model reads from every member's - reports: the screen with them is the screen without them, while their - report has not closed the family's legacy benefits. + changes an open couple claim's screen or award. If the couple's own + reports have closed, an outside member's separate open claim may pass + the aggregate screen without reopening the couple's award. Adults are employees (pay within the basic rate band), self-employed (a profit within the Class 4 upper limit) or employees with a self-employment @@ -318,11 +317,20 @@ 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): - # The closure reads every member's reports (#2119). - closes = legacy_benefits_closed(couple, [extras[i]], parameters) - if closes and not legacy_benefits_closed(couple, [], parameters): - event("an outside member's report closes the family's") - assert eligible[n + i] == (eligible[i] and not closes), ( + closed = legacy_benefits_closed(couple, [], 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. + if not closed: + assert eligible[n + i] == eligible[i], ( + variable, + units[i], + extras[i], + ) + couple_award = sim.calculate( + "claimant_or_partner_" + reported.removesuffix("_reported"), year + ) + assert couple_award[n + i] == couple_award[i], ( variable, units[i], extras[i], diff --git a/policyengine_uk/tests/test_legacy_closure_scope.py b/policyengine_uk/tests/test_legacy_closure_scope.py new file mode 100644 index 0000000000..4126a21ddc --- /dev/null +++ b/policyengine_uk/tests/test_legacy_closure_scope.py @@ -0,0 +1,190 @@ +"""Ownership of the model's annual legacy-closure proxy (#2395). + +These fixtures keep the existing annual dates and eligibility screens. They +do not represent actual migration notices, deadlines, or statutory run-ons. +The separate adult's award is conditional on their own zero-capital, +non-working claim; sharing a benefit unit does not make it the couple's. +""" + +import pytest + +from policyengine_uk import Simulation + + +def household(year, claimant=None, partner=None, outside=None, child=False): + members = { + "claimant": {"age": 30, "is_claimant_or_partner": True}, + "partner": {"age": 30, "is_claimant_or_partner": True}, + "outside": { + "age": 40, + "is_claimant_or_partner": False, + "current_education": "NOT_IN_EDUCATION", + }, + } + for name, changes in ( + ("claimant", claimant), + ("partner", partner), + ("outside", outside), + ): + members[name].update(changes or {}) + if child: + members["claimant"]["is_parent"] = True + members["partner"]["is_parent"] = True + members["child"] = {"age": 5, "is_claimant_or_partner": False} + return { + "people": { + name: {key: {str(year): value} for key, value in inputs.items()} + for name, inputs in members.items() + }, + "benunits": { + "unit": { + "members": list(members), + "esa_income_assessable_capital": {str(year): 0}, + "jsa_income_assessable_capital": {str(year): 0}, + } + }, + "households": {"household": {"members": list(members)}}, + } + + +@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}} + 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 + assert sim.calculate("esa_income", 2027)[0] == 5_000 + + +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}, + outside={"age": 70, "pension_credit_reported": 1_000}, + ) + ) + assert sim.calculate("legacy_benefits_closed", 2027)[0] + assert sim.calculate("claimant_or_partner_esa_income", 2027)[0] == 0 + + +@pytest.mark.parametrize("award", ["esa_income", "jsa_income"]) +def test_couple_closure_does_not_end_outside_own_award(award): + # Tax credits are closed in 2025, while income-based JSA is still active. + sim = Simulation( + situation=household( + 2025, + claimant={"child_tax_credit_reported": 2_000}, + outside={f"{award}_reported": 5_000}, + child=True, + ) + ) + assert sim.calculate("legacy_benefits_closed", 2025)[0] + assert sim.calculate(award, 2025)[0] == 5_000 + assert sim.calculate(f"claimant_or_partner_{award}", 2025)[0] == 0 + status = ( + "is_on_income_related_esa" + if award == "esa_income" + else "is_on_income_based_jsa" + ) + assert sim.calculate(status, 2025).tolist() == [False, False, True, False] + + +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}, + ) + ) + assert not sim.calculate("legacy_benefits_closed", 2027)[0] + assert sim.calculate("esa_income", 2027)[0] == 5_000 + assert sim.calculate("is_on_income_related_esa", 2027).tolist() == [ + True, + False, + False, + ] + + +def test_preclosure_report_does_not_end_outside_own_award(): + sim = Simulation( + situation=household( + 2024, + claimant={"child_tax_credit_reported": 2_000}, + outside={"esa_income_reported": 5_000}, + child=True, + ) + ) + assert not sim.calculate("legacy_benefits_closed", 2024)[0] + assert sim.calculate("esa_income", 2024)[0] == 5_000 + assert sim.calculate("is_on_income_related_esa", 2024).tolist() == [ + False, + False, + True, + False, + ] + + +def test_existing_pension_age_exception_is_preserved(): + sim = Simulation( + situation=household( + 2027, + claimant={"age": 70, "income_support_reported": 1_000}, + partner={"age": 70}, + ) + ) + assert not sim.calculate("legacy_benefits_closed", 2027)[0] + + +@pytest.mark.parametrize("award", ["esa_income", "jsa_income"]) +@pytest.mark.parametrize( + "mode", ["formula", "raw_total", "distinct_entered", "nil", "neutralized"] +) +def test_closed_reports_preserve_stored_award_value_contract(award, mode): + inputs = household( + 2025, + claimant={"child_tax_credit_reported": 2_000, f"{award}_reported": 5_000}, + outside={f"{award}_reported": 5_000}, + child=True, + ) + if mode in {"raw_total", "distinct_entered", "nil"}: + entered = {"raw_total": 10_000, "distinct_entered": 7_000, "nil": 0}[mode] + inputs["benunits"]["unit"][award] = {"2025": entered} + sim = Simulation(situation=inputs) + if mode == "neutralized": + sim.tax_benefit_system.neutralize_variable(award) + # Raw report totals retain the held-award path; distinct direct amounts + # remain the couple's. Formula awards instead omit only closed reports. + aggregate, scoped = { + "formula": (5_000, 0), + "raw_total": (10_000, 5_000), + "distinct_entered": (7_000, 7_000), + "nil": (0, 0), + "neutralized": (0, 0), + }[mode] + assert sim.calculate(award, 2025)[0] == aggregate + assert sim.calculate(f"claimant_or_partner_{award}", 2025)[0] == scoped + status = ( + "is_on_income_related_esa" + if award == "esa_income" + else "is_on_income_based_jsa" + ) + assert bool(sim.calculate(status, 2025)[2]) == (mode not in {"nil", "neutralized"}) + + +def test_outside_own_pension_credit_age_input_is_used(): + sim = Simulation( + situation=household( + 2027, + outside={ + "income_support_reported": 1_000, + "has_attained_state_pension_credit_qualifying_age": True, + "is_SP_age": False, + }, + ) + ) + # An explicit qualifying-age input tests the interface mapping, not a + # new assertion about the legal timetable or actual migration notices. + assert not sim.calculate("person_legacy_benefits_closed", 2027)[2] diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index bf136eaf90..2df7693ab5 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -80,8 +80,13 @@ def award_of_members(benunit, period, award, report, award_on_reports, members): reported = person(report, period) reported_total = benunit.sum(reported) members_reported = benunit.sum(reported * members) - award_on_all_reports = award_on_reports(benunit, period, reported_total) - award_on_members_reports = award_on_reports(benunit, period, members_reported) + # Formula awards omit reports whose own award has closed. Keep the raw + # totals above for disable_simulated_benefits and direct-value provenance. + open_reported = reported * ~person("person_legacy_benefits_closed", period) + award_on_all_reports = award_on_reports(benunit, period, benunit.sum(open_reported)) + award_on_members_reports = award_on_reports( + benunit, period, benunit.sum(open_reported * members) + ) # Compare in the precision the award is stored in (float32), so the # formula's own award always matches the award recomputed here. stored = stored_award.dtype diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 21b8f01b5d..cee5d2c28a 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -53,28 +53,14 @@ def own_report_is_paid(person, period, award, works, capital_limit): cannot stand in for theirs, because when the claimant or partner reports an award it tests only them. - The family's legacy benefits must not have closed - (legacy_benefits_closed). The model ends every award in the benefit unit - once they close, and so the benefit unit's award; this person's status - follows it. That is a simplification: in law the migration notice goes - to the claimant and partner (SI 2014/1230 reg 44(1), (4)) and ends their - awards (reg 46(1)), not the award of a member who claims in their own - right. The benefit unit's award must also be in payment: positive, or - nil while the benefit unit's screen fails and the award is not - neutralised. When the award on this person's report alone is positive - and the family's legacy benefits are open, that screen fails only when - the claimant or partner reports an award and fails the work tests, - which leaves this person's own claim standing; on the formula path that - is the only way the award is nil. A zero cannot say why it is zero, so - a reform that replaces the award with nil, or a nil award entered - directly, removes the status only while the screen passes. A reform - that neutralises the benefit always removes it. - - Apart from the closure, on the formula path another member's report or - work never changes this. Their own tests read only their own work, - their own report and the household's capital, and when the award on - their report alone is positive, the benefit unit's award is positive - exactly when its screen passes.""" + 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: + 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 + existing value-based override rules and shared-capital proxy are retained. + """ benunit = person.benunit own_award = ( ~works @@ -84,10 +70,7 @@ def own_report_is_paid(person, period, award, works, capital_limit): > benunit(f"{award}_tariff_income", period) ) ) - # The screen also fails once the family's legacy benefits have closed; - # the model then ends every member's award (a simplification: SI - # 2014/1230 reg 46(1) ends only the claimant's and partner's). - closed = benunit("legacy_benefits_closed", period) + closed = person("person_legacy_benefits_closed", period) removed = person.simulation.tax_benefit_system.get_variable(award).is_neutralized paid = benunit(award, period) > 0 screen_fails = ~benunit(f"{award}_eligible", period) diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index 0f6d2d37fa..7b723bf4c6 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -23,5 +23,9 @@ class esa_income(Variable): unit = GBP def formula(benunit, period, parameters): - reported_award = add(benunit, period, ["esa_income_reported"]) + person = benunit.members + reported_award = benunit.sum( + person("esa_income_reported", period) + * ~person("person_legacy_benefits_closed", period) + ) return income_related_esa_award(benunit, period, reported_award) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 031fa6c671..c071f77373 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -26,8 +26,9 @@ class esa_income_eligible(Variable): "in it, so when the claimant or partner reports, another member's " "report is paid or not with their claim. Whether that member is on " "the award themselves (is_on_income_related_esa) is tested on their " - "own claim. The award also ends once one of the family's legacy " - "benefits has closed (legacy_benefits_closed). This is not a full " + "own claim. Reports whose owner has closed legacy benefits are left " + "out before choosing candidates (person_legacy_benefits_closed). " + "This is not a full " "entitlement model." ) definition_period = YEAR @@ -43,7 +44,9 @@ def formula(benunit, period, parameters): ESA = parameters(period).gov.dwp.ESA.income person = benunit.members capital = benunit("esa_income_assessable_capital", period) - reports_award = person("esa_income_reported", period) > 0 + reports_award = (person("esa_income_reported", period) > 0) & ~person( + "person_legacy_benefits_closed", period + ) claimant_or_partner = person("is_claimant_or_partner", period) # The candidate claimants: the claimant or partner who reports the # award, or, if neither does, whoever in the benefit unit does. @@ -65,11 +68,4 @@ def formula(benunit, period, parameters): (benunit.project(benunit.sum(works_as_partner)) - works_as_partner) > 0 ) claimant = candidate & ~claimant_works & ~other_member_works - # Once one of the family's legacy benefits has closed, DWP's - # migration notice ends this award too: a Universal Credit claim - # brings the abolition of income-related ESA (Welfare Reform Act - # 2012 s. 33(1)) into force for the claimant (see SI 2025/1148 - # art. 3A(3)), and a family that does not claim loses the award at the - # notice's deadline (SI 2014/1230 reg 46(1)(a)). - closed = benunit("legacy_benefits_closed", period) - return benunit.any(claimant) & ~closed & (capital <= ESA.capital.limit) + return benunit.any(claimant) & (capital <= ESA.capital.limit) diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py index 8e91573713..1b701f64a5 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py @@ -25,10 +25,9 @@ class is_on_income_based_jsa(Variable): "positive, the report exceeding the tariff income from the benefit " "unit's capital, within the capital limit. On the formula path the " "claimant's or partner's claim and work never change this. Income-based " - "JSA must be open (gov.dwp.JSA.income.active), the family's legacy " - "benefits must not have closed (legacy_benefits_closed; the model ends " - "every member's award then, where SI 2014/1230 reg 46(1) ends only the " - "claimant's and partner's), and the benefit unit's modelled award must " + "JSA must be open (gov.dwp.JSA.income.active), the legacy " + "benefits for this person's own award must remain open " + "(person_legacy_benefits_closed), and the benefit unit's modelled award must " "be in payment: positive, or nil while jsa_income_eligible fails and " "jsa_income is not neutralised. On the formula path that nil comes only " "from the claimant's or partner's own claim failing the screen. " 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 0df7276e1a..cd3c0173d5 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 @@ -24,10 +24,9 @@ class is_on_income_related_esa(Variable): "para 6(1)(e)), and the award on their own report alone is positive, " "the report exceeding the tariff income from the benefit unit's " "capital, within the capital limit. On the formula path the claimant's " - "or partner's claim and work never change this. The family's legacy " - "benefits must not have closed (legacy_benefits_closed; the model ends " - "every member's award then, where SI 2014/1230 reg 46(1) ends only the " - "claimant's and partner's), and the benefit unit's modelled award must " + "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 " "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 claimant's or partner's own claim failing the screen. " diff --git a/policyengine_uk/variables/gov/dwp/jsa_income.py b/policyengine_uk/variables/gov/dwp/jsa_income.py index beae7d3f99..34a3c0e2a5 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income.py @@ -28,5 +28,9 @@ class jsa_income(Variable): def formula(benunit, period, parameters): if not parameters(period).gov.dwp.JSA.income.active: return benunit.empty_array() - reported_award = add(benunit, period, ["jsa_income_reported"]) + person = benunit.members + reported_award = benunit.sum( + person("jsa_income_reported", period) + * ~person("person_legacy_benefits_closed", period) + ) return income_related_jsa_award(benunit, period, reported_award) diff --git a/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py b/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py index 505d1c47b1..8d26015795 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py @@ -31,8 +31,8 @@ class jsa_income_eligible(Variable): "reports, another member's report is paid or not with their claim. " "Whether that member is on the award themselves " "(is_on_income_based_jsa) is tested on their own claim. The award " - "also ends once one of the family's legacy benefits has closed " - "(legacy_benefits_closed). This is not a full entitlement model." + "excludes reports whose owner has closed legacy benefits before choosing " + "candidates (person_legacy_benefits_closed). This is not a full entitlement model." ) definition_period = YEAR reference = ( @@ -48,7 +48,9 @@ def formula(benunit, period, parameters): JSA = parameters(period).gov.dwp.JSA person = benunit.members capital = benunit("jsa_income_assessable_capital", period) - reports_award = person("jsa_income_reported", period) > 0 + reports_award = (person("jsa_income_reported", period) > 0) & ~person( + "person_legacy_benefits_closed", period + ) claimant_or_partner = person("is_claimant_or_partner", period) # The candidate claimants: the claimant or partner who reports the # award, or, if neither does, whoever in the benefit unit does. @@ -67,11 +69,4 @@ def formula(benunit, period, parameters): (benunit.project(benunit.sum(works_as_partner)) - works_as_partner) > 0 ) claimant = candidate & ~works_as_claimant & ~other_member_works - # Once one of the family's legacy benefits has closed, DWP's - # migration notice ends this award too: a Universal Credit claim - # brings the abolition of income-based JSA (Welfare Reform Act - # 2012 s. 33(1)) into force for the claimant (see SI 2025/1148 - # art. 3A(3)), and a family that does not claim loses the award at the - # notice's deadline (SI 2014/1230 reg 46(1)(a)). - closed = benunit("legacy_benefits_closed", period) - return benunit.any(claimant) & ~closed & (capital <= JSA.income.capital.limit) + return benunit.any(claimant) & (capital <= JSA.income.capital.limit) 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 fec4ccaa0d..51e8d1889f 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,13 +1,52 @@ from policyengine_uk.model_api import * +def legacy_award_is_closed(reports, pension_age_route, mixed_age_couple, dwp): + """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. + """ + on_pension_credit = reports("pension_credit_reported") + # A tax credit closure notice sent to Pension Credit, not Universal + # Credit, a family on Pension Credit, or one on Child Tax Credit but + # not Working Tax Credit that meets the Pension Credit age conditions + # (single, both pension age, or a protected mixed-age couple; SI + # 2019/167 art. 3A(1)). Every other tax credit family, including a + # pension-age one on Working Tax Credit (SI 2014/1230 reg 60A), got a + # Universal Credit migration notice (reg 44). + pension_credit_route = on_pension_credit | ( + pension_age_route & ~reports("working_tax_credit_reported") + ) + tax_credits_closed = ( + reports("child_tax_credit_reported", "working_tax_credit_reported") + & (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) + return tax_credits_closed | ( + (income_support_closed | jsa_income_closed) + & ~on_pension_credit + & ~wholly_pension_age + ) + + class legacy_benefits_closed(Variable): value_type = bool entity = BenUnit label = "Moved off legacy benefits onto the Universal Credit route" documentation = ( - "Whether DWP has moved this family off its legacy means-tested " - "benefits towards Universal Credit because one it reports has closed: " + "Annual proxy for whether DWP has moved the claimant and partner off " + "their legacy means-tested " + "benefits towards Universal Credit because one they report has closed: " "tax credits from 6 April 2025, and Income Support and income-based " "Jobseeker's Allowance from 1 April 2026 (their active parameters). " "The migration notice covers all the family's legacy awards, so they " @@ -19,7 +58,9 @@ class legacy_benefits_closed(Variable): "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." + "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." ) definition_period = YEAR reference = ( @@ -33,39 +74,16 @@ class legacy_benefits_closed(Variable): ) def formula(benunit, period, parameters): - dwp = parameters(period).gov.dwp + person = benunit.members + owners = person("is_claimant_or_partner", period) def reports(*variables): - return add(benunit, period, list(variables)) > 0 + amounts = sum(person(name, period) for name in variables) + return benunit.sum(amounts * owners) > 0 - on_pension_credit = reports("pension_credit_reported") - pension_age_route = benunit("meets_pension_credit_age_conditions", period) - # A tax credit closure notice sent to Pension Credit, not Universal - # Credit, a family on Pension Credit, or one on Child Tax Credit but - # not Working Tax Credit that meets the Pension Credit age conditions - # (single, both pension age, or a protected mixed-age couple; SI - # 2019/167 art. 3A(1)). Every other tax credit family, including a - # pension-age one on Working Tax Credit (SI 2014/1230 reg 60A), got a - # Universal Credit migration notice (reg 44). - pension_credit_route = on_pension_credit | ( - pension_age_route & ~reports("working_tax_credit_reported") - ) - tax_credits_closed = ( - reports("child_tax_credit_reported", "working_tax_credit_reported") - & (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 & ~benunit("is_mixed_age_couple", period) - income_support_closed = reports("income_support_reported") & ( - not dwp.income_support.active - ) - jsa_income_closed = reports("jsa_income_reported") & (not dwp.JSA.income.active) - return tax_credits_closed | ( - (income_support_closed | jsa_income_closed) - & ~on_pension_credit - & ~wholly_pension_age + return legacy_award_is_closed( + reports, + benunit("meets_pension_credit_age_conditions", period), + benunit("is_mixed_age_couple", period), + parameters(period).gov.dwp, ) 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 new file mode 100644 index 0000000000..9276abf287 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/person_legacy_benefits_closed.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.legacy_benefits_closed import ( + legacy_award_is_closed, +) + + +class person_legacy_benefits_closed(Variable): + value_type = bool + entity = Person + label = "Legacy benefits closed for this person's award" + documentation = ( + "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 " + "couple's award, and the couple's reports do not close theirs. No new " + "notice, deadline, run-on or legal relationship inference is made." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/44", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", + ) + + def formula(person, period, parameters): + def reports(*variables): + return sum(person(name, period) for name in variables) > 0 + + pension_age = person("has_attained_state_pension_credit_qualifying_age", period) + own_closed = legacy_award_is_closed( + reports, pension_age, pension_age & False, parameters(period).gov.dwp + ) + return where( + person("is_claimant_or_partner", period), + person.benunit("legacy_benefits_closed", period), + own_closed, + ) From 02f80240fb10384956cc6dcb1d276c45302491ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:24:04 +0100 Subject: [PATCH 2/2] Fix issues from review: preserve reported legacy awards --- changelog.d/2395.fixed.md | 2 + .../disable_simulated_benefits.py | 3 +- .../tests/test_disable_simulated_benefits.py | 44 ++++++++++++++----- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/changelog.d/2395.fixed.md b/changelog.d/2395.fixed.md index 7d3dce9d6d..d00bf88801 100644 --- a/changelog.d/2395.fixed.md +++ b/changelog.d/2395.fixed.md @@ -1 +1,3 @@ Keep annual legacy-benefit closure scoped to the claimant and partner or a separate member's own award, so unrelated reports cannot close or shield each other's awards. Preserve the existing migration-date proxy and bounded work and capital tests. + +Keep each person's legacy closure false alongside the family closure when the report-only benefit reform holds migration off. diff --git a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py index fc893866ab..15b042658e 100644 --- a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py +++ b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py @@ -72,7 +72,8 @@ # 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). -NOT_MOVED = ["legacy_benefits_closed"] +# Hold the family closure and each separate member's own closure. +NOT_MOVED = ["legacy_benefits_closed", "person_legacy_benefits_closed"] # 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/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 079089376a..1becfb4bc7 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -313,17 +313,31 @@ def test_the_council_tax_reduction_passport(reformed, year): @pytest.mark.parametrize("year", YEARS) def test_no_family_is_moved_off_its_legacy_benefits(reformed, unreformed, year): - # Income-based JSA and Income Support close from April 2026, and the - # model then moves a working-age family reporting either, unless it is on - # Pension Credit, off all its legacy benefits (legacy_benefits_closed), so - # their awards end. Every family here reports one of them. The reform pays every reported award in every - # year, so it holds the move off; otherwise an adult outside the couple - # would stop being on the ESA the reform still pays them, and on its JSA - # while income-based JSA is open (test_each_person_is_on_their_own_award). + # The annual closure proxy follows each award's owners (SI 2014/1230 + # regs 44 and 46). Only the excluded adult reports a closing benefit in + # excluded_adult_no_award and excluded_income_support, so their reports + # never close the claimant's award. The other claimants report Income + # Support, which closes from 2026 under the existing model parameters. moved = year > DATASET_YEAR - assert by_family(unreformed, "legacy_benefits_closed", year) == dict.fromkeys( - NAMES, moved - ) + assert by_family(unreformed, "legacy_benefits_closed", year) == { + "excluded_adult": moved, + "excluded_adult_no_award": False, + "own_esa": moved, + "own_jsa": moved, + "own_esa_over_tariff": moved, + "excluded_income_support": False, + } + assert by_person(unreformed, "person_legacy_benefits_closed", year) == { + "excluded_adult_0": moved, + "excluded_adult_1": moved, + "excluded_adult_no_award_0": False, + "excluded_adult_no_award_1": moved, + "own_esa_0": moved, + "own_jsa_0": moved, + "own_esa_over_tariff_0": moved, + "excluded_income_support_0": False, + "excluded_income_support_1": moved, + } assert by_family(reformed, "legacy_benefits_closed", year) == dict.fromkeys( NAMES, False ) @@ -792,3 +806,13 @@ def member(age, claimant_or_partner_in_first_year, **reported): == claimant_or_partner_esa ) assert simulation.calculate("income_support", year)[0] == income_support + + +@pytest.mark.parametrize("year", range(DATASET_YEAR, DATASET_YEAR + YEARS_IN_FUTURE)) +def test_no_person_is_moved_off_their_legacy_benefits(reformed, year): + # The reform pays reported awards for all ten years, including an adult's + # own award outside the couple. Both award owners' closure controls must + # remain false; holding only the claimant's closure leaves that adult's + # reported ESA in household income while incorrectly ending their status. + closed = by_person(reformed, "person_legacy_benefits_closed", year) + assert closed == dict.fromkeys(closed, False)