From 5eef48384bcf56838d0f64b6c72657c75feb1dae Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 18:19:05 -0400 Subject: [PATCH 01/11] Add claimant-or-partner legacy award variables claimant_or_partner_esa_income and claimant_or_partner_jsa_income are the income-related awards on the claimant's and partner's reported amounts, after the same capital test as esa_income and jsa_income (the income_related_esa_award and income_related_jsa_award helpers, byte-identical to the copies on #2013 and #2025). A directly entered esa_income or jsa_income is taken to be the claimant's or partner's. is_on_income_related_esa, is_on_income_based_jsa and is_on_income_support say whether the award is payable to a person (HB Regs 2006 reg 2(1), (3), (3A)): the claimant and partner through their couple's award, anyone else through an award they report themselves. Co-Authored-By: Claude Opus 5.5 --- .../gov/dwp/claimant_or_partner_esa_income.py | 37 +++++++++++++++++++ .../gov/dwp/claimant_or_partner_jsa_income.py | 37 +++++++++++++++++++ .../gov/dwp/is_on_income_based_jsa.py | 28 ++++++++++++++ .../gov/dwp/is_on_income_related_esa.py | 27 ++++++++++++++ .../variables/gov/dwp/is_on_income_support.py | 30 +++++++++++++++ .../variables/gov/dwp/jsa_income.py | 18 ++++++--- 6 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py create mode 100644 policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py create mode 100644 policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py create mode 100644 policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py create mode 100644 policyengine_uk/variables/gov/dwp/is_on_income_support.py diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py new file mode 100644 index 0000000000..f7be930d63 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award + + +class claimant_or_partner_esa_income(Variable): + value_type = float + entity = BenUnit + label = "income-related ESA of the claimant or partner" + documentation = ( + "Income-related ESA on the claimant's and partner's reported awards, " + "after the same capital test as esa_income. Means tests and passports " + "that ask whether the claimant or partner is on income-related ESA " + "read this. Another member of the benefit unit who is neither the " + "claimant, the partner nor a child or young person they are " + "responsible for (for example a non-dependent adult) claims in their " + "own right, so their award is left out here; it still counts in " + "esa_income and so in household income. When esa_income is entered " + "directly rather than calculated from reported awards, the entered " + "award is taken to be the claimant's or partner's." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/136", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/25", + "https://www.legislation.gov.uk/uksi/2008/794/regulation/83", + ) + + def formula(benunit, period, parameters): + if "esa_income" in benunit.simulation.input_variables: + return benunit("esa_income", period) + person = benunit.members + reported_award = benunit.sum( + person("esa_income_reported", period) + * person("is_claimant_or_partner", period) + ) + return income_related_esa_award(benunit, period, reported_award) diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py new file mode 100644 index 0000000000..816ce47d2c --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.jsa_income import income_related_jsa_award + + +class claimant_or_partner_jsa_income(Variable): + value_type = float + entity = BenUnit + label = "income-based JSA of the claimant or partner" + documentation = ( + "Income-based JSA on the claimant's and partner's reported awards, " + "after the same capital test as jsa_income. Means tests and passports " + "that ask whether the claimant or partner is on income-based JSA " + "read this. Another member of the benefit unit who is neither the " + "claimant, the partner nor a child or young person they are " + "responsible for (for example a non-dependent adult) claims in their " + "own right, so their award is left out here; it still counts in " + "jsa_income and so in household income. When jsa_income is entered " + "directly rather than calculated from reported awards, the entered " + "award is taken to be the claimant's or partner's." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/136", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/25", + "https://www.legislation.gov.uk/uksi/1996/207/regulation/88", + ) + + def formula(benunit, period, parameters): + if "jsa_income" in benunit.simulation.input_variables: + return benunit("jsa_income", period) + person = benunit.members + reported_award = benunit.sum( + person("jsa_income_reported", period) + * person("is_claimant_or_partner", period) + ) + return income_related_jsa_award(benunit, period, reported_award) 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 new file mode 100644 index 0000000000..98a682ac8f --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class is_on_income_based_jsa(Variable): + value_type = bool + entity = Person + label = "on income-based JSA" + documentation = ( + "Whether an income-based jobseeker's allowance is payable to this " + "person (HB Regs 2006 reg 2(3); the council tax reduction schemes use " + "the same definition). A couple's award covers both partners, and the " + "model treats the claimant and the partner as both on it when their " + "award (claimant_or_partner_jsa_income) is positive. Any other member " + "of the benefit unit, such as a non-dependent adult, claims in their " + "own right and is on it only if they report an award themselves while " + "income-based JSA is in payment." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/2", + ) + + def formula(person, period, parameters): + active = parameters(period).gov.dwp.JSA.income.active + couple_award = person.benunit("claimant_or_partner_jsa_income", period) > 0 + own_award = active & (person("jsa_income_reported", period) > 0) + return where(person("is_claimant_or_partner", period), couple_award, own_award) 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 new file mode 100644 index 0000000000..6435ab9037 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class is_on_income_related_esa(Variable): + value_type = bool + entity = Person + label = "on income-related ESA" + documentation = ( + "Whether an income-related employment and support allowance is " + "payable to this person (HB Regs 2006 reg 2(3A); the council tax " + "reduction schemes use the same definition). A couple's award covers " + "both partners, and the model treats the claimant and the partner as " + "both on it when their award (claimant_or_partner_esa_income) is " + "positive. Any other member of the benefit unit, such as a " + "non-dependent adult, claims in their own right and is on it only if " + "they report an award themselves." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/2", + ) + + def formula(person, period, parameters): + couple_award = person.benunit("claimant_or_partner_esa_income", period) > 0 + own_award = person("esa_income_reported", period) > 0 + return where(person("is_claimant_or_partner", period), couple_award, own_award) diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_support.py b/policyengine_uk/variables/gov/dwp/is_on_income_support.py new file mode 100644 index 0000000000..8ba5563b70 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_on_income_support.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class is_on_income_support(Variable): + value_type = bool + entity = Person + label = "on Income Support" + documentation = ( + 'Whether this person is in receipt of Income Support ("person on ' + 'income support", HB Regs 2006 reg 2(1); the council tax reduction ' + "schemes use the same definition). Income Support is calculated for " + "the claimant's family and needs the claimant's or partner's own " + "award (income_support_eligible). A couple's award covers both " + "partners, and the model treats the claimant and the partner as both " + "on it when it is positive. Any other member of the benefit unit, " + "such as a non-dependent adult, claims in their own right and is on " + "it only if they report an award themselves while Income Support is " + "in payment." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/2", + ) + + def formula(person, period, parameters): + active = parameters(period).gov.dwp.income_support.active + couple_award = person.benunit("income_support", period) > 0 + own_award = active & (person("income_support_reported", period) > 0) + return where(person("is_claimant_or_partner", period), couple_award, own_award) diff --git a/policyengine_uk/variables/gov/dwp/jsa_income.py b/policyengine_uk/variables/gov/dwp/jsa_income.py index 96e91a94f6..c584664475 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income.py @@ -1,6 +1,18 @@ from policyengine_uk.model_api import * +def income_related_jsa_award(benunit, period, reported_award): + """Income-based JSA paid on a reported award: the award less tariff + income from capital, or nothing if the scheme is not active or the + benefit unit fails the capital test (jsa_income_eligible).""" + JSA = benunit.simulation.tax_benefit_system.parameters(period).gov.dwp.JSA + if not JSA.income.active: + return benunit.empty_array() + tariff_income = benunit("jsa_income_tariff_income", period) + eligible = benunit("jsa_income_eligible", period) + return where(eligible, max_(0, reported_award - tariff_income), 0) + + class jsa_income(Variable): value_type = float entity = BenUnit @@ -13,9 +25,5 @@ class jsa_income(Variable): unit = GBP 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"]) - tariff_income = benunit("jsa_income_tariff_income", period) - eligible = benunit("jsa_income_eligible", period) - return where(eligible, max_(0, reported_award - tariff_income), 0) + return income_related_jsa_award(benunit, period, reported_award) From 670fc31ab6f23e17d83facf91ffb0e6fc9d70264 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 18:19:05 -0400 Subject: [PATCH 02/11] Count only the claimant's and partner's legacy awards in means tests and passports esa_income and jsa_income sum every benefit-unit member's reported award, so a member who is neither the claimant, the partner nor their dependant (for example a non-dependent adult) fed means tests and passports that the law ties to the claimant and partner: - HB and CTR income (SSCBA s.136(1), HB Regs 2006 reg 25(1); CTR (PR) (England) Regs 2012 Sch 1 para 11) and the working-age CTR passport; - the tax credit income test (TCA 2002 s.7(2), SI 2002/2008 reg 4); - Scottish Child Payment (SSI 2020/351 reg 18(e)-(f)); - the benefit cap health exemption (HB Regs 2006 reg 75F(1)(a)); - targeted childcare (SI 2014/2147 reg 2(1)); - would_claim_IS. The CTR non-dependant exemption ("who is on") and the maintenance loan benefits proxy now read the person's own award. Household totals (esa, household_benefits, HBAI, gov_spending, benefit_cap_reduction) are unchanged: another member's award is still household income. Tests: 53 YAML cases computed by hand from the regulations, and Hypothesis properties (adding such a member never changes any reader; the scoped awards are bounded by, and without other awards equal to, the benefit-unit awards; they match a direct reference; the person-level flags follow their definition; Merton's non-dependant deductions are unchanged). Co-Authored-By: Claude Opus 5.5 --- ...cy-award-readers-claimant-partner.added.md | 2 + ...cy-award-readers-claimant-partner.fixed.md | 3 + .../qualifying_benefits.yaml | 8 +- .../claimant_or_partner_awards.yaml | 273 +++++++++++ .../council_tax_reduction_passports.yaml | 196 ++++++++ ...ome_support_take_up_and_person_awards.yaml | 259 ++++++++++ .../means_test_income.yaml | 125 +++++ .../legacy_award_readers/passports.yaml | 458 ++++++++++++++++++ .../test_legacy_award_readers_properties.py | 424 ++++++++++++++++ policyengine_uk/utils/benefit_unit.py | 5 +- .../maintenance_loan_entitled_to_benefits.py | 5 +- .../housing_benefit_applicable_income.py | 9 +- ...is_benefit_cap_exempt_health_disability.py | 5 +- .../gov/dwp/tax_credits_applicable_income.py | 10 +- .../variables/gov/dwp/would_claim_IS.py | 16 +- .../council_tax_reduction/_legacy.py | 9 +- ...council_tax_reduction_applicable_income.py | 9 +- ...reduction_relevant_income_based_benefit.py | 22 +- .../is_scp_eligible.py | 12 +- 19 files changed, 1830 insertions(+), 20 deletions(-) create mode 100644 changelog.d/legacy-award-readers-claimant-partner.added.md create mode 100644 changelog.d/legacy-award-readers-claimant-partner.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/council_tax_reduction_passports.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/means_test_income.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml create mode 100644 policyengine_uk/tests/test_legacy_award_readers_properties.py diff --git a/changelog.d/legacy-award-readers-claimant-partner.added.md b/changelog.d/legacy-award-readers-claimant-partner.added.md new file mode 100644 index 0000000000..242a159a3f --- /dev/null +++ b/changelog.d/legacy-award-readers-claimant-partner.added.md @@ -0,0 +1,2 @@ +- Add `claimant_or_partner_esa_income` and `claimant_or_partner_jsa_income`: the income-related award on the claimant's and partner's reported amounts after the capital test, or a directly entered `esa_income` or `jsa_income`. +- Add `is_on_income_related_esa`, `is_on_income_based_jsa` and `is_on_income_support`: whether the award is payable to the person, through their couple's award for the claimant and partner and through their own report for anyone else. diff --git a/changelog.d/legacy-award-readers-claimant-partner.fixed.md b/changelog.d/legacy-award-readers-claimant-partner.fixed.md new file mode 100644 index 0000000000..12f9282de4 --- /dev/null +++ b/changelog.d/legacy-award-readers-claimant-partner.fixed.md @@ -0,0 +1,3 @@ +- Count only the claimant's and partner's income-related ESA and income-based JSA in Housing Benefit, council tax reduction and tax credit income, and in the passports for working-age council tax reduction, Scottish Child Payment, targeted childcare and the benefit cap. Another member of the benefit unit, such as a non-dependent adult, who reports an award claims in their own right; their award still counts in household income. +- Exempt a council tax reduction non-dependant, and put a student on the maintenance loan schedule for students entitled to benefits, only through their own or their couple's Income Support, income-based JSA or income-related ESA, not another benefit-unit member's. +- Treat a family as claiming Income Support (`would_claim_IS`) only when the claimant or partner reports it. diff --git a/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml b/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml index 7b0297e3ce..2f4a3d6ea3 100644 --- a/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml +++ b/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml @@ -1,6 +1,8 @@ -description: The Department for Education designates these programs as qualifying benefits for the targeted childcare entitlement eligibility (the 2-year-old entitlement) in England. +description: The Department for Education designates these programs as qualifying benefits for the targeted childcare entitlement eligibility (the 2-year-old entitlement) in England. The child's parent must be entitled to one, so income-based JSA and income-related ESA count only as the claimant's or partner's award. metadata: reference: + - title: The Local Authority (Duty to Secure Early Years Provision Free of Charge) Regulations 2014 - regulation 2(1), "eligible child" + href: https://www.legislation.gov.uk/uksi/2014/2147/regulation/2 - title: The Local Authority (Duty to Secure Early Years Provision Free of Charge) (Amendment) Regulations 2018 - part 2.a href: https://www.legislation.gov.uk/uksi/2018/146/regulation/2/made period: year @@ -9,6 +11,6 @@ metadata: values: 2014-01-01: - income_support - - jsa_income - - esa_income + - claimant_or_partner_jsa_income + - claimant_or_partner_esa_income - guarantee_credit \ No newline at end of file diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml new file mode 100644 index 0000000000..80d5ecd3cc --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml @@ -0,0 +1,273 @@ +# claimant_or_partner_esa_income and claimant_or_partner_jsa_income: the +# income-related award on the claimant's and partner's reported amounts, with +# the same capital test as esa_income and jsa_income. "other" is a member who +# is neither the claimant, the partner nor a child or young person they are +# responsible for (an adult, not in education, with an explicit role), so +# their award is their own claim. It still counts in esa_income / jsa_income. +# Capital: tariff income is £1 a week for each £250, or part, above £6,000 +# (ESA Regs 2008 reg 118; JSA Regs 1996 reg 116), times 52 weeks. + +- name: The claimant's own income-related ESA is the claimant-or-partner award + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + esa_income: 3_000 + claimant_or_partner_esa_income: 3_000 + +- name: The partner's income-related ESA is the claimant-or-partner award + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + partner: + age: 38 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + esa_income: 3_000 + claimant_or_partner_esa_income: 3_000 + +- name: Another member's income-related ESA is household income but not the claimant's award + period: 2025 + absolute_error_margin: 0 + 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: 5_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + is_child_or_young_person_for_legacy_benefits: [false, false] + esa_income: 5_000 + claimant_or_partner_esa_income: 0 + +- name: The claimant-or-partner ESA award takes the same tariff income from capital + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 3_000 + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + savings: 10_000 + output: + # (10,000 - 6,000) / 250 = 16 steps; 16 x £1 x 52 = £832. + esa_income_tariff_income: 832 + esa_income: 7_168 # 3,000 + 5,000 - 832 + claimant_or_partner_esa_income: 2_168 # 3,000 - 832 + +- name: Capital above the income-related ESA limit removes the claimant-or-partner award + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + savings: 16_001 + output: + esa_income: 0 + claimant_or_partner_esa_income: 0 + +- name: A directly entered esa_income is the claimant's award whatever another member reports + period: 2025 + absolute_error_margin: 0 + 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: 5_000 + benunits: + benunit: + members: [claimant, other] + esa_income: 3_000 + households: + household: + members: [claimant, other] + output: + claimant_or_partner_esa_income: 3_000 + +- name: A directly entered zero esa_income overrides the claimant's report + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + esa_income: 0 + households: + household: + members: [claimant] + output: + claimant_or_partner_esa_income: 0 + +- name: The claimant's own income-based JSA is the claimant-or-partner award + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + jsa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + jsa_income: 3_000 + claimant_or_partner_jsa_income: 3_000 + +- name: Another member's income-based JSA is household income but not the claimant's award + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + jsa_income: 4_000 + claimant_or_partner_jsa_income: 0 + +- name: The claimant-or-partner JSA award takes the same tariff income from capital + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + jsa_income_reported: 3_000 + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + savings: 7_000 + output: + # (7,000 - 6,000) / 250 = 4 steps; 4 x £1 x 52 = £208. + jsa_income_tariff_income: 208 + jsa_income: 6_792 # 3,000 + 4,000 - 208 + claimant_or_partner_jsa_income: 2_792 # 3,000 - 208 + +- name: No claimant-or-partner JSA award once income-based JSA has closed + period: 2027 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + jsa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + jsa_income: 0 + claimant_or_partner_jsa_income: 0 + +- name: A directly entered jsa_income is the claimant's award whatever another member reports + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + jsa_income: 2_000 + households: + household: + members: [claimant, other] + output: + claimant_or_partner_jsa_income: 2_000 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 new file mode 100644 index 0000000000..ae7f84b186 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/council_tax_reduction_passports.yaml @@ -0,0 +1,196 @@ +# Working-age council tax reduction passport: the applicant or partner is on +# Income Support, income-based JSA or income-related ESA (CTR (Default Scheme) +# (England) Regs 2012, Schedule; the local schemes follow it). A member who is +# neither the applicant, the partner nor a child or young person they are +# responsible for ("other") claims in their own right. + +- name: Another member's income-related ESA does not passport the applicant + period: 2025 + 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: 5_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + council_tax_reduction_relevant_income_based_benefit: false + +- name: The applicant's own income-related ESA passports them + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + council_tax_reduction_relevant_income_based_benefit: true + +- name: Another member's income-based JSA does not passport the applicant + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + council_tax_reduction_relevant_income_based_benefit: false + +- name: The partner's income-based JSA passports the applicant + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + partner: + age: 38 + is_claimant_or_partner: true + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + council_tax_reduction_relevant_income_based_benefit: true + +- name: Another member's Income Support report does not passport the applicant + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + income_support_reported: 4_000 + receives_carer_benefit: true + care_hours: 35 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + income_support: 0 + council_tax_reduction_relevant_income_based_benefit: false + +- name: An entered Income Support award passports the applicant + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + benunits: + benunit: + members: [claimant] + income_support: 1_000 + households: + household: + members: [claimant] + output: + council_tax_reduction_relevant_income_based_benefit: true + +# Non-dependant deductions (Merton's working-age scheme, which follows the +# Default Scheme): no deduction for a non-dependant "who is on" Income Support, +# income-based JSA or income-related ESA. One deduction is made per non- +# dependant benefit unit (the higher of its members'); Merton's lowest band is +# £5.20 a week for a non-dependant not in remunerative work, £270.40 a year. +# The applicant is the eldest, so their benefit unit holds the household head; +# the non-dependant benefit unit is a claimant ("nondep") and another adult +# ("other") who is neither their partner nor their dependant. Universal Credit +# is entered as 0 for that benefit unit so that its no-earnings exemption does +# not apply. + +- name: Another member's ESA does not exempt a non-dependant from the deduction + period: 2027 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 50 + nondep: + age: 30 + is_claimant_or_partner: true + current_education: NOT_IN_EDUCATION + other: + age: 25 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 4_000 + benunits: + applicant_unit: + members: [applicant] + nondep_unit: + members: [nondep, other] + universal_credit: 0 + households: + household: + members: [applicant, nondep, other] + country: ENGLAND + local_authority: MERTON + output: + merton_council_tax_reduction_individual_non_dep_deduction: [0, 270.40, 0] + +- name: A non-dependant's own ESA exempts them but not another adult in their benefit unit + period: 2027 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 50 + nondep: + age: 30 + is_claimant_or_partner: true + current_education: NOT_IN_EDUCATION + esa_income_reported: 4_000 + other: + age: 25 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + applicant_unit: + members: [applicant] + nondep_unit: + members: [nondep, other] + universal_credit: 0 + households: + household: + members: [applicant, nondep, other] + country: ENGLAND + local_authority: MERTON + output: + merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 270.40] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml new file mode 100644 index 0000000000..46698f7f5d --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml @@ -0,0 +1,259 @@ +# would_claim_IS is the family's take-up flag: the claimant or partner +# reports Income Support. An Income Support report from a member who is neither +# the claimant, the partner nor their dependant ("other") is that member's own +# claim. Any report in the simulation turns claims_all_entitled_benefits off. + +- name: Another member's Income Support report is not this family's claim + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + income_support_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + claims_all_entitled_benefits: false + would_claim_IS: false + +- name: The partner's Income Support report is this family's claim + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + partner: + age: 38 + is_claimant_or_partner: true + income_support_reported: 4_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + would_claim_IS: true + +# income_support_assessable_capital gives full household capital to a benefit +# unit on the Income Support claim path when anyone in the household is on it, +# and otherwise shares it by claimants and partners. With no claimant or +# partner reporting Income Support, £10,000 of savings is shared 1:1 between +# the two benefit units. + +- name: Another member's Income Support report does not move household capital to their benefit unit + period: 2025 + 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 + income_support_reported: 4_000 + lodger: + age: 35 + is_claimant_or_partner: true + benunits: + family: + members: [claimant, other] + lodger_unit: + members: [lodger] + households: + household: + members: [claimant, other, lodger] + savings: 10_000 + output: + would_claim_IS: [false, false] + income_support_assessable_capital: [5_000, 5_000] + +# is_on_income_related_esa, is_on_income_based_jsa and is_on_income_support: +# "on" a benefit when it is payable to the person (HB Regs 2006 reg 2(1), +# (3), (3A)). A couple's award covers both partners; another member is on it +# only through an award they report themselves. + +- name: Both partners are on the couple's income-related ESA + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 3_000 + partner: + age: 38 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [claimant, partner, other] + households: + household: + members: [claimant, partner, other] + output: + is_on_income_related_esa: [true, true, false] + +- name: Another member is on their own income-related ESA and the couple is not + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + partner: + age: 38 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant, partner, other] + households: + household: + members: [claimant, partner, other] + output: + is_on_income_related_esa: [false, false, true] + +- name: The claimant's income-related ESA removed by the capital test leaves them off it + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + savings: 16_001 + output: + is_on_income_related_esa: false + +- name: Another member is on their own income-based JSA while it is paid + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + is_on_income_based_jsa: [false, true] + +- name: Nobody is on income-based JSA once it has closed + period: 2027 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + jsa_income_reported: 3_000 + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + is_on_income_based_jsa: [false, false] + +- name: Another member is on their own Income Support and the claimant is not + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + income_support_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + income_support: 0 + is_on_income_support: [false, true] + +- name: The claimant is on an entered Income Support award and another member is not + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [claimant, other] + income_support: 1_000 + households: + household: + members: [claimant, other] + output: + is_on_income_support: [true, false] + +- name: Nobody is on Income Support once it has closed + period: 2027 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + income_support_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + is_on_income_support: [false, false] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/means_test_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/means_test_income.yaml new file mode 100644 index 0000000000..8e4bb2be1a --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/means_test_income.yaml @@ -0,0 +1,125 @@ +# Housing Benefit and council tax reduction count the claimant's (applicant's) +# and partner's income only: SSCBA 1992 s.136(1) and HB Regs 2006 reg 25(1); +# CTR (Prescribed Requirements) (England) Regs 2012 Sch 1 para 11. "other" is +# a member who is neither the claimant, the partner nor a child or young person +# they are responsible for, so an award they report is their own claim and is +# not the claimant's income. +# HB: the model takes the single-person disregard (Sch 4) from all income, so +# with no income of the claimant's own the result is 0. That the claimant's and +# partner's own awards are counted in full is checked in +# test_legacy_award_readers_properties.py, independently of the disregard. +# CTR: no disregard in council_tax_reduction_applicable_income; universal_credit +# is entered as 0 so the award is the only income. + +- name: HB counts no income-related ESA of another member + period: 2025 + 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: 5_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + housing_benefit_applicable_income: 0 # max(0, 0 - 260) + +- name: HB counts no income-based JSA of another member + period: 2025 + 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 + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + housing_benefit_applicable_income: 0 + +- name: CTR counts no income-related ESA of another member + period: 2025 + 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: 5_000 + benunits: + benunit: + members: [claimant, other] + universal_credit: 0 + households: + household: + members: [claimant, other] + output: + council_tax_reduction_applicable_income: 0 + +- name: CTR counts no income-based JSA of another member + period: 2025 + 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 + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + universal_credit: 0 + households: + household: + members: [claimant, other] + output: + council_tax_reduction_applicable_income: 0 + +- name: CTR counts the claimant's own income-related ESA + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 5_000 + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [claimant, other] + universal_credit: 0 + households: + household: + members: [claimant, other] + output: + council_tax_reduction_applicable_income: 5_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml new file mode 100644 index 0000000000..b04004fe15 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml @@ -0,0 +1,458 @@ +# Passports that the law ties to the claimant, the partner, the parent or the +# student. "other" is a member who is neither the claimant, the partner nor a +# child or young person they are responsible for, so an award they report is +# their own claim. + +# Tax credits: no income test while "the person, or either of the persons" +# claiming is entitled to Income Support, income-based JSA or income-related +# ESA (TCA 2002 s.7(2); SI 2002/2008 reg 4). Otherwise the claimant's earnings +# of £10,000 are the applicable income (the £300 disregard only applies to +# step-one income, of which there is none). + +- name: Another member's income-related ESA leaves the tax credit income test in place + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + employment_income: 10_000 + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + tax_credits_applicable_income: 10_000 + +- name: The claimant's own income-related ESA lifts the tax credit income test + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + employment_income: 10_000 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + tax_credits_applicable_income: 0 + +- name: Another member's income-based JSA leaves the tax credit income test in place + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + employment_income: 10_000 + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + households: + household: + members: [claimant, other] + output: + tax_credits_applicable_income: 10_000 + +- name: The partner's income-based JSA lifts the tax credit income test + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + employment_income: 10_000 + partner: + age: 38 + is_claimant_or_partner: true + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + tax_credits_applicable_income: 0 + +# Scottish Child Payment: the individual responsible for the child has been +# awarded a qualifying benefit (SSI 2020/351 reg 18(e)-(f), regs 9, 12 and 14). +# Universal Credit, tax credits and Pension Credit are entered as 0 so the +# income-related award is the only possible qualifying benefit. + +- name: Another member's income-related ESA does not qualify the child for Scottish Child Payment + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 3 + other: + age: 20 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [parent, child, other] + universal_credit: 0 + child_tax_credit: 0 + working_tax_credit: 0 + pension_credit: 0 + households: + household: + members: [parent, child, other] + region: SCOTLAND + output: + is_scp_eligible: [false, false, false] + +- name: The parent's own income-related ESA qualifies the child for Scottish Child Payment + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + esa_income_reported: 5_000 + child: + age: 3 + other: + age: 20 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, child, other] + universal_credit: 0 + child_tax_credit: 0 + working_tax_credit: 0 + pension_credit: 0 + households: + household: + members: [parent, child, other] + region: SCOTLAND + output: + is_scp_eligible: [false, true, false] + +- name: Another member's income-based JSA does not qualify the child for Scottish Child Payment + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 3 + other: + age: 20 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 4_000 + benunits: + benunit: + members: [parent, child, other] + universal_credit: 0 + child_tax_credit: 0 + working_tax_credit: 0 + pension_credit: 0 + households: + household: + members: [parent, child, other] + region: SCOTLAND + output: + is_scp_eligible: [false, false, false] + +- name: The parent's own income-based JSA qualifies the child for Scottish Child Payment + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + jsa_income_reported: 4_000 + child: + age: 3 + other: + age: 20 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, child, other] + universal_credit: 0 + child_tax_credit: 0 + working_tax_credit: 0 + pension_credit: 0 + households: + household: + members: [parent, child, other] + region: SCOTLAND + output: + is_scp_eligible: [false, true, false] + +# Targeted childcare (England's two-year-old offer): the child's parent is +# entitled to a qualifying benefit (SI 2014/2147 reg 2(1), "eligible child"). +# The other routes (UC, CTC and WTC criteria, Guarantee Credit) are switched +# off with inputs. + +- name: Another member's income-related ESA does not qualify the family for targeted childcare + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 2 + other: + age: 20 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [parent, child, other] + guarantee_credit: 0 + meets_universal_credit_criteria_for_targeted_childcare_entitlement: false + meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: false + working_tax_credit_run_on: false + extended_childcare_entitlement_eligible: false + households: + household: + members: [parent, child, other] + country: ENGLAND + output: + targeted_childcare_entitlement_eligible: false + +- name: The parent's own income-related ESA qualifies the family for targeted childcare + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + esa_income_reported: 5_000 + child: + age: 2 + other: + age: 20 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, child, other] + guarantee_credit: 0 + meets_universal_credit_criteria_for_targeted_childcare_entitlement: false + meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: false + working_tax_credit_run_on: false + extended_childcare_entitlement_eligible: false + households: + household: + members: [parent, child, other] + country: ENGLAND + output: + targeted_childcare_entitlement_eligible: true + +- name: Another member's income-based JSA does not qualify the family for targeted childcare + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 2 + other: + age: 20 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 4_000 + benunits: + benunit: + members: [parent, child, other] + guarantee_credit: 0 + meets_universal_credit_criteria_for_targeted_childcare_entitlement: false + meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: false + working_tax_credit_run_on: false + extended_childcare_entitlement_eligible: false + households: + household: + members: [parent, child, other] + country: ENGLAND + output: + targeted_childcare_entitlement_eligible: false + +- name: The parent's own income-based JSA qualifies the family for targeted childcare + period: 2025 + input: + people: + parent: + age: 30 + is_claimant_or_partner: true + jsa_income_reported: 4_000 + child: + age: 2 + other: + age: 20 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, child, other] + guarantee_credit: 0 + meets_universal_credit_criteria_for_targeted_childcare_entitlement: false + meets_child_tax_credit_criteria_for_targeted_childcare_entitlement: false + working_tax_credit_run_on: false + extended_childcare_entitlement_eligible: false + households: + household: + members: [parent, child, other] + country: ENGLAND + output: + targeted_childcare_entitlement_eligible: true + +# Benefit cap: no cap where "the claimant or the claimant's partner" receives +# ESA with the support component (HB Regs 2006 reg 75F(1)(a)). The model +# exempts any income-related ESA of the claimant or partner. + +- name: Another member's income-related ESA does not exempt the family from the benefit cap + period: 2025 + 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: 5_000 + benunits: + benunit: + members: [claimant, other] + working_tax_credit: 0 + households: + household: + members: [claimant, other] + output: + is_benefit_cap_exempt_health_disability: false + +- name: The claimant's own income-related ESA exempts the family from the benefit cap + period: 2025 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 5_000 + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [claimant, other] + working_tax_credit: 0 + households: + household: + members: [claimant, other] + output: + is_benefit_cap_exempt_health_disability: true + +# Maintenance loans: the "students entitled to benefits" schedule is for a +# student who could claim benefits themselves (SI 2011/1986 regs 61(2) and +# 71(1)(h)). The model's proxy reads the student's own income-related ESA, or +# their couple's; a parent's award does not make a student living in the +# parent's benefit unit (neither claimant, partner nor dependant) eligible. + +- name: A parent's income-related ESA does not put a student on the benefits schedule + period: 2025 + input: + people: + parent: + age: 50 + is_claimant_or_partner: true + is_parent: true + esa_income_reported: 5_000 + student: + age: 19 + is_claimant_or_partner: false + current_education: TERTIARY + benunits: + benunit: + members: [parent, student] + households: + household: + members: [parent, student] + country: ENGLAND + output: + is_child_or_young_person_for_legacy_benefits: [false, false] + maintenance_loan_entitled_to_benefits: [true, false] + +- name: A student's own income-related ESA puts them on the benefits schedule + period: 2025 + input: + people: + parent: + age: 50 + is_claimant_or_partner: true + is_parent: true + student: + age: 19 + is_claimant_or_partner: false + current_education: TERTIARY + esa_income_reported: 5_000 + benunits: + benunit: + members: [parent, student] + households: + household: + members: [parent, student] + country: ENGLAND + output: + maintenance_loan_entitled_to_benefits: [true, true] + +- name: A student in a couple on income-related ESA is on the benefits schedule + period: 2025 + input: + people: + student: + age: 22 + is_claimant_or_partner: true + current_education: TERTIARY + partner: + age: 23 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [student, partner] + households: + household: + members: [student, partner] + country: ENGLAND + output: + maintenance_loan_entitled_to_benefits: [true, true] diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py new file mode 100644 index 0000000000..9a5b492c49 --- /dev/null +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -0,0 +1,424 @@ +"""Readers of the legacy income-related awards follow the claimant and partner. + +Income-related ESA, income-based JSA and Income Support are awarded to a +claimant for themselves and their partner. Each means test or passport that +reads them names the claimant (or applicant, or person claiming) and partner, +the child's parent, or the person themselves: + +- Housing Benefit and council tax reduction income: SSCBA 1992 s.136(1), HB + Regs 2006 reg 25(1); CTR (Prescribed Requirements) (England) Regs 2012 + Sch 1 para 11. +- The working-age council tax reduction passport and non-dependant + exemption: CTR (Default Scheme) (England) Regs 2012, Schedule. +- The tax credit income test: TCA 2002 s.7(2), SI 2002/2008 reg 4. +- Scottish Child Payment: SSI 2020/351 reg 18(e)-(f). +- The benefit cap: HB Regs 2006 reg 75F(1)(a). +- Targeted childcare: SI 2014/2147 reg 2(1). +- Maintenance loans for students entitled to benefits: SI 2011/1986 regs + 61(2) and 71(1)(h). + +A member of the benefit unit who is neither the claimant, the partner nor a +child or young person they are responsible for (for example a non-dependent +adult) claims in their own right, so: + +- adding such a member, whatever Income Support, income-based JSA or + income-related ESA they report, never changes any of these readers for the + claimant's family or for its existing members; +- the claimant-or-partner awards are bounded by the benefit-unit awards + (0 <= claimant_or_partner_esa_income <= esa_income, likewise for JSA) and + equal them when no other member reports an award; +- a person is on an award exactly when their couple's award is positive (the + claimant and partner) or they report one themselves (anyone else). + +Roles are given explicitly (is_claimant_or_partner), so the properties test +the readers rather than role inference. Each example builds many families in +one simulation, in separate households and benefit units. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +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). +NON_DEP_YEAR = 2027 + +FAMILY_READERS = [ + "claimant_or_partner_esa_income", + "claimant_or_partner_jsa_income", + "housing_benefit_applicable_income", + "council_tax_reduction_applicable_income", + "council_tax_reduction_relevant_income_based_benefit", + "tax_credits_applicable_income", + "is_benefit_cap_exempt_health_disability", + "targeted_childcare_entitlement_eligible", + "would_claim_IS", + "income_support_eligible", +] +MEMBER_READERS = [ + "is_scp_eligible", + "maintenance_loan_entitled_to_benefits", + "is_on_income_related_esa", + "is_on_income_based_jsa", + "is_on_income_support", +] +AWARDS = st.sampled_from([0, 0, 200, 3_000]) + + +@st.composite +def award_reports(draw): + return { + "esa_income_reported": draw(AWARDS), + "jsa_income_reported": draw(AWARDS), + "income_support_reported": draw(AWARDS), + } + + +@st.composite +def families(draw): + """A claimant, an optional partner and up to two dependants.""" + dependants = [] + for _ in range(draw(st.integers(0, 2))): + if draw(st.booleans()): + dependants.append({"age": draw(st.integers(0, 15))}) + else: + # A qualifying young person: 16-19 in non-advanced education. + dependants.append( + { + "age": draw(st.integers(16, 19)), + "current_education": "UPPER_SECONDARY", + } + ) + eldest_dependant = max([d["age"] for d in dependants], default=0) + adults = [] + for i in range(1 + draw(st.booleans())): + adults.append( + { + "age": draw(st.integers(max(18, eldest_dependant + 16), 60)), + "employment_income": draw(st.sampled_from([0, 0, 8_000, 20_000])), + "is_parent": bool(dependants), + "receives_carer_benefit": draw(st.booleans()), + "care_hours": draw(st.sampled_from([0, 35])), + "current_education": draw( + st.sampled_from(["NOT_IN_EDUCATION", "TERTIARY"]) + ), + **draw(award_reports()), + } + ) + household = { + "country": draw(st.sampled_from(["ENGLAND", "SCOTLAND", "WALES"])), + "savings": draw(st.sampled_from([0, 7_000, 20_000])), + } + return adults, dependants, household + + +@st.composite +def other_members(draw): + """An adult who is neither claimant, partner nor a dependant. + + They are under state pension age and not in education, so a 16 to 19 year + old is not a qualifying young person. They have no income other than the + awards they report. + """ + return { + "age": draw(st.integers(16, 60)), + "current_education": "NOT_IN_EDUCATION", + **draw(award_reports()), + } + + +def situation(units, year=YEAR): + people, benunits, households = {}, {}, {} + for i, (adults, dependants, household, other) in enumerate(units): + members = [(m, True) for m in adults] + [(m, False) for m in dependants] + if other is not None: + members.append((other, False)) + names = [] + for j, (inputs, claimant_or_partner) in enumerate(members): + name = f"p{i}_{j}" + people[name] = {k: {year: v} for k, v in inputs.items()} + people[name]["is_claimant_or_partner"] = {year: claimant_or_partner} + names.append(name) + benunits[f"b{i}"] = {"members": names} + households[f"h{i}"] = { + "members": names, + **{k: {year: v} for k, v in household.items()}, + } + return {"people": people, "benunits": benunits, "households": households} + + +SETTINGS = settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@SETTINGS +@given(st.lists(st.tuples(families(), other_members()), min_size=1, max_size=6)) +def test_other_members_awards_never_change_the_readers(drawn): + without = [(*family, None) for family, _ in drawn] + with_other = [(*family, other) for family, other in drawn] + sim = Simulation(situation=situation(without + with_other)) + flags = sim.calculate("is_claimant_or_partner", YEAR) + dependant = sim.calculate("is_child_or_young_person_for_legacy_benefits", YEAR) + sizes = [len(a) + len(d) + (o is not None) for a, d, _, o in without + with_other] + starts = np.cumsum([0] + sizes[:-1]) + k = len(drawn) + for i in range(k): + # The added member is the last of its benefit unit: neither claimant, + # partner nor a dependant. + last = starts[k + i] + sizes[k + i] - 1 + assert not flags[last] and not dependant[last], drawn[i] + for variable in FAMILY_READERS: + values = sim.calculate(variable, YEAR) + for i in range(k): + assert np.isclose(values[i], values[k + i], atol=0.01), ( + variable, + drawn[i], + ) + for variable in MEMBER_READERS: + values = sim.calculate(variable, YEAR) + for i in range(k): + n = sizes[i] + assert np.array_equal( + values[starts[i] : starts[i] + n], + values[starts[k + i] : starts[k + i] + n], + ), (variable, drawn[i]) + + +@SETTINGS +@given(st.lists(st.tuples(families(), other_members()), min_size=1, max_size=6)) +def test_claimant_or_partner_awards_bounded_by_benefit_unit_awards(drawn): + units = [(*family, other) for family, other in drawn] + sim = Simulation(situation=situation(units)) + for scoped, total, report in [ + ("claimant_or_partner_esa_income", "esa_income", "esa_income_reported"), + ("claimant_or_partner_jsa_income", "jsa_income", "jsa_income_reported"), + ]: + scoped_award = sim.calculate(scoped, YEAR) + total_award = sim.calculate(total, YEAR) + assert (scoped_award >= 0).all() + assert (scoped_award <= total_award + 0.01).all() + for i, (_, _, _, other) in enumerate(units): + if other[report] == 0: + # Nobody else reports this award: the two coincide. + assert np.isclose(scoped_award[i], total_award[i]), (scoped, units[i]) + + +def reference_claimant_or_partner_award(adults, other, household, report, params): + """The claimant-or-partner award read directly from the regulations. + + Capital is the household's savings (the only capital input here), all of + it the benefit unit's, as it is the only one in its household. Tariff + income is £1 a week for each £250 or part above £6,000, and capital above + £16,000 removes the award (ESA Regs 2008 regs 110 and 118; JSA Regs 1996 + regs 107 and 116, as parameterised). + """ + capital_rules = params.capital + capital = household["savings"] + anyone_reports = other[report] > 0 or any(a[report] > 0 for a in adults) + if not anyone_reports or capital > capital_rules.limit: + return 0.0 + steps = np.ceil( + max(0, capital - capital_rules.tariff_income.threshold) + / capital_rules.tariff_income.step + ) + tariff = steps * capital_rules.tariff_income.amount * 52 + return max(0.0, sum(a[report] for a in adults) - tariff) + + +@SETTINGS +@given(st.lists(st.tuples(families(), other_members()), min_size=1, max_size=6)) +def test_claimant_or_partner_awards_match_reference(drawn): + units = [(*family, other) for family, other in drawn] + sim = Simulation(situation=situation(units)) + dwp = sim.tax_benefit_system.parameters(YEAR).gov.dwp + for variable, report, params, active in [ + ("claimant_or_partner_esa_income", "esa_income_reported", dwp.ESA.income, 1), + ( + "claimant_or_partner_jsa_income", + "jsa_income_reported", + dwp.JSA.income, + dwp.JSA.income.active, + ), + ]: + award = sim.calculate(variable, YEAR) + for i, (adults, _, household, other) in enumerate(units): + expected = active * reference_claimant_or_partner_award( + adults, other, household, report, params + ) + assert np.isclose(award[i], expected), (variable, units[i]) + + +@SETTINGS +@given(st.lists(st.tuples(families(), other_members()), min_size=1, max_size=6)) +def test_person_is_on_award_from_couple_or_own_report(drawn): + units = [(*family, other) for family, other in drawn] + sim = Simulation(situation=situation(units)) + claimant_or_partner = sim.calculate("is_claimant_or_partner", YEAR) + for person_variable, couple_award, report in [ + ( + "is_on_income_related_esa", + "claimant_or_partner_esa_income", + "esa_income_reported", + ), + ( + "is_on_income_based_jsa", + "claimant_or_partner_jsa_income", + "jsa_income_reported", + ), + ("is_on_income_support", "income_support", "income_support_reported"), + ]: + on = sim.calculate(person_variable, YEAR) + couple = sim.calculate(couple_award, YEAR, map_to="person") > 0 + own = sim.calculate(report, YEAR) > 0 + expected = np.where(claimant_or_partner, couple, own) + assert np.array_equal(on, expected), (person_variable, units) + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists( + st.tuples( + st.lists(st.sampled_from([0, 0, 3_000]), min_size=1, max_size=2), + st.sampled_from([0, 3_000]), + ), + min_size=1, + max_size=6, + ) +) +def test_other_members_esa_never_changes_non_dependant_exemption(drawn): + """A non-dependant is exempt only through their own (or couple's) award. + + Each household in Merton has an older applicant (the household head) and a + non-dependant benefit unit of one or two claimants, with or without another + adult who reports income-related ESA. The existing members' individual + deductions never change when the other adult is added. + """ + year = NON_DEP_YEAR + people, benunits, households = {}, {}, {} + layouts = [] + for variant, other_present in [(0, False), (1, True)]: + for i, (claimant_awards, other_award) in enumerate(drawn): + tag = f"{variant}_{i}" + names = [f"applicant_{tag}"] + people[names[0]] = {"age": {year: 60}} + unit = [] + for j, award in enumerate(claimant_awards): + name = f"nondep_{tag}_{j}" + people[name] = { + "age": {year: 30 + j}, + "is_claimant_or_partner": {year: True}, + "current_education": {year: "NOT_IN_EDUCATION"}, + "esa_income_reported": {year: award}, + } + unit.append(name) + if other_present: + name = f"other_{tag}" + people[name] = { + "age": {year: 25}, + "is_claimant_or_partner": {year: False}, + "current_education": {year: "NOT_IN_EDUCATION"}, + "esa_income_reported": {year: other_award}, + } + unit.append(name) + people[names[0]]["is_claimant_or_partner"] = {year: True} + benunits[f"applicant_unit_{tag}"] = {"members": names} + benunits[f"nondep_unit_{tag}"] = { + "members": unit, + "universal_credit": {year: 0}, + } + households[f"h_{tag}"] = { + "members": names + unit, + "country": {year: "ENGLAND"}, + "local_authority": {year: "MERTON"}, + } + layouts.append(names + unit) + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + deduction = dict( + zip( + people, + sim.calculate( + "merton_council_tax_reduction_individual_non_dep_deduction", year + ), + ) + ) + for i in range(len(drawn)): + without, with_other = layouts[i], layouts[len(drawn) + i] + for a, b in zip(without, with_other): + assert np.isclose(deduction[a], deduction[b]), drawn[i] + + +def _difference(variable, base_people, award_people, benunit_inputs=None): + def build(people): + names = list(people) + return { + "people": { + n: {k: {YEAR: v} for k, v in inputs.items()} + for n, inputs in people.items() + }, + "benunits": { + "b": { + "members": names, + **{k: {YEAR: v} for k, v in (benunit_inputs or {}).items()}, + } + }, + "households": {"h": {"members": names}}, + } + + base = Simulation(situation=build(base_people)).calculate(variable, YEAR)[0] + award = Simulation(situation=build(award_people)).calculate(variable, YEAR)[0] + return award - base + + +CLAIMANT = {"age": 40, "is_claimant_or_partner": True, "employment_income": 10_000} +OTHER = { + "age": 30, + "is_claimant_or_partner": False, + "current_education": "NOT_IN_EDUCATION", +} + + +def test_claimant_and_partner_awards_count_in_full_as_their_income(): + """HB Regs 2006 reg 25(1): the partner's income is the claimant's. + + The claimant earns £10,000, below the tax and NI thresholds and above any + disregard, so each award adds exactly its amount. + """ + hb = "housing_benefit_applicable_income" + ctr = "council_tax_reduction_applicable_income" + no_uc = {"universal_credit": 0} + claimant_esa = {"claimant": {**CLAIMANT, "esa_income_reported": 5_000}} + assert np.isclose(_difference(hb, {"claimant": CLAIMANT}, claimant_esa), 5_000) + assert np.isclose( + _difference(ctr, {"claimant": CLAIMANT}, claimant_esa, no_uc), 5_000 + ) + partner = {"age": 38, "is_claimant_or_partner": True} + couple = {"claimant": CLAIMANT, "partner": partner} + couple_jsa = { + "claimant": CLAIMANT, + "partner": {**partner, "jsa_income_reported": 3_000}, + } + assert np.isclose(_difference(hb, couple, couple_jsa), 3_000) + assert np.isclose(_difference(ctr, couple, couple_jsa, no_uc), 3_000) + # Another member's award adds nothing. + other_esa = { + "claimant": CLAIMANT, + "other": {**OTHER, "esa_income_reported": 5_000}, + } + with_other = {"claimant": CLAIMANT, "other": OTHER} + assert np.isclose(_difference(hb, with_other, other_esa), 0) + assert np.isclose(_difference(ctr, with_other, other_esa, no_uc), 0) diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index 853726105f..80e936c34b 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -9,7 +9,10 @@ def add_for_members(benunit, period, variables, members): are. Means tests pass the claimant and partner plus the programme's own children or young persons, so a member who is none of these (for example an 18-year-old who is neither a partner nor a qualifying young person) - does not count. + does not count. A benefit-unit variable must therefore already cover only + those members: pass claimant_or_partner_esa_income and + claimant_or_partner_jsa_income, not esa_income and jsa_income, which also + hold awards that other members claim in their own right. """ person = benunit.members total = 0 diff --git a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py index fce3b27d78..ed88147cfd 100644 --- a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py +++ b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py @@ -27,6 +27,9 @@ def formula(person, period, parameters): > 0 ) has_child = person("is_parent", period) - receives_income_related_esa = person.benunit("esa_income", period) > 0 + # The student's own award (or their couple's), not another benefit-unit + # member's: the schedule is for students who could claim benefits + # themselves (SI 2011/1986 regs 61(2) and 71(1)(h)). + receives_income_related_esa = person("is_on_income_related_esa", period) return has_child | qualifying_disability_support | receives_income_related_esa diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index f07a386d61..e90ebf86a6 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -18,11 +18,16 @@ def formula(benunit, period, parameters): "is_child_or_young_person_for_legacy_benefits", period ) any_over_SP_age = benunit.any(benunit.members("is_SP_age", period)) + # Only the claimant's and partner's income-related awards: their income is + # the claimant's (SSCBA s.136(1); HB Regs 2006 reg 25(1)). A member outside + # the family who has an award of their own claims in their own right. + # Income Support already needs the claimant's or partner's own award + # (income_support_eligible). BENUNIT_MEANS_TESTED_BENEFITS = [ "child_benefit", "income_support", - "jsa_income", - "esa_income", + "claimant_or_partner_jsa_income", + "claimant_or_partner_esa_income", ] PERSONAL_BENEFITS = [ "carers_allowance", diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 69d9205c9a..473155bfd2 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -43,9 +43,10 @@ def formula(benunit, period, parameters): "iidb", # Industrial injuries disability benefit ] - # ESA and Working Tax Credit + # ESA and Working Tax Credit. HB Regs 2006 reg 75F(1)(a) exempts a + # claimant or partner receiving ESA, so only their income-related award. QUAL_BENUNIT_BENEFITS = [ - "esa_income", # Income-based ESA + "claimant_or_partner_esa_income", # Income-based ESA "working_tax_credit", # If getting WTC, likely working enough ] diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index 1c698c1921..087a676d59 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -39,7 +39,15 @@ def formula(benunit, period, parameters): if bi.interactions.include_in_means_tests: STEP_2_COMPONENTS.append("basic_income") income += add_for_members(benunit, period, STEP_2_COMPONENTS, members) - EXEMPT_BENEFITS = ["income_support", "esa_income", "jsa_income"] + # TCA 2002 s.7(2) and SI 2002/2008 reg 4: no income test while "the + # person, or either of the persons" claiming is entitled to Income + # Support, income-based JSA or income-related ESA. That is the claimant's + # or partner's award, not another member's. + EXEMPT_BENEFITS = [ + "income_support", + "claimant_or_partner_esa_income", + "claimant_or_partner_jsa_income", + ] on_exempt_benefits = ( add_for_members(benunit, period, EXEMPT_BENEFITS, members) > 0 ) diff --git a/policyengine_uk/variables/gov/dwp/would_claim_IS.py b/policyengine_uk/variables/gov/dwp/would_claim_IS.py index f2bed79a2e..4211ea3ce1 100644 --- a/policyengine_uk/variables/gov/dwp/would_claim_IS.py +++ b/policyengine_uk/variables/gov/dwp/would_claim_IS.py @@ -5,10 +5,22 @@ class would_claim_IS(Variable): value_type = bool entity = BenUnit label = "Would claim Income Support" - documentation = "Whether this family would claim Income Support if eligible" + documentation = ( + "Whether this family would claim Income Support if eligible: the " + "claimant or partner reports an award. Another member of the benefit " + "unit who reports Income Support, such as a non-dependent adult, " + "claims for themselves, not for this family." + ) definition_period = YEAR def formula(benunit, period, parameters): - reported_is = add(benunit, period, ["income_support_reported"]) > 0 + person = benunit.members + reported_is = ( + benunit.sum( + person("income_support_reported", period) + * person("is_claimant_or_partner", period) + ) + > 0 + ) claims_all_entitled_benefits = benunit("claims_all_entitled_benefits", period) return reported_is | claims_all_entitled_benefits diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index 54883e9b4b..e96827e6ef 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -127,10 +127,13 @@ def normal_gross_income_non_dep_deduction( "council_tax_reduction_household_has_non_dep_exemption", period ) full_time_student = is_full_time_student_non_dep(person, period) + # No deduction for a non-dependant "who is on" Income Support, income-based + # JSA or income-related ESA: their own (or their couple's) award, not that of + # another member of their benefit unit. income_based_benefit = ( - (person.benunit("income_support", period) > 0) - | (person.benunit("jsa_income", period) > 0) - | (person.benunit("esa_income", period) > 0) + person("is_on_income_support", period) + | person("is_on_income_based_jsa", period) + | person("is_on_income_related_esa", period) | (person.benunit("pension_credit", period) > 0) ) has_uc = person.benunit("universal_credit", period) > 0 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 444ebecc09..2d20faa8a1 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -18,11 +18,16 @@ def formula(benunit, period, parameters): members = person("is_claimant_or_partner", period) | person( "is_child_or_young_person_for_legacy_benefits", period ) + # Only the claimant's and partner's income-related awards: the scheme + # counts the applicant's and partner's income (Sch 1 para 11 above). A + # member outside the family who has an award of their own claims in their + # own right. Income Support already needs the claimant's or partner's own + # award (income_support_eligible). benunit_means_tested_benefits = [ "child_benefit", "income_support", - "jsa_income", - "esa_income", + "claimant_or_partner_jsa_income", + "claimant_or_partner_esa_income", "universal_credit", ] personal_benefits = [ diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py index 74df5864f3..cb9300b1c1 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py @@ -5,7 +5,27 @@ class council_tax_reduction_relevant_income_based_benefit(Variable): value_type = bool entity = BenUnit label = "CTR claimant has an income-based passporting benefit" + documentation = ( + "Whether the applicant or partner is on Income Support, income-based " + "JSA or income-related ESA, which passports the working-age council " + "tax reduction schemes to their maximum reduction. Only the " + "applicant's and partner's awards count: another member of the " + "benefit unit, such as a non-dependent adult, who has an award of " + "their own claims in their own right." + ) definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2012/2886/schedule" def formula(benunit, period, parameters): - return add(benunit, period, ["income_support", "jsa_income", "esa_income"]) > 0 + return ( + add( + benunit, + period, + [ + "income_support", + "claimant_or_partner_jsa_income", + "claimant_or_partner_esa_income", + ], + ) + > 0 + ) diff --git a/policyengine_uk/variables/gov/social_security_scotland/is_scp_eligible.py b/policyengine_uk/variables/gov/social_security_scotland/is_scp_eligible.py index 4cb03989f1..0bb70bb589 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/is_scp_eligible.py +++ b/policyengine_uk/variables/gov/social_security_scotland/is_scp_eligible.py @@ -38,8 +38,16 @@ def formula(person, period, parameters): receives_income_support = ( benunit("income_support", period) > 0 ) & qb.income_support - receives_jsa_income = (benunit("jsa_income", period) > 0) & qb.jsa_income - receives_esa_income = (benunit("esa_income", period) > 0) & qb.esa_income + # SSI 2020/351 reg 18(e)-(f): the individual responsible for the child + # (through their own or their partner's Child Benefit, Pension Credit or + # Universal Credit award, regs 9 and 12) has been awarded the benefit. + # That is the claimant or partner, not another member of the benefit unit. + receives_jsa_income = ( + benunit("claimant_or_partner_jsa_income", period) > 0 + ) & qb.jsa_income + receives_esa_income = ( + benunit("claimant_or_partner_esa_income", period) > 0 + ) & qb.esa_income receives_pension_credit = ( benunit("pension_credit", period) > 0 ) & qb.pension_credit From 2110ff48e8449655b3bb2a635ac7d680ead2b949 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 00:22:47 -0400 Subject: [PATCH 03/11] Treat an ESA or JSA award as entered directly only for the period it was input claimant_or_partner_esa_income and claimant_or_partner_jsa_income took esa_income / jsa_income as the claimant's or partner's award whenever the name was in Simulation.input_variables. That list is fixed at construction and ignores the period: an input for 2024 alone made 2025 read the benefit-unit total, so another member's 2025 award counted, and an award set later with set_input or on a branch was ignored. Both now use entered_directly (policyengine_uk/utils/inputs.py, byte-identical to #2013 and #2025), which reads core's record of each input's variable, branch and period. jsa_income.py is re-copied from #2025 (6337f3141): the formula returns early while income-based JSA is inactive, before reading reports. Tests: YAML cases for an award entered for an earlier year only, and Python tests for a late set_input, a set_input after the value was cached, a branch and a late zero entry. Co-Authored-By: Claude Opus 5.5 --- .../claimant_or_partner_awards.yaml | 50 ++++++++++++++ .../test_legacy_award_readers_properties.py | 67 +++++++++++++++++++ .../gov/dwp/claimant_or_partner_esa_income.py | 7 +- .../gov/dwp/claimant_or_partner_jsa_income.py | 7 +- .../variables/gov/dwp/jsa_income.py | 2 + 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml index 80d5ecd3cc..06d78a0347 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml @@ -271,3 +271,53 @@ members: [claimant, other] output: claimant_or_partner_jsa_income: 2_000 + +- name: An esa_income entered for an earlier year only does not make another member's award the claimant's + period: 2025 + absolute_error_margin: 0 + 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: 5_000 + benunits: + benunit: + members: [claimant, other] + esa_income: + 2024: 1_000 + households: + household: + members: [claimant, other] + output: + esa_income: 5_000 + claimant_or_partner_esa_income: 0 + +- name: A jsa_income entered for an earlier year only does not make another member's award the claimant's + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + jsa_income: + 2024: 1_000 + households: + household: + members: [claimant, other] + output: + jsa_income: 4_000 + claimant_or_partner_jsa_income: 0 diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 9a5b492c49..7e1b27dd7f 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -422,3 +422,70 @@ def test_claimant_and_partner_awards_count_in_full_as_their_income(): with_other = {"claimant": CLAIMANT, "other": OTHER} assert np.isclose(_difference(hb, with_other, other_esa), 0) assert np.isclose(_difference(ctr, with_other, other_esa, no_uc), 0) + + +def _claimant_and_other(other_report): + people = { + "claimant": {"age": {YEAR: 40}, "is_claimant_or_partner": {YEAR: True}}, + "other": { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + **{k: {YEAR: v} for k, v in other_report.items()}, + }, + } + return { + "people": people, + "benunits": {"b": {"members": list(people)}}, + "households": {"h": {"members": list(people)}}, + } + + +def test_award_set_after_construction_is_taken_as_entered(): + """An award set with set_input after the simulation is built is entered + directly, so it is the claimant's whatever another member reports.""" + for variable, scoped, report in [ + ("esa_income", "claimant_or_partner_esa_income", "esa_income_reported"), + ("jsa_income", "claimant_or_partner_jsa_income", "jsa_income_reported"), + ]: + sim = Simulation(situation=_claimant_and_other({report: 5_000})) + sim.set_input(variable, YEAR, np.array([3_000.0])) + assert np.isclose(sim.calculate(scoped, YEAR)[0], 3_000), variable + + +def test_award_set_on_a_branch_is_taken_as_entered_on_that_branch(): + for variable, scoped, report in [ + ("esa_income", "claimant_or_partner_esa_income", "esa_income_reported"), + ("jsa_income", "claimant_or_partner_jsa_income", "jsa_income_reported"), + ]: + sim = Simulation(situation=_claimant_and_other({report: 5_000})) + branch = sim.get_branch("entered") + branch.set_input(variable, YEAR, np.array([3_000.0])) + assert np.isclose(branch.calculate(scoped, YEAR)[0], 3_000), variable + # The main simulation still reads the reports: another member's award + # is not the claimant's. + assert np.isclose(sim.calculate(scoped, YEAR)[0], 0), variable + + +def test_award_set_after_the_value_is_cached_is_taken_as_entered(): + for variable, scoped, report in [ + ("esa_income", "claimant_or_partner_esa_income", "esa_income_reported"), + ("jsa_income", "claimant_or_partner_jsa_income", "jsa_income_reported"), + ]: + sim = Simulation(situation=_claimant_and_other({report: 5_000})) + assert np.isclose(sim.calculate(scoped, YEAR)[0], 0), variable + sim.set_input(variable, YEAR, np.array([3_000.0])) + sim.delete_arrays(scoped) + assert np.isclose(sim.calculate(scoped, YEAR)[0], 3_000), variable + + +def test_late_zero_entry_overrides_the_claimants_reports(): + for variable, scoped, report in [ + ("esa_income", "claimant_or_partner_esa_income", "esa_income_reported"), + ("jsa_income", "claimant_or_partner_jsa_income", "jsa_income_reported"), + ]: + situation = _claimant_and_other({}) + situation["people"]["claimant"][report] = {YEAR: 3_000} + sim = Simulation(situation=situation) + sim.set_input(variable, YEAR, np.array([0.0])) + assert np.isclose(sim.calculate(scoped, YEAR)[0], 0), variable diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py index f7be930d63..22fffa2803 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.inputs import entered_directly from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award @@ -16,7 +17,9 @@ class claimant_or_partner_esa_income(Variable): "own right, so their award is left out here; it still counts in " "esa_income and so in household income. When esa_income is entered " "directly rather than calculated from reported awards, the entered " - "award is taken to be the claimant's or partner's." + "award is taken to be the claimant's or partner's. Entered directly " + "means set as an input for this period (entered_directly), not " + "merely for some other year." ) definition_period = YEAR unit = GBP @@ -27,7 +30,7 @@ class claimant_or_partner_esa_income(Variable): ) def formula(benunit, period, parameters): - if "esa_income" in benunit.simulation.input_variables: + if entered_directly(benunit, "esa_income", period): return benunit("esa_income", period) person = benunit.members reported_award = benunit.sum( diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py index 816ce47d2c..f31221e5f9 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.inputs import entered_directly from policyengine_uk.variables.gov.dwp.jsa_income import income_related_jsa_award @@ -16,7 +17,9 @@ class claimant_or_partner_jsa_income(Variable): "own right, so their award is left out here; it still counts in " "jsa_income and so in household income. When jsa_income is entered " "directly rather than calculated from reported awards, the entered " - "award is taken to be the claimant's or partner's." + "award is taken to be the claimant's or partner's. Entered directly " + "means set as an input for this period (entered_directly), not " + "merely for some other year." ) definition_period = YEAR unit = GBP @@ -27,7 +30,7 @@ class claimant_or_partner_jsa_income(Variable): ) def formula(benunit, period, parameters): - if "jsa_income" in benunit.simulation.input_variables: + if entered_directly(benunit, "jsa_income", period): return benunit("jsa_income", period) person = benunit.members reported_award = benunit.sum( diff --git a/policyengine_uk/variables/gov/dwp/jsa_income.py b/policyengine_uk/variables/gov/dwp/jsa_income.py index c584664475..600faf9faa 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income.py @@ -25,5 +25,7 @@ class jsa_income(Variable): unit = GBP 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"]) return income_related_jsa_award(benunit, period, reported_award) From 200a1cdc4e689b5c69d68582d235c2edaf9e51d7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 00:58:26 -0400 Subject: [PATCH 04/11] Keep the record of direct inputs in step with stored values Takes policyengine_uk/simulation.py and policyengine_uk/utils/inputs.py byte-identical from 48abd8817 on #2025 (is-remunerative-work-jsa-conditions). In policyengine-core 3.32.9, delete_arrays keeps the deleted arrays' _user_input_keys entries, and clone shares the record. A formula value could then look like a direct input, after a deletion or an input on a clone or on a parent after a branch. The UK Simulation now gives each clone its own copy of the record and drops the entries for deleted arrays, and entered_directly asks about the stored value the simulation actually reads. test_claimant_or_partner_awards_entered_directly.py mirrors #2025's lifecycle cases on claimant_or_partner_esa_income and claimant_or_partner_jsa_income. 6 of its 12 cases fail without the Simulation changes. #2025's own test file asserts its s.124(1)(f) JSA bar, which this branch does not have, so it is not copied. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/simulation.py | 40 ++++++ ...mant_or_partner_awards_entered_directly.py | 114 ++++++++++++++++++ policyengine_uk/utils/inputs.py | 39 +++--- 3 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index eee6214d44..397667cef7 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -222,6 +222,46 @@ def reset_calculations(self): if variable not in self.input_variables: self.delete_arrays(variable) + def clone(self, *args, **kwargs) -> "Simulation": + """Clone the simulation, with its own record of explicit inputs. + + Core's ``clone`` copies the holders' stored arrays but shares + ``_user_input_keys``, the (variable, branch, period) record of values + set with ``set_input``. An input set afterwards on the clone (or on a + branch, which is a clone) was then recorded against the original's + own stored arrays too. Each copy keeps its own record here, matching + its own storage. ``entered_directly`` reads that record. + """ + new = super().clone(*args, **kwargs) + new._user_input_keys = set(getattr(self, "_user_input_keys", ())) + return new + + def delete_arrays(self, variable: str, period=None) -> None: + """Delete stored values, and forget that deleted inputs were inputs. + + Core removes the arrays but keeps their ``_user_input_keys`` entries, + so a formula result calculated later for the same period would be + taken for a user input. The entries for the variable, on the + branches whose storage this deletes and in the deleted periods, are + dropped with the arrays. + """ + super().delete_arrays(variable, period) + keys = getattr(self, "_user_input_keys", None) + if not keys: + return + if period is not None: + period = period_(period) + branches = set(self._get_visible_branch_names()) + self._user_input_keys = { + key + for key in keys + if not ( + key[0] == variable + and key[1] in branches + and (period is None or period.contains(period_(key[2]))) + ) + } + def get_known_variables(self): variables = [] for variable in self.tax_benefit_system.variables: diff --git a/policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py b/policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py new file mode 100644 index 0000000000..ff98c4941b --- /dev/null +++ b/policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py @@ -0,0 +1,114 @@ +"""The claimant-or-partner awards follow direct inputs through their lifecycle. + +claimant_or_partner_esa_income and claimant_or_partner_jsa_income take an +esa_income or jsa_income entered directly to be the claimant's or partner's +award, and otherwise use the claimant's and partner's reported awards. +entered_directly decides which, from core's record of explicit inputs, which +the UK Simulation keeps in step with storage through deletion, clones and +branches. Each case has a claimant with no award of their own and an adult +outside the couple who reports £3,000 of the award. Whenever the stored award +is a formula result, the claimant-or-partner award must be £0; when it is an +input, it must be the input. + +These mirror test_entered_directly_lifecycle.py on #2025, which asserts the +same lifecycle through income_support_eligible. +""" + +import pytest + +from policyengine_uk import Simulation + +YEAR = 2025 + +AWARDS = { + "esa_income": ("esa_income_reported", "claimant_or_partner_esa_income"), + "jsa_income": ("jsa_income_reported", "claimant_or_partner_jsa_income"), +} + + +def family(award): + reported, _ = AWARDS[award] + members = ["claimant", "other_adult"] + return Simulation( + situation={ + "people": { + "claimant": { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + }, + "other_adult": { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + reported: {YEAR: 3_000}, + }, + }, + "benunits": {"family": {"members": members}}, + "households": {"home": {"members": members}}, + } + ) + + +def scoped(simulation, award): + _, variable = AWARDS[award] + simulation.delete_arrays(variable) + return float(simulation.calculate(variable, YEAR)[0]) + + +@pytest.mark.parametrize("award", AWARDS) +def test_another_members_report_is_never_the_claimants_award(award): + simulation = family(award) + assert simulation.calculate(award, YEAR)[0] == 3_000 + assert scoped(simulation, award) == 0 + + +@pytest.mark.parametrize("award", AWARDS) +def test_a_deleted_input_does_not_make_a_later_formula_result_direct(award): + simulation = family(award) + simulation.set_input(award, YEAR, [1_000]) + assert scoped(simulation, award) == 1_000 + simulation.delete_arrays(award) + assert simulation.calculate(award, YEAR)[0] == 3_000 + assert scoped(simulation, award) == 0 + + +@pytest.mark.parametrize("award", AWARDS) +def test_an_input_on_a_clone_does_not_reach_the_original(award): + simulation = family(award) + assert simulation.calculate(award, YEAR)[0] == 3_000 + clone = simulation.clone() + clone.set_input(award, YEAR, [1_000]) + assert scoped(clone, award) == 1_000 + assert scoped(simulation, award) == 0 + + +@pytest.mark.parametrize("award", AWARDS) +def test_a_parent_input_does_not_reach_an_earlier_branch(award): + simulation = family(award) + assert simulation.calculate(award, YEAR)[0] == 3_000 + branch = simulation.get_branch("before", clone_system=False) + simulation.set_input(award, YEAR, [1_000]) + assert scoped(simulation, award) == 1_000 + # The branch still stores the formula result it copied. + assert scoped(branch, award) == 0 + + +@pytest.mark.parametrize("award", AWARDS) +def test_a_branch_made_after_a_parent_input_inherits_it(award): + simulation = family(award) + simulation.set_input(award, YEAR, [1_000]) + branch = simulation.get_branch("after", clone_system=False) + assert scoped(branch, award) == 1_000 + + +@pytest.mark.parametrize("award", AWARDS) +def test_nested_branches_read_the_nearest_stored_input(award): + simulation = family(award) + outer = simulation.get_branch("outer", clone_system=False) + outer.set_input(award, YEAR, [1_000]) + inner = outer.get_branch("inner", clone_system=False) + assert scoped(inner, award) == 1_000 + inner.set_input(award, YEAR, [500]) + assert scoped(inner, award) == 500 + assert scoped(outer, award) == 1_000 + assert scoped(simulation, award) == 0 diff --git a/policyengine_uk/utils/inputs.py b/policyengine_uk/utils/inputs.py index 899a344c6d..828ce93aaa 100644 --- a/policyengine_uk/utils/inputs.py +++ b/policyengine_uk/utils/inputs.py @@ -4,26 +4,27 @@ def entered_directly(population, variable, period): - """Whether ``variable`` was set as an input for ``period``. + """Whether the value of ``variable`` for ``period`` is a user input. - The input counts if it was set on this simulation's branch or a branch it - inherits from, at construction or later with ``set_input``. The value - must also still be stored. ``Simulation.input_variables`` cannot answer - this: it is fixed when the simulation is built and ignores the period, so - it misses later inputs and treats an input for one year as an input for - every year. Core records each explicit input as (variable, branch, - period) in ``_user_input_keys``, which is what ``to_input_dataframe`` - reads; this reads the same record. + The simulation reads the value from the first of its visible branches + (its own, then its parents', then the default) that stores one, so this + asks whether that stored value was set with ``set_input``, when the + simulation was built or later. ``Simulation.input_variables`` cannot + answer this: it is fixed when the simulation is built and ignores the + period, so it misses later inputs and treats an input for one year as an + input for every year. + + Core records each explicit input as (variable, branch, period) in + ``_user_input_keys``. The UK ``Simulation`` keeps that record in step + with storage: a clone gets its own copy, and ``delete_arrays`` drops the + entries for the arrays it deletes, so a formula result stored later is + not taken for an input. """ simulation = population.simulation period = periods.period(period) - if hasattr(simulation, "_get_visible_branch_names"): - branches = set(simulation._get_visible_branch_names()) - else: - branches = {getattr(simulation, "branch_name", "default"), "default"} - if period not in simulation.get_holder(variable).get_known_periods(): - return False - return any( - name == variable and branch in branches and periods.period(key) == period - for name, branch, key in getattr(simulation, "_user_input_keys", ()) - ) + keys = simulation._user_input_keys + stored = set(simulation.get_holder(variable).get_known_branch_periods()) + for branch in simulation._get_visible_branch_names(): + if (branch, period) in stored: + return (variable, branch, period) in keys + return False From 57b37fdea670b8c492b8d725d3edd568a82498f8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 03:08:35 -0400 Subject: [PATCH 05/11] Describe the income-based JSA screen without naming its tests Docstring only: jsa_income.py is byte-identical to #2025's ffe3a5800, matching #2013's esa_income.py (44d59d909). No code changed. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/variables/gov/dwp/jsa_income.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/variables/gov/dwp/jsa_income.py b/policyengine_uk/variables/gov/dwp/jsa_income.py index 600faf9faa..58dad5288d 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income.py @@ -4,7 +4,7 @@ def income_related_jsa_award(benunit, period, reported_award): """Income-based JSA paid on a reported award: the award less tariff income from capital, or nothing if the scheme is not active or the - benefit unit fails the capital test (jsa_income_eligible).""" + benefit unit fails the screen in jsa_income_eligible.""" JSA = benunit.simulation.tax_benefit_system.parameters(period).gov.dwp.JSA if not JSA.income.active: return benunit.empty_array() From 3f313475564c09506f21425b48f8411704bf1836 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 06:01:46 -0400 Subject: [PATCH 06/11] Answer review r2: zero and precision rules, own-status gating, benefit cap left unscoped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r2 of #2027 (subfleet job 20261002-044711) and #2013/#2025's latest value-rule refinements (0953c8aa6, 081ec1460): - claimant_or_partner_{esa,jsa}_income: a stored zero is zero. References are compared at the stored float32 precision. When the stored award is the plain total of the reports (the disable_simulated_benefits reform), the claimant's and partner's plain reported total passes through unscreened, so a claimant's own £200 at £10,000 capital is no longer zeroed. Entering these variables directly is the explicit way to state ownership. - is_on_income_related_esa / _based_jsa: another member's own report counts only while the benefit unit's modelled award is positive, so a reform that removes the benefit takes the status with it. is_on_income_support: likewise only while Income Support is active and not neutralised. - is_benefit_cap_exempt_health_disability is reverted to the benefit unit's esa_income. benefit_cap_reduction (out of scope) still caps every member's ESA and JSA, so scoping the exemption alone would cap the claimant with another adult's own award. Both go to the benefit-cap follow-up. - The property generator fixes claims_all_entitled_benefits per benefit unit (it sums reports across the simulation). SCP comments name the kinship route. The childcare citation is reg 1(2), not 2(1). Changelog wording matches the paths. Co-Authored-By: Claude Opus 5.5 --- ...cy-award-readers-claimant-partner.added.md | 4 +- ...cy-award-readers-claimant-partner.fixed.md | 6 +- .../qualifying_benefits.yaml | 4 +- .../claimant_or_partner_awards.yaml | 70 ++++++++++++ .../legacy_award_readers/passports.yaml | 33 ++---- ...mant_or_partner_awards_entered_directly.py | 101 +++++++++++++++++- .../test_legacy_award_readers_properties.py | 26 +++-- .../gov/dwp/claimant_or_partner_esa_income.py | 63 ++++++----- .../gov/dwp/claimant_or_partner_jsa_income.py | 63 ++++++----- ...is_benefit_cap_exempt_health_disability.py | 5 +- .../gov/dwp/is_on_income_based_jsa.py | 9 +- .../gov/dwp/is_on_income_related_esa.py | 9 +- .../variables/gov/dwp/is_on_income_support.py | 12 ++- .../is_scp_eligible.py | 5 +- 14 files changed, 307 insertions(+), 103 deletions(-) diff --git a/changelog.d/legacy-award-readers-claimant-partner.added.md b/changelog.d/legacy-award-readers-claimant-partner.added.md index 242a159a3f..ebba6e7649 100644 --- a/changelog.d/legacy-award-readers-claimant-partner.added.md +++ b/changelog.d/legacy-award-readers-claimant-partner.added.md @@ -1,2 +1,2 @@ -- Add `claimant_or_partner_esa_income` and `claimant_or_partner_jsa_income`: the income-related award on the claimant's and partner's reported amounts after the capital test, or a directly entered `esa_income` or `jsa_income`. -- Add `is_on_income_related_esa`, `is_on_income_based_jsa` and `is_on_income_support`: whether the award is payable to the person, through their couple's award for the claimant and partner and through their own report for anyone else. +- Add `claimant_or_partner_esa_income` and `claimant_or_partner_jsa_income`: the income-related award of the claimant and partner. When `esa_income` or `jsa_income` holds what the reported awards give (its formula, or their plain total), this is the same on the claimant's and partner's reports; any other stored value is taken to be theirs, and a stored zero is zero. An entered award equal to either reported amount is read through the reports, so enter these variables directly to state the claimant's or partner's award. +- Add `is_on_income_related_esa`, `is_on_income_based_jsa` and `is_on_income_support`: whether the award is payable to the person, through their couple's award for the claimant and partner and through their own report for anyone else while the benefit is modelled as paid. diff --git a/changelog.d/legacy-award-readers-claimant-partner.fixed.md b/changelog.d/legacy-award-readers-claimant-partner.fixed.md index 12f9282de4..f72e9ef575 100644 --- a/changelog.d/legacy-award-readers-claimant-partner.fixed.md +++ b/changelog.d/legacy-award-readers-claimant-partner.fixed.md @@ -1,3 +1,3 @@ -- Count only the claimant's and partner's income-related ESA and income-based JSA in Housing Benefit, council tax reduction and tax credit income, and in the passports for working-age council tax reduction, Scottish Child Payment, targeted childcare and the benefit cap. Another member of the benefit unit, such as a non-dependent adult, who reports an award claims in their own right; their award still counts in household income. -- Exempt a council tax reduction non-dependant, and put a student on the maintenance loan schedule for students entitled to benefits, only through their own or their couple's Income Support, income-based JSA or income-related ESA, not another benefit-unit member's. -- Treat a family as claiming Income Support (`would_claim_IS`) only when the claimant or partner reports it. +- Count only the claimant's and partner's income-related ESA and income-based JSA in Housing Benefit, council tax reduction and tax credit income, and in the passports for working-age council tax reduction, Scottish Child Payment and targeted childcare. Another member of the benefit unit, such as a non-dependent adult, who reports an award claims in their own right; their award still counts in household income. +- Exempt a council tax reduction non-dependant only through their own or their couple's Income Support, income-based JSA or income-related ESA, not another benefit-unit member's. The maintenance loan schedule for students entitled to benefits now reads the student's own or their couple's income-related ESA, alongside the existing parent and disability tests. +- Treat a family as claiming Income Support (`would_claim_IS`) when the claimant or partner reports it, or when every entitled benefit is claimed (`claims_all_entitled_benefits`); another member's report no longer counts. diff --git a/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml b/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml index 2f4a3d6ea3..6cf7f7b91a 100644 --- a/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml +++ b/policyengine_uk/parameters/gov/dfe/targeted_childcare_entitlement/qualifying_benefits.yaml @@ -1,8 +1,8 @@ description: The Department for Education designates these programs as qualifying benefits for the targeted childcare entitlement eligibility (the 2-year-old entitlement) in England. The child's parent must be entitled to one, so income-based JSA and income-related ESA count only as the claimant's or partner's award. metadata: reference: - - title: The Local Authority (Duty to Secure Early Years Provision Free of Charge) Regulations 2014 - regulation 2(1), "eligible child" - href: https://www.legislation.gov.uk/uksi/2014/2147/regulation/2 + - title: The Local Authority (Duty to Secure Early Years Provision Free of Charge) Regulations 2014 - regulation 1(2), "eligible child" + href: https://www.legislation.gov.uk/uksi/2014/2147/regulation/1 - title: The Local Authority (Duty to Secure Early Years Provision Free of Charge) (Amendment) Regulations 2018 - part 2.a href: https://www.legislation.gov.uk/uksi/2018/146/regulation/2/made period: year diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml index 06d78a0347..06af3598f5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml @@ -321,3 +321,73 @@ output: jsa_income: 4_000 claimant_or_partner_jsa_income: 0 + +- name: An entered claimant-or-partner award is the claimant's whatever another member reports + period: 2025 + absolute_error_margin: 0 + 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: 3_000 + benunits: + benunit: + members: [claimant, other] + esa_income: 3_000 + claimant_or_partner_esa_income: 3_000 + households: + household: + members: [claimant, other] + output: + # esa_income alone equals the other member's report, so it would be read + # through the reports; entering the scoped award states whose it is. + claimant_or_partner_esa_income: 3_000 + council_tax_reduction_relevant_income_based_benefit: true + +- name: The claimant's raw reported award passes through when esa_income holds the raw total + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 200 + benunits: + benunit: + members: [claimant] + esa_income: 200 + households: + household: + members: [claimant] + savings: 10_000 + output: + # The formula would give max(0, 200 - 832) = 0; the stored 200 is the raw + # total of the reports (as the disable_simulated_benefits reform sets), + # so the claimant's raw report is used. + claimant_or_partner_esa_income: 200 + +- name: A stored zero esa_income is never the claimant's award + period: 2025 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 0.004 + benunits: + benunit: + members: [claimant] + esa_income: 0 + households: + household: + members: [claimant] + output: + claimant_or_partner_esa_income: 0 + council_tax_reduction_relevant_income_based_benefit: false diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml index b04004fe15..a9b57d0296 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml @@ -98,6 +98,9 @@ # Scottish Child Payment: the individual responsible for the child has been # awarded a qualifying benefit (SSI 2020/351 reg 18(e)-(f), regs 9, 12 and 14). +# Responsibility runs through the individual's or partner's Child Benefit, +# Pension Credit or Universal Credit award, or kinship care (reg 12(1)(b)); the +# model takes the responsible individual to be the claimant or partner. # Universal Credit, tax credits and Pension Credit are entered as 0 so the # income-related award is the only possible qualifying benefit. @@ -214,7 +217,7 @@ is_scp_eligible: [false, true, false] # Targeted childcare (England's two-year-old offer): the child's parent is -# entitled to a qualifying benefit (SI 2014/2147 reg 2(1), "eligible child"). +# entitled to a qualifying benefit (SI 2014/2147 reg 1(2), "eligible child"). # The other routes (UC, CTC and WTC criteria, Guarantee Credit) are switched # off with inputs. @@ -336,29 +339,11 @@ # Benefit cap: no cap where "the claimant or the claimant's partner" receives # ESA with the support component (HB Regs 2006 reg 75F(1)(a)). The model -# exempts any income-related ESA of the claimant or partner. - -- name: Another member's income-related ESA does not exempt the family from the benefit cap - period: 2025 - 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: 5_000 - benunits: - benunit: - members: [claimant, other] - working_tax_credit: 0 - households: - household: - members: [claimant, other] - output: - is_benefit_cap_exempt_health_disability: false +# exempts any income-related ESA. This PR leaves the cap's reading of the +# benefit unit's ESA unchanged: scoping the exemption alone would cap the +# claimant with another member's own award in the capped sum +# (benefit_cap_reduction), which is out of scope here. The claimant's own +# award still exempts the family. - name: The claimant's own income-related ESA exempts the family from the benefit cap period: 2025 diff --git a/policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py b/policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py index fc6e926d87..8a1104b6c1 100644 --- a/policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py +++ b/policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py @@ -133,8 +133,9 @@ def test_the_raw_reported_total_is_read_through_the_reports(award): couple £3,000, with savings of £10,000. Tariff income of ceil(4,000 / 250) x £1 x 52 = £832 a year extinguishes the partner's award. The raw total, £3,200, differs from the screened award, £2,368, - but is still what the reports give, so the claimant-or-partner award is - the partner's screened award, £0.""" + but is still what the reports give. As the reform uses reported amounts + unscreened, the claimant-or-partner award is the partner's raw report, + £200.""" reported, _ = AWARDS[award] members = ["claimant", "partner", "other_adult"] simulation = Simulation( @@ -160,7 +161,7 @@ def test_the_raw_reported_total_is_read_through_the_reports(award): assert simulation.calculate(award, YEAR)[0] == 2_368 assert scoped(simulation, award) == 0 simulation.set_input(award, YEAR, [3_200]) - assert scoped(simulation, award) == 0 + assert scoped(simulation, award) == 200 # A different award entered directly is the couple's. simulation.set_input(award, YEAR, [4_000]) assert scoped(simulation, award) == 4_000 @@ -177,3 +178,97 @@ def test_a_neutralised_award_switches_the_claimants_award_off(award): simulation.delete_arrays(award) assert simulation.calculate(award, YEAR)[0] == 0 assert scoped(simulation, award) == 0 + + +@pytest.mark.parametrize("award", AWARDS) +def test_a_stored_zero_is_never_the_claimants_award(award): + """A stored award of zero is no award, even when the reports give a + fraction of a penny that rounds to it.""" + reported, _ = AWARDS[award] + simulation = family(award) + simulation.set_input(reported, YEAR, [0.004, 0]) + simulation.set_input(award, YEAR, [0]) + assert scoped(simulation, award) == 0 + + +@pytest.mark.parametrize("award", AWARDS) +def test_large_reports_are_compared_at_storage_precision(award): + """Awards are stored as float32. Two reports of £65,536.01 and £65,536.00 + from adults outside the couple give £131,072.01, which float32 rounds; + the formula's own award must still be recognised as the formula's.""" + reported, _ = AWARDS[award] + members = ["claimant", "other_adult", "third_adult"] + simulation = Simulation( + situation={ + "people": { + "claimant": {"age": {YEAR: 40}, "is_claimant_or_partner": {YEAR: True}}, + "other_adult": { + "age": {YEAR: 30}, + "is_claimant_or_partner": {YEAR: False}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + reported: {YEAR: 65_536.01}, + }, + "third_adult": { + "age": {YEAR: 31}, + "is_claimant_or_partner": {YEAR: False}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + reported: {YEAR: 65_536.00}, + }, + }, + "benunits": {"family": {"members": members}}, + "households": {"home": {"members": members}}, + } + ) + assert simulation.calculate(award, YEAR)[0] > 131_000 + assert scoped(simulation, award) == 0 + + +@pytest.mark.parametrize("award", AWARDS) +def test_an_entered_scoped_award_states_ownership(award): + """Entering the claimant-or-partner award itself is the explicit way to + say whose an award is: another member's report then never changes it.""" + _, variable = AWARDS[award] + for other_report in [2_000, 3_000]: + simulation = family(award) + reported, _ = AWARDS[award] + simulation.set_input(reported, YEAR, [0, other_report]) + simulation.set_input(award, YEAR, [3_000]) + simulation.set_input(variable, YEAR, [3_000]) + assert simulation.calculate(variable, YEAR)[0] == 3_000 + + +def test_a_removed_esa_award_takes_an_excluded_students_status_with_it(): + """An adult outside the couple who reports income-related ESA is on it + while the model pays it; once a reform removes esa_income, neither their + status nor the maintenance loan schedule follows the report alone.""" + members = ["parent", "student"] + simulation = Simulation( + situation={ + "people": { + "parent": { + "age": {YEAR: 50}, + "is_claimant_or_partner": {YEAR: True}, + "is_parent": {YEAR: False}, + }, + "student": { + "age": {YEAR: 19}, + "is_claimant_or_partner": {YEAR: False}, + "current_education": {YEAR: "TERTIARY"}, + "esa_income_reported": {YEAR: 3_000}, + }, + }, + "benunits": {"family": {"members": members}}, + "households": {"home": {"members": members, "country": {YEAR: "ENGLAND"}}}, + } + ) + assert simulation.calculate("is_on_income_related_esa", YEAR)[1] + assert simulation.calculate("maintenance_loan_entitled_to_benefits", YEAR)[1] + simulation.tax_benefit_system.neutralize_variable("esa_income") + for variable in [ + "esa_income", + "is_on_income_related_esa", + "maintenance_loan_entitled_to_benefits", + ]: + simulation.delete_arrays(variable) + assert not simulation.calculate("is_on_income_related_esa", YEAR)[1] + assert not simulation.calculate("maintenance_loan_entitled_to_benefits", YEAR)[1] diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 7e1b27dd7f..980f37744d 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -12,8 +12,7 @@ exemption: CTR (Default Scheme) (England) Regs 2012, Schedule. - The tax credit income test: TCA 2002 s.7(2), SI 2002/2008 reg 4. - Scottish Child Payment: SSI 2020/351 reg 18(e)-(f). -- The benefit cap: HB Regs 2006 reg 75F(1)(a). -- Targeted childcare: SI 2014/2147 reg 2(1). +- Targeted childcare: SI 2014/2147 reg 1(2). - Maintenance loans for students entitled to benefits: SI 2011/1986 regs 61(2) and 71(1)(h). @@ -31,7 +30,9 @@ claimant and partner) or they report one themselves (anyone else). Roles are given explicitly (is_claimant_or_partner), so the properties test -the readers rather than role inference. Each example builds many families in +the readers rather than role inference. The take-up mode is fixed +(claims_all_entitled_benefits false): that flag sums reports across the +whole simulation, so with it a report anywhere can change would_claim_IS. Each example builds many families in one simulation, in separate households and benefit units. """ @@ -54,7 +55,6 @@ "council_tax_reduction_applicable_income", "council_tax_reduction_relevant_income_based_benefit", "tax_credits_applicable_income", - "is_benefit_cap_exempt_health_disability", "targeted_childcare_entitlement_eligible", "would_claim_IS", "income_support_eligible", @@ -143,7 +143,13 @@ def situation(units, year=YEAR): people[name] = {k: {year: v} for k, v in inputs.items()} people[name]["is_claimant_or_partner"] = {year: claimant_or_partner} names.append(name) - benunits[f"b{i}"] = {"members": names} + # claims_all_entitled_benefits sums reports across the whole + # simulation, so fix the take-up mode per benefit unit: the variants + # must not share it. + benunits[f"b{i}"] = { + "members": names, + "claims_all_entitled_benefits": {year: False}, + } households[f"h{i}"] = { "members": names, **{k: {year: v} for k, v in household.items()}, @@ -262,22 +268,28 @@ def test_person_is_on_award_from_couple_or_own_report(drawn): units = [(*family, other) for family, other in drawn] sim = Simulation(situation=situation(units)) claimant_or_partner = sim.calculate("is_claimant_or_partner", YEAR) - for person_variable, couple_award, report in [ + for person_variable, couple_award, report, total_award in [ ( "is_on_income_related_esa", "claimant_or_partner_esa_income", "esa_income_reported", + "esa_income", ), ( "is_on_income_based_jsa", "claimant_or_partner_jsa_income", "jsa_income_reported", + "jsa_income", ), - ("is_on_income_support", "income_support", "income_support_reported"), + ("is_on_income_support", "income_support", "income_support_reported", None), ]: on = sim.calculate(person_variable, YEAR) couple = sim.calculate(couple_award, YEAR, map_to="person") > 0 own = sim.calculate(report, YEAR) > 0 + if total_award is not None: + # Another member's own report counts while the benefit unit's + # modelled award is positive. + own = own & (sim.calculate(total_award, YEAR, map_to="person") > 0) expected = np.where(claimant_or_partner, couple, own) assert np.array_equal(on, expected), (person_variable, units) diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py index 7b867cd1ed..e22e91beda 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py @@ -7,20 +7,23 @@ class claimant_or_partner_esa_income(Variable): entity = BenUnit label = "income-related ESA of the claimant or partner" documentation = ( - "Income-related ESA on the claimant's and partner's reported awards, " - "after the same capital test as esa_income. Means tests and passports " - "that ask whether the claimant or partner is on income-related ESA " - "read this. Another member of the benefit unit who is neither the " - "claimant, the partner nor a child or young person they are " - "responsible for (for example a non-dependent adult) claims in their " - "own right, so their award is left out here; it still counts in " - "esa_income and so in household income. When esa_income holds what the " - "reported awards give, either after the capital test (its formula) " - "or as their plain total (the disable_simulated_benefits reform), the " - "reports say whose award it is. When it holds anything else (an " - "award entered directly, or a reform that replaces or removes it), " - "they do not, and it is taken to be the claimant's or partner's. An " - "entered award equal to either amount is read through the reports." + "Income-related ESA of the claimant and partner. Means tests and passports that " + "ask whether the claimant or partner is on income-related ESA read this. " + "Another member of the benefit unit who is neither the claimant, the " + "partner nor a child or young person they are responsible for (for " + "example a non-dependent adult) claims in their own right, so their " + "award is left out here; it still counts in esa_income and so in " + "household income. Which awards esa_income holds is decided by value: " + "when it holds the award its formula gives on all reported awards, " + "this is the same award on the claimant's and partner's reports; " + "when it holds the plain total of the reports (the " + "disable_simulated_benefits reform), this is the plain total of the " + "claimant's and partner's reports; when it holds anything else (an " + "award entered directly, or a reform that replaces it), that value " + "is taken to be the claimant's or partner's. A stored zero is zero. " + "An entered esa_income equal to either reported amount is read through " + "the reports, so to enter the claimant's or partner's award whatever " + "other members report, enter this variable directly." ) definition_period = YEAR unit = GBP @@ -33,17 +36,27 @@ class claimant_or_partner_esa_income(Variable): def formula(benunit, period, parameters): person = benunit.members esa_income = benunit("esa_income", period) - reported_total = add(benunit, period, ["esa_income_reported"]) + reported = person("esa_income_reported", period) + reported_total = benunit.sum(reported) + claimant_or_partner_reported = benunit.sum( + reported * person("is_claimant_or_partner", period) + ) award_on_all_reports = income_related_esa_award(benunit, period, reported_total) award_on_claimant_or_partner_reports = income_related_esa_award( - benunit, - period, - benunit.sum( - person("esa_income_reported", period) - * person("is_claimant_or_partner", period) - ), + benunit, period, claimant_or_partner_reported + ) + # Compare in the precision esa_income is stored in (float32), so the + # formula's own award always matches the award recomputed here. + stored = esa_income.dtype + as_formula = np.isclose( + esa_income, award_on_all_reports.astype(stored), rtol=0, atol=0.005 + ) + as_reported_total = np.isclose( + esa_income, reported_total.astype(stored), rtol=0, atol=0.005 + ) + scoped = where( + as_formula, + award_on_claimant_or_partner_reports, + where(as_reported_total, claimant_or_partner_reported, esa_income), ) - as_reported = np.isclose( - esa_income, award_on_all_reports, rtol=0, atol=0.005 - ) | np.isclose(esa_income, reported_total, rtol=0, atol=0.005) - return where(as_reported, award_on_claimant_or_partner_reports, esa_income) + return where(esa_income > 0, scoped, 0) diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py index 0892183278..d50a7a7883 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py @@ -7,20 +7,23 @@ class claimant_or_partner_jsa_income(Variable): entity = BenUnit label = "income-based JSA of the claimant or partner" documentation = ( - "Income-based JSA on the claimant's and partner's reported awards, " - "after the same capital test as jsa_income. Means tests and passports " - "that ask whether the claimant or partner is on income-based JSA " - "read this. Another member of the benefit unit who is neither the " - "claimant, the partner nor a child or young person they are " - "responsible for (for example a non-dependent adult) claims in their " - "own right, so their award is left out here; it still counts in " - "jsa_income and so in household income. When jsa_income holds what the " - "reported awards give, either after the capital test (its formula) " - "or as their plain total (the disable_simulated_benefits reform), the " - "reports say whose award it is. When it holds anything else (an " - "award entered directly, or a reform that replaces or removes it), " - "they do not, and it is taken to be the claimant's or partner's. An " - "entered award equal to either amount is read through the reports." + "Income-based JSA of the claimant and partner. Means tests and passports that " + "ask whether the claimant or partner is on income-based JSA read this. " + "Another member of the benefit unit who is neither the claimant, the " + "partner nor a child or young person they are responsible for (for " + "example a non-dependent adult) claims in their own right, so their " + "award is left out here; it still counts in jsa_income and so in " + "household income. Which awards jsa_income holds is decided by value: " + "when it holds the award its formula gives on all reported awards, " + "this is the same award on the claimant's and partner's reports; " + "when it holds the plain total of the reports (the " + "disable_simulated_benefits reform), this is the plain total of the " + "claimant's and partner's reports; when it holds anything else (an " + "award entered directly, or a reform that replaces it), that value " + "is taken to be the claimant's or partner's. A stored zero is zero. " + "An entered jsa_income equal to either reported amount is read through " + "the reports, so to enter the claimant's or partner's award whatever " + "other members report, enter this variable directly." ) definition_period = YEAR unit = GBP @@ -33,17 +36,27 @@ class claimant_or_partner_jsa_income(Variable): def formula(benunit, period, parameters): person = benunit.members jsa_income = benunit("jsa_income", period) - reported_total = add(benunit, period, ["jsa_income_reported"]) + reported = person("jsa_income_reported", period) + reported_total = benunit.sum(reported) + claimant_or_partner_reported = benunit.sum( + reported * person("is_claimant_or_partner", period) + ) award_on_all_reports = income_related_jsa_award(benunit, period, reported_total) award_on_claimant_or_partner_reports = income_related_jsa_award( - benunit, - period, - benunit.sum( - person("jsa_income_reported", period) - * person("is_claimant_or_partner", period) - ), + benunit, period, claimant_or_partner_reported + ) + # Compare in the precision jsa_income is stored in (float32), so the + # formula's own award always matches the award recomputed here. + stored = jsa_income.dtype + as_formula = np.isclose( + jsa_income, award_on_all_reports.astype(stored), rtol=0, atol=0.005 + ) + as_reported_total = np.isclose( + jsa_income, reported_total.astype(stored), rtol=0, atol=0.005 + ) + scoped = where( + as_formula, + award_on_claimant_or_partner_reports, + where(as_reported_total, claimant_or_partner_reported, jsa_income), ) - as_reported = np.isclose( - jsa_income, award_on_all_reports, rtol=0, atol=0.005 - ) | np.isclose(jsa_income, reported_total, rtol=0, atol=0.005) - return where(as_reported, award_on_claimant_or_partner_reports, jsa_income) + return where(jsa_income > 0, scoped, 0) diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 473155bfd2..69d9205c9a 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -43,10 +43,9 @@ def formula(benunit, period, parameters): "iidb", # Industrial injuries disability benefit ] - # ESA and Working Tax Credit. HB Regs 2006 reg 75F(1)(a) exempts a - # claimant or partner receiving ESA, so only their income-related award. + # ESA and Working Tax Credit QUAL_BENUNIT_BENEFITS = [ - "claimant_or_partner_esa_income", # Income-based ESA + "esa_income", # Income-based ESA "working_tax_credit", # If getting WTC, likely working enough ] 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 98a682ac8f..6d86d1cc9c 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 @@ -13,7 +13,7 @@ class is_on_income_based_jsa(Variable): "award (claimant_or_partner_jsa_income) is positive. Any other member " "of the benefit unit, such as a non-dependent adult, claims in their " "own right and is on it only if they report an award themselves while " - "income-based JSA is in payment." + "the benefit unit's modelled income-based JSA is positive." ) definition_period = YEAR reference = ( @@ -22,7 +22,10 @@ class is_on_income_based_jsa(Variable): ) def formula(person, period, parameters): - active = parameters(period).gov.dwp.JSA.income.active couple_award = person.benunit("claimant_or_partner_jsa_income", period) > 0 - own_award = active & (person("jsa_income_reported", period) > 0) + # Their own report, while the model pays income-based JSA in the + # benefit unit (so not once it closes or a reform removes it). + own_award = (person("jsa_income_reported", period) > 0) & ( + person.benunit("jsa_income", period) > 0 + ) return where(person("is_claimant_or_partner", period), couple_award, own_award) 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 6435ab9037..52eb65c3d6 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 @@ -13,7 +13,8 @@ class is_on_income_related_esa(Variable): "both on it when their award (claimant_or_partner_esa_income) is " "positive. Any other member of the benefit unit, such as a " "non-dependent adult, claims in their own right and is on it only if " - "they report an award themselves." + "they report an award themselves while the benefit unit's modelled " + "income-related ESA is positive." ) definition_period = YEAR reference = ( @@ -23,5 +24,9 @@ class is_on_income_related_esa(Variable): def formula(person, period, parameters): couple_award = person.benunit("claimant_or_partner_esa_income", period) > 0 - own_award = person("esa_income_reported", period) > 0 + # Their own report, while the model pays income-related ESA in the + # benefit unit (so not once a reform removes or zeroes it). + own_award = (person("esa_income_reported", period) > 0) & ( + person.benunit("esa_income", period) > 0 + ) return where(person("is_claimant_or_partner", period), couple_award, own_award) diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_support.py b/policyengine_uk/variables/gov/dwp/is_on_income_support.py index 8ba5563b70..c772d6ff37 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_support.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_support.py @@ -15,7 +15,7 @@ class is_on_income_support(Variable): "on it when it is positive. Any other member of the benefit unit, " "such as a non-dependent adult, claims in their own right and is on " "it only if they report an award themselves while Income Support is " - "in payment." + "in payment and no reform removes it." ) definition_period = YEAR reference = ( @@ -25,6 +25,14 @@ class is_on_income_support(Variable): def formula(person, period, parameters): active = parameters(period).gov.dwp.income_support.active + removed = person.simulation.tax_benefit_system.get_variable( + "income_support" + ).is_neutralized couple_award = person.benunit("income_support", period) > 0 - own_award = active & (person("income_support_reported", period) > 0) + # Their own report, while Income Support is in payment and no reform + # removes it. The model calculates the award only for the claimant + # and partner, so it cannot gate another member's own claim. + own_award = ( + active & (not removed) & (person("income_support_reported", period) > 0) + ) return where(person("is_claimant_or_partner", period), couple_award, own_award) diff --git a/policyengine_uk/variables/gov/social_security_scotland/is_scp_eligible.py b/policyengine_uk/variables/gov/social_security_scotland/is_scp_eligible.py index 0bb70bb589..c51be2694e 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/is_scp_eligible.py +++ b/policyengine_uk/variables/gov/social_security_scotland/is_scp_eligible.py @@ -40,8 +40,9 @@ def formula(person, period, parameters): ) & qb.income_support # SSI 2020/351 reg 18(e)-(f): the individual responsible for the child # (through their own or their partner's Child Benefit, Pension Credit or - # Universal Credit award, regs 9 and 12) has been awarded the benefit. - # That is the claimant or partner, not another member of the benefit unit. + # Universal Credit award, or as a kinship carer: regs 9, 11 and 12) has + # been awarded the benefit. The model takes that individual to be the + # claimant or partner, not another member of the benefit unit. receives_jsa_income = ( benunit("claimant_or_partner_jsa_income", period) > 0 ) & qb.jsa_income From f551e542b94ef9c6874f146d8aae6e6e4104ea48 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:20:46 -0400 Subject: [PATCH 07/11] Make "on" a legacy award payee-only (HB Regs 2006 reg 2(1), (3), (3A)) A person is on income-based JSA or income-related ESA on a day it "is payable to him" (reg 2(3), (3A)), and a person on income support is one "in receipt of" it (reg 2(1)). A couple's award is paid to the claimant, not the partner. is_on_income_related_esa, is_on_income_based_jsa and is_on_income_support now hold, of the claimant and partner, only for the payee: the one who reports the award, or the claimant where neither does (is_payee_of_couple_award). Another member's report never moves the payee. Anyone else is unchanged (their own report while the award is modelled as paid). Agreed with #2007, whose round-2 review raised the same point and which applies the same reading through its is_award_payee helper; the codebase now reads "who is on" one way. Effects: in the local working-age CTR schemes a non-dependant couple on one of these benefits pays the partner's deduction (one deduction per couple, the higher: Default Scheme Sch para 30(3)); the maintenance-loan ESA proxy follows the student's own award, not their partner's. Co-Authored-By: Claude Opus 5.5 --- ...cy-award-readers-claimant-partner.added.md | 2 +- ...cy-award-readers-claimant-partner.fixed.md | 2 +- .../council_tax_reduction_passports.yaml | 35 +++++++++++++++++++ ...ome_support_take_up_and_person_awards.yaml | 12 ++++--- .../legacy_award_readers/passports.yaml | 10 +++--- .../test_legacy_award_readers_properties.py | 34 ++++++++++++++++-- .../variables/gov/dwp/_legacy_award_payee.py | 25 +++++++++++++ .../gov/dwp/is_on_income_based_jsa.py | 24 ++++++++----- .../gov/dwp/is_on_income_related_esa.py | 19 ++++++---- .../variables/gov/dwp/is_on_income_support.py | 20 +++++++---- 10 files changed, 147 insertions(+), 36 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/_legacy_award_payee.py diff --git a/changelog.d/legacy-award-readers-claimant-partner.added.md b/changelog.d/legacy-award-readers-claimant-partner.added.md index ebba6e7649..030830fd7d 100644 --- a/changelog.d/legacy-award-readers-claimant-partner.added.md +++ b/changelog.d/legacy-award-readers-claimant-partner.added.md @@ -1,2 +1,2 @@ - Add `claimant_or_partner_esa_income` and `claimant_or_partner_jsa_income`: the income-related award of the claimant and partner. When `esa_income` or `jsa_income` holds what the reported awards give (its formula, or their plain total), this is the same on the claimant's and partner's reports; any other stored value is taken to be theirs, and a stored zero is zero. An entered award equal to either reported amount is read through the reports, so enter these variables directly to state the claimant's or partner's award. -- Add `is_on_income_related_esa`, `is_on_income_based_jsa` and `is_on_income_support`: whether the award is payable to the person, through their couple's award for the claimant and partner and through their own report for anyone else while the benefit is modelled as paid. +- Add `is_on_income_related_esa`, `is_on_income_based_jsa` and `is_on_income_support`: whether the award is payable to the person. Of the claimant and partner, that is the payee of their award (the one who reports it, or the claimant); for anyone else, their own report while the benefit is modelled as paid. diff --git a/changelog.d/legacy-award-readers-claimant-partner.fixed.md b/changelog.d/legacy-award-readers-claimant-partner.fixed.md index f72e9ef575..901f024638 100644 --- a/changelog.d/legacy-award-readers-claimant-partner.fixed.md +++ b/changelog.d/legacy-award-readers-claimant-partner.fixed.md @@ -1,3 +1,3 @@ - Count only the claimant's and partner's income-related ESA and income-based JSA in Housing Benefit, council tax reduction and tax credit income, and in the passports for working-age council tax reduction, Scottish Child Payment and targeted childcare. Another member of the benefit unit, such as a non-dependent adult, who reports an award claims in their own right; their award still counts in household income. -- Exempt a council tax reduction non-dependant only through their own or their couple's Income Support, income-based JSA or income-related ESA, not another benefit-unit member's. The maintenance loan schedule for students entitled to benefits now reads the student's own or their couple's income-related ESA, alongside the existing parent and disability tests. +- Exempt a council tax reduction non-dependant in the local working-age schemes only when Income Support, income-based JSA or income-related ESA is payable to them, not to their partner or another benefit-unit member. A non-dependant couple on one of these benefits therefore pays the partner's deduction. The maintenance loan schedule for students entitled to benefits now reads whether income-related ESA is payable to the student, alongside the existing parent and disability tests. - Treat a family as claiming Income Support (`would_claim_IS`) when the claimant or partner reports it, or when every entitled benefit is claimed (`claims_all_entitled_benefits`); another member's report no longer counts. 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 ae7f84b186..67e623218c 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 @@ -194,3 +194,38 @@ local_authority: MERTON output: merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 270.40] + +- name: A non-dependant couple on ESA pays the partner's deduction + period: 2027 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 50 + payee: + age: 30 + is_claimant_or_partner: true + current_education: NOT_IN_EDUCATION + esa_income_reported: 4_000 + partner: + age: 29 + is_claimant_or_partner: true + current_education: NOT_IN_EDUCATION + benunits: + applicant_unit: + members: [applicant] + nondep_unit: + members: [payee, partner] + universal_credit: 0 + households: + household: + members: [applicant, payee, partner] + country: ENGLAND + local_authority: MERTON + output: + # ESA is payable to the payee only (HB Regs 2006 reg 2(3A), as the + # schemes define it), so only they are exempt. One deduction is made for + # the couple, the higher of the two (Default Scheme Sch para 30(3)): the + # partner's £5.20 a week. + is_on_income_related_esa: [false, true, false] + merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 270.40] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml index 46698f7f5d..561dd35b63 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/income_support_take_up_and_person_awards.yaml @@ -81,11 +81,13 @@ income_support_assessable_capital: [5_000, 5_000] # is_on_income_related_esa, is_on_income_based_jsa and is_on_income_support: -# "on" a benefit when it is payable to the person (HB Regs 2006 reg 2(1), -# (3), (3A)). A couple's award covers both partners; another member is on it -# only through an award they report themselves. +# "on" a benefit when it "is payable to him" (HB Regs 2006 reg 2(3), (3A)) or +# they are "in receipt of" it (reg 2(1)). A couple's award is paid to the +# claimant: of the claimant and partner only the payee (the one who reports +# it) is on it. Another member is on it only through an award they report +# themselves. -- name: Both partners are on the couple's income-related ESA +- name: Only the payee is on the couple's income-related ESA period: 2025 input: people: @@ -107,7 +109,7 @@ household: members: [claimant, partner, other] output: - is_on_income_related_esa: [true, true, false] + is_on_income_related_esa: [true, false, false] - name: Another member is on their own income-related ESA and the couple is not period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml index a9b57d0296..94a9a019b7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml @@ -369,9 +369,9 @@ # Maintenance loans: the "students entitled to benefits" schedule is for a # student who could claim benefits themselves (SI 2011/1986 regs 61(2) and -# 71(1)(h)). The model's proxy reads the student's own income-related ESA, or -# their couple's; a parent's award does not make a student living in the -# parent's benefit unit (neither claimant, partner nor dependant) eligible. +# 71(1)(h)). The model's proxy reads whether income-related ESA is payable to +# the student; a parent's or partner's award does not make the student +# eligible. - name: A parent's income-related ESA does not put a student on the benefits schedule period: 2025 @@ -420,7 +420,7 @@ output: maintenance_loan_entitled_to_benefits: [true, true] -- name: A student in a couple on income-related ESA is on the benefits schedule +- name: A student whose partner is paid income-related ESA is not on the benefits schedule through it period: 2025 input: people: @@ -440,4 +440,4 @@ members: [student, partner] country: ENGLAND output: - maintenance_loan_entitled_to_benefits: [true, true] + maintenance_loan_entitled_to_benefits: [false, true] diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 980f37744d..e986e53eb9 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -26,8 +26,10 @@ - the claimant-or-partner awards are bounded by the benefit-unit awards (0 <= claimant_or_partner_esa_income <= esa_income, likewise for JSA) and equal them when no other member reports an award; -- a person is on an award exactly when their couple's award is positive (the - claimant and partner) or they report one themselves (anyone else). +- 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 they report one themselves while the benefit + unit's modelled award is positive. Roles are given explicitly (is_claimant_or_partner), so the properties test the readers rather than role inference. The take-up mode is fixed @@ -262,6 +264,27 @@ def test_claimant_or_partner_awards_match_reference(drawn): assert np.isclose(award[i], expected), (variable, units[i]) +def payee(sim, claimant_or_partner, reports): + """The claimant or partner who reports the award, or the claimant (the + head, else the eldest of the couple) where neither does.""" + benunit = sim.calculate("benunit_id", YEAR, map_to="person") + head = sim.calculate("is_benunit_head", YEAR) & claimant_or_partner + age = sim.calculate("age", YEAR) + result = np.zeros(len(benunit), dtype=bool) + for unit in np.unique(benunit): + members = benunit == unit + couple = members & claimant_or_partner + reporting = couple & reports + if reporting.any(): + result |= reporting + elif (members & head).any(): + result |= members & head + elif couple.any(): + eldest = np.flatnonzero(couple)[np.argmax(age[couple])] + result[eldest] = True + return result + + @SETTINGS @given(st.lists(st.tuples(families(), other_members()), min_size=1, max_size=6)) def test_person_is_on_award_from_couple_or_own_report(drawn): @@ -284,8 +307,13 @@ def test_person_is_on_award_from_couple_or_own_report(drawn): ("is_on_income_support", "income_support", "income_support_reported", None), ]: on = sim.calculate(person_variable, YEAR) - couple = sim.calculate(couple_award, YEAR, map_to="person") > 0 own = sim.calculate(report, YEAR) > 0 + # Of the claimant and partner, only the payee is on a couple's award: + # the one who reports it, or the claimant where neither does + # (HB Regs 2006 reg 2(3), (3A): "payable to him"). + couple = (sim.calculate(couple_award, YEAR, map_to="person") > 0) & payee( + sim, claimant_or_partner, own + ) if total_award is not None: # Another member's own report counts while the benefit unit's # modelled award is positive. diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py new file mode 100644 index 0000000000..4462ef2927 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -0,0 +1,25 @@ +"""Who is "on" a legacy income-related award.""" + +from policyengine_uk.model_api import * + + +def is_payee_of_couple_award(person, period, reported): + """Whether the claimant's or partner's family award is payable to this + person. A person is on income-based JSA or income-related ESA on a day + the allowance "is payable to him" (HB Regs 2006 reg 2(3) and (3A)); a + person on Income Support is one "in receipt of" it (reg 2(1)). A couple's + award is paid to the claimant, not the partner. The payee is the + claimant or partner who reports the award, or the claimant where neither + does (an award entered directly). Another member's report never moves + the payee.""" + claimant_or_partner = person("is_claimant_or_partner", period) + reports = (person(reported, period) > 0) & claimant_or_partner + head = claimant_or_partner & person("is_benunit_head", period) + eldest = claimant_or_partner & ( + person.get_rank( + person.benunit, -person("age", period), condition=claimant_or_partner + ) + == 0 + ) + claimant = where(person.benunit.any(head), head, eldest) + return where(person.benunit.any(reports), reports, claimant) 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 6d86d1cc9c..665c7c6abf 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 @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._legacy_award_payee import ( + is_payee_of_couple_award, +) class is_on_income_based_jsa(Variable): @@ -7,13 +10,16 @@ class is_on_income_based_jsa(Variable): label = "on income-based JSA" documentation = ( "Whether an income-based jobseeker's allowance is payable to this " - "person (HB Regs 2006 reg 2(3); the council tax reduction schemes use " - "the same definition). A couple's award covers both partners, and the " - "model treats the claimant and the partner as both on it when their " - "award (claimant_or_partner_jsa_income) is positive. Any other member " - "of the benefit unit, such as a non-dependent adult, claims in their " - "own right and is on it only if they report an award themselves while " - "the benefit unit's modelled income-based JSA is positive." + 'person (HB Regs 2006 reg 2(3): on any day it "is payable to him"; ' + "the council tax reduction schemes use the same definition). A " + "couple's award is paid to the claimant, not the partner: of the " + "claimant and partner, only the payee is on it, meaning the one who " + "reports the award, or the claimant where neither does, and only " + "while their award (claimant_or_partner_jsa_income) is positive. The " + "model has no joint-claim JSA input. Any other member of the benefit " + "unit, such as a non-dependent adult, claims in their own right and " + "is on it only if they report an award themselves while the benefit " + "unit's modelled income-based JSA is positive." ) definition_period = YEAR reference = ( @@ -22,7 +28,9 @@ class is_on_income_based_jsa(Variable): ) def formula(person, period, parameters): - couple_award = person.benunit("claimant_or_partner_jsa_income", period) > 0 + couple_award = ( + person.benunit("claimant_or_partner_jsa_income", period) > 0 + ) & (is_payee_of_couple_award(person, period, "jsa_income_reported")) # Their own report, while the model pays income-based JSA in the # benefit unit (so not once it closes or a reform removes it). own_award = (person("jsa_income_reported", period) > 0) & ( 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 52eb65c3d6..cf71922a90 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 @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._legacy_award_payee import ( + is_payee_of_couple_award, +) class is_on_income_related_esa(Variable): @@ -7,11 +10,13 @@ class is_on_income_related_esa(Variable): label = "on income-related ESA" documentation = ( "Whether an income-related employment and support allowance is " - "payable to this person (HB Regs 2006 reg 2(3A); the council tax " - "reduction schemes use the same definition). A couple's award covers " - "both partners, and the model treats the claimant and the partner as " - "both on it when their award (claimant_or_partner_esa_income) is " - "positive. Any other member of the benefit unit, such as a " + 'payable to this person (HB Regs 2006 reg 2(3A): on any day it "is ' + 'payable to him"; the council tax reduction schemes use the same ' + "definition). A couple's award is paid to the claimant, not the " + "partner: of the claimant and partner, only the payee is on it, " + "meaning the one who reports the award, or the claimant where neither " + "does, and only while their award (claimant_or_partner_esa_income) " + "is positive. Any other member of the benefit unit, such as a " "non-dependent adult, claims in their own right and is on it only if " "they report an award themselves while the benefit unit's modelled " "income-related ESA is positive." @@ -23,7 +28,9 @@ class is_on_income_related_esa(Variable): ) def formula(person, period, parameters): - couple_award = person.benunit("claimant_or_partner_esa_income", period) > 0 + couple_award = ( + person.benunit("claimant_or_partner_esa_income", period) > 0 + ) & (is_payee_of_couple_award(person, period, "esa_income_reported")) # Their own report, while the model pays income-related ESA in the # benefit unit (so not once a reform removes or zeroes it). own_award = (person("esa_income_reported", period) > 0) & ( diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_support.py b/policyengine_uk/variables/gov/dwp/is_on_income_support.py index c772d6ff37..c3f8dc5cee 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_support.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_support.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp._legacy_award_payee import ( + is_payee_of_couple_award, +) class is_on_income_support(Variable): @@ -10,12 +13,13 @@ class is_on_income_support(Variable): 'income support", HB Regs 2006 reg 2(1); the council tax reduction ' "schemes use the same definition). Income Support is calculated for " "the claimant's family and needs the claimant's or partner's own " - "award (income_support_eligible). A couple's award covers both " - "partners, and the model treats the claimant and the partner as both " - "on it when it is positive. Any other member of the benefit unit, " - "such as a non-dependent adult, claims in their own right and is on " - "it only if they report an award themselves while Income Support is " - "in payment and no reform removes it." + "award (income_support_eligible). It is paid to the claimant, not " + "the partner: of the claimant and partner, only the payee is in " + "receipt, meaning the one who reports it, or the claimant where " + "neither does, and only while it is positive. Any other member of the " + "benefit unit, such as a non-dependent adult, claims in their own " + "right and is on it only if they report an award themselves while " + "Income Support is in payment and no reform removes it." ) definition_period = YEAR reference = ( @@ -28,7 +32,9 @@ def formula(person, period, parameters): removed = person.simulation.tax_benefit_system.get_variable( "income_support" ).is_neutralized - couple_award = person.benunit("income_support", period) > 0 + couple_award = (person.benunit("income_support", period) > 0) & ( + is_payee_of_couple_award(person, period, "income_support_reported") + ) # Their own report, while Income Support is in payment and no reform # removes it. The model calculates the award only for the claimant # and partner, so it cannot gate another member's own claim. From b8b4d7b509a4c2fa16969bbb41c5bfd5b6b94b42 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 09:57:54 -0400 Subject: [PATCH 08/11] Screen an outside member's own ESA/JSA report on its own is_on_income_related_esa and is_on_income_based_jsa gated a member outside the couple on the benefit unit's award over every report. Another member's report could then lift that award above the tariff income and put them on it: an excluded student's loan schedule or a Merton non-dependant deduction changed when another adult was added (review r3's probes on 3803dda0d). They now ask whether the award on that member's own report alone is positive: the report exceeds the tariff income from the benefit unit's capital, within the capital limit, while the benefit unit's award is positive. Once the member reports, the capital test reads the household's capital whoever else reports, so no other report can change the result. The property generators now give qualifying young persons and an existing outside adult reports of their own, and the Merton property has savings and an existing outside adult; both failed on the old code. Two YAML cases are hand-computed from ESA Regs 2008 reg 118. The maintenance-loan proxy cites SI 2011/1986 reg 71(1)(h)(iii), reg 61(2)(b) and HB Regs 2006 reg 56(2)(a). Co-Authored-By: Claude Opus 5.5 --- ...cy-award-readers-claimant-partner.added.md | 2 +- .../council_tax_reduction_passports.yaml | 42 ++++++ .../legacy_award_readers/passports.yaml | 47 ++++++- .../test_legacy_award_readers_properties.py | 126 +++++++++++++----- .../maintenance_loan_entitled_to_benefits.py | 12 +- .../variables/gov/dwp/_legacy_award_payee.py | 27 ++++ .../gov/dwp/is_on_income_based_jsa.py | 14 +- .../gov/dwp/is_on_income_related_esa.py | 13 +- 8 files changed, 225 insertions(+), 58 deletions(-) diff --git a/changelog.d/legacy-award-readers-claimant-partner.added.md b/changelog.d/legacy-award-readers-claimant-partner.added.md index 030830fd7d..2c28bebf0d 100644 --- a/changelog.d/legacy-award-readers-claimant-partner.added.md +++ b/changelog.d/legacy-award-readers-claimant-partner.added.md @@ -1,2 +1,2 @@ - Add `claimant_or_partner_esa_income` and `claimant_or_partner_jsa_income`: the income-related award of the claimant and partner. When `esa_income` or `jsa_income` holds what the reported awards give (its formula, or their plain total), this is the same on the claimant's and partner's reports; any other stored value is taken to be theirs, and a stored zero is zero. An entered award equal to either reported amount is read through the reports, so enter these variables directly to state the claimant's or partner's award. -- Add `is_on_income_related_esa`, `is_on_income_based_jsa` and `is_on_income_support`: whether the award is payable to the person. Of the claimant and partner, that is the payee of their award (the one who reports it, or the claimant); for anyone else, their own report while the benefit is modelled as paid. +- Add `is_on_income_related_esa`, `is_on_income_based_jsa` and `is_on_income_support`: whether the award is payable to the person. Of the claimant and partner, that is the payee of their award (the one who reports it, or the claimant); for anyone else, the award on their own report alone (after tariff income from the benefit unit's capital, within the capital limit; for Income Support, their report) while the benefit is modelled as paid, so another member's report never changes it. 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 67e623218c..8a41a02487 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 @@ -229,3 +229,45 @@ # partner's £5.20 a week. is_on_income_related_esa: [false, true, false] merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 270.40] + +- name: A non-dependant's own ESA report below the tariff income does not exempt them, whatever another member reports + period: 2027 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 50 + nondep: + age: 30 + is_claimant_or_partner: true + current_education: NOT_IN_EDUCATION + lodger: + age: 45 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 200 + other: + age: 25 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 3_000 + benunits: + applicant_unit: + members: [applicant] + nondep_unit: + members: [nondep, lodger, other] + universal_credit: 0 + households: + household: + members: [applicant, nondep, lodger, other] + country: ENGLAND + local_authority: MERTON + savings: 7_000 + output: + # Tariff income on £7,000 of savings is £208 a year (ESA Regs 2008 + # reg 118), so the £200 report alone gives no award: that adult is not on + # income-related ESA and pays the £5.20 a week deduction, as the + # non-dependant claimant does. The adult reporting £3,000 is paid £2,792 + # and is exempt (Default Scheme Sch para 30(8)(a)). + is_on_income_related_esa: [false, false, false, true] + merton_council_tax_reduction_individual_non_dep_deduction: [0, 270.40, 270.40, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml index 94a9a019b7..67bf142d83 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml @@ -367,10 +367,12 @@ output: is_benefit_cap_exempt_health_disability: true -# Maintenance loans: the "students entitled to benefits" schedule is for a -# student who could claim benefits themselves (SI 2011/1986 regs 61(2) and -# 71(1)(h)). The model's proxy reads whether income-related ESA is payable to -# the student; a parent's or partner's award does not make the student +# Maintenance loans: the "students entitled to benefits" schedule (SI 2011/1986 +# reg 71(1)(h)(iii)) follows the special support grant test in reg 61(2)(b), +# which follows HB Regs 2006 reg 56(2)(a): a full-time student "who is a +# person on" income-related ESA, that is, one to whom it "is payable" +# (reg 2(3A)). The model's proxy reads whether income-related ESA is payable +# to the student; a parent's or partner's award does not make the student # eligible. - name: A parent's income-related ESA does not put a student on the benefits schedule @@ -441,3 +443,40 @@ country: ENGLAND output: maintenance_loan_entitled_to_benefits: [false, true] + +- name: A student's own ESA report below the tariff income is not paid, whatever another member reports + period: 2025 + input: + people: + parent: + age: 50 + is_claimant_or_partner: true + is_parent: false + student: + age: 19 + is_claimant_or_partner: false + current_education: TERTIARY + esa_income_reported: 200 + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 3_000 + benunits: + benunit: + members: [parent, student, other] + households: + household: + members: [parent, student, other] + country: ENGLAND + savings: 7_000 + output: + # Savings of £7,000 are £1,000 above £6,000: 4 steps of £250, so tariff + # income of £4 a week, £208 a year (ESA Regs 2008 reg 118). The student's + # £200 alone gives no award; the other adult's £3,000 gives £2,792. The + # benefit unit's award on both reports, £2,992, is household income but + # does not make the student's report paid. + esa_income: [2_992] + claimant_or_partner_esa_income: [0] + is_on_income_related_esa: [false, false, true] + maintenance_loan_entitled_to_benefits: [false, false, true] diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index e986e53eb9..3e7a334944 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -13,8 +13,8 @@ - The tax credit income test: TCA 2002 s.7(2), SI 2002/2008 reg 4. - Scottish Child Payment: SSI 2020/351 reg 18(e)-(f). - Targeted childcare: SI 2014/2147 reg 1(2). -- Maintenance loans for students entitled to benefits: SI 2011/1986 regs - 61(2) and 71(1)(h). +- Maintenance loans for students entitled to benefits: SI 2011/1986 reg + 71(1)(h)(iii), through reg 61(2)(b) and HB Regs 2006 reg 56(2)(a). A member of the benefit unit who is neither the claimant, the partner nor a child or young person they are responsible for (for example a non-dependent @@ -28,8 +28,9 @@ equal them when no other member reports an award; - 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 they report one themselves while the benefit - unit's modelled award is positive. + or, for anyone else, when the award on their own report alone is positive + after tariff income from the household's capital and within the capital + limit (Income Support: when they report it). Roles are given explicitly (is_claimant_or_partner), so the properties test the readers rather than role inference. The take-up mode is fixed @@ -82,7 +83,13 @@ def award_reports(draw): @st.composite def families(draw): - """A claimant, an optional partner and up to two dependants.""" + """A claimant, an optional partner, up to two dependants and sometimes an + adult outside the couple who is already a member. + + Qualifying young persons and the existing outside adult may report awards + of their own, so the properties also cover members who are on an award + in their own right. + """ dependants = [] for _ in range(draw(st.integers(0, 2))): if draw(st.booleans()): @@ -93,6 +100,7 @@ def families(draw): { "age": draw(st.integers(16, 19)), "current_education": "UPPER_SECONDARY", + **draw(award_reports()), } ) eldest_dependant = max([d["age"] for d in dependants], default=0) @@ -111,11 +119,22 @@ def families(draw): **draw(award_reports()), } ) + non_couple = list(dependants) + if draw(st.booleans()): + non_couple.append( + { + "age": draw(st.integers(20, 60)), + "current_education": "NOT_IN_EDUCATION", + **draw(award_reports()), + } + ) household = { "country": draw(st.sampled_from(["ENGLAND", "SCOTLAND", "WALES"])), + # £7,000 gives tariff income of £208 a year, more than a £200 report + # on its own but less than two reports together. "savings": draw(st.sampled_from([0, 7_000, 20_000])), } - return adults, dependants, household + return adults, non_couple, household @st.composite @@ -135,8 +154,8 @@ def other_members(draw): def situation(units, year=YEAR): people, benunits, households = {}, {}, {} - for i, (adults, dependants, household, other) in enumerate(units): - members = [(m, True) for m in adults] + [(m, False) for m in dependants] + for i, (adults, non_couple, household, other) in enumerate(units): + members = [(m, True) for m in adults] + [(m, False) for m in non_couple] if other is not None: members.append((other, False)) names = [] @@ -213,14 +232,16 @@ def test_claimant_or_partner_awards_bounded_by_benefit_unit_awards(drawn): total_award = sim.calculate(total, YEAR) assert (scoped_award >= 0).all() assert (scoped_award <= total_award + 0.01).all() - for i, (_, _, _, other) in enumerate(units): - if other[report] == 0: - # Nobody else reports this award: the two coincide. + for i, (_, non_couple, _, other) in enumerate(units): + if other[report] == 0 and all(m.get(report, 0) == 0 for m in non_couple): + # Nobody outside the couple reports this award: the two + # coincide. assert np.isclose(scoped_award[i], total_award[i]), (scoped, units[i]) -def reference_claimant_or_partner_award(adults, other, household, report, params): - """The claimant-or-partner award read directly from the regulations. +def reference_award(reported, capital, params): + """The income-related award on a reported amount, read directly from the + regulations. Works on scalars or arrays. Capital is the household's savings (the only capital input here), all of it the benefit unit's, as it is the only one in its household. Tariff @@ -229,16 +250,23 @@ def reference_claimant_or_partner_award(adults, other, household, report, params regs 107 and 116, as parameterised). """ capital_rules = params.capital - capital = household["savings"] - anyone_reports = other[report] > 0 or any(a[report] > 0 for a in adults) - if not anyone_reports or capital > capital_rules.limit: - return 0.0 steps = np.ceil( - max(0, capital - capital_rules.tariff_income.threshold) + np.maximum(0, capital - capital_rules.tariff_income.threshold) / capital_rules.tariff_income.step ) tariff = steps * capital_rules.tariff_income.amount * 52 - return max(0.0, sum(a[report] for a in adults) - tariff) + return np.where( + (reported > 0) & (capital <= capital_rules.limit), + np.maximum(0.0, reported - tariff), + 0.0, + ) + + +def reference_claimant_or_partner_award(adults, household, report, params): + """The award on the claimant's and partner's reports.""" + return float( + reference_award(sum(a[report] for a in adults), household["savings"], params) + ) @SETTINGS @@ -259,7 +287,7 @@ def test_claimant_or_partner_awards_match_reference(drawn): award = sim.calculate(variable, YEAR) for i, (adults, _, household, other) in enumerate(units): expected = active * reference_claimant_or_partner_award( - adults, other, household, report, params + adults, household, report, params ) assert np.isclose(award[i], expected), (variable, units[i]) @@ -291,33 +319,45 @@ def test_person_is_on_award_from_couple_or_own_report(drawn): units = [(*family, other) for family, other in drawn] sim = Simulation(situation=situation(units)) claimant_or_partner = sim.calculate("is_claimant_or_partner", YEAR) - for person_variable, couple_award, report, total_award in [ + capital = sim.calculate("savings", YEAR, map_to="person") + dwp = sim.tax_benefit_system.parameters(YEAR).gov.dwp + for person_variable, couple_award, report, params, active in [ ( "is_on_income_related_esa", "claimant_or_partner_esa_income", "esa_income_reported", - "esa_income", + dwp.ESA.income, + True, ), ( "is_on_income_based_jsa", "claimant_or_partner_jsa_income", "jsa_income_reported", - "jsa_income", + dwp.JSA.income, + dwp.JSA.income.active, + ), + ( + "is_on_income_support", + "income_support", + "income_support_reported", + None, + dwp.income_support.active, ), - ("is_on_income_support", "income_support", "income_support_reported", None), ]: on = sim.calculate(person_variable, YEAR) - own = sim.calculate(report, YEAR) > 0 + reported = sim.calculate(report, YEAR) # Of the claimant and partner, only the payee is on a couple's award: # the one who reports it, or the claimant where neither does # (HB Regs 2006 reg 2(3), (3A): "payable to him"). couple = (sim.calculate(couple_award, YEAR, map_to="person") > 0) & payee( - sim, claimant_or_partner, own + sim, claimant_or_partner, reported > 0 ) - if total_award is not None: - # Another member's own report counts while the benefit unit's - # modelled award is positive. - own = own & (sim.calculate(total_award, YEAR, map_to="person") > 0) + if params is None: + # Income Support: their own report, while it is in payment. + own = active & (reported > 0) + else: + # The award on their own report alone, on the household's capital. + own = active & (reference_award(reported, capital, params) > 0) expected = np.where(claimant_or_partner, couple, own) assert np.array_equal(on, expected), (person_variable, units) @@ -331,8 +371,10 @@ def test_person_is_on_award_from_couple_or_own_report(drawn): @given( st.lists( st.tuples( - st.lists(st.sampled_from([0, 0, 3_000]), min_size=1, max_size=2), + st.lists(st.sampled_from([0, 0, 200, 3_000]), min_size=1, max_size=2), + st.sampled_from([None, 0, 200, 3_000]), st.sampled_from([0, 3_000]), + st.sampled_from([0, 7_000]), ), min_size=1, max_size=6, @@ -342,15 +384,19 @@ def test_other_members_esa_never_changes_non_dependant_exemption(drawn): """A non-dependant is exempt only through their own (or couple's) award. Each household in Merton has an older applicant (the household head) and a - non-dependant benefit unit of one or two claimants, with or without another - adult who reports income-related ESA. The existing members' individual - deductions never change when the other adult is added. + non-dependant benefit unit of one or two claimants, sometimes with an adult + outside their couple who is already a member, with or without a further + adult who reports income-related ESA. Savings of £7,000 give tariff income + of £208 a year, more than a £200 report on its own. The existing members' + individual deductions never change when the further adult is added. """ year = NON_DEP_YEAR people, benunits, households = {}, {}, {} layouts = [] for variant, other_present in [(0, False), (1, True)]: - for i, (claimant_awards, other_award) in enumerate(drawn): + for i, (claimant_awards, existing_award, other_award, savings) in enumerate( + drawn + ): tag = f"{variant}_{i}" names = [f"applicant_{tag}"] people[names[0]] = {"age": {year: 60}} @@ -364,6 +410,15 @@ def test_other_members_esa_never_changes_non_dependant_exemption(drawn): "esa_income_reported": {year: award}, } unit.append(name) + if existing_award is not None: + name = f"existing_{tag}" + people[name] = { + "age": {year: 45}, + "is_claimant_or_partner": {year: False}, + "current_education": {year: "NOT_IN_EDUCATION"}, + "esa_income_reported": {year: existing_award}, + } + unit.append(name) if other_present: name = f"other_{tag}" people[name] = { @@ -383,6 +438,7 @@ def test_other_members_esa_never_changes_non_dependant_exemption(drawn): "members": names + unit, "country": {year: "ENGLAND"}, "local_authority": {year: "MERTON"}, + "savings": {year: savings}, } layouts.append(names + unit) sim = Simulation( diff --git a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py index ed88147cfd..29b0326d14 100644 --- a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py +++ b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py @@ -7,7 +7,8 @@ class maintenance_loan_entitled_to_benefits(Variable): label = "Entitled to benefits for maintenance loan assessment" documentation = ( "Proxy for the Student Finance England 'students entitled to benefits' maintenance loan schedule. " - "This uses observable parent/disability/ESA-related signals already present in the model." + "This uses observable parent/disability/ESA-related signals already present in the model; " + "the ESA signal is income-related ESA payable to the student (HB Regs 2006 reg 56(2)(a))." ) definition_period = YEAR @@ -27,9 +28,12 @@ def formula(person, period, parameters): > 0 ) has_child = person("is_parent", period) - # The student's own award (or their couple's), not another benefit-unit - # member's: the schedule is for students who could claim benefits - # themselves (SI 2011/1986 regs 61(2) and 71(1)(h)). + # Income-related ESA payable to the student, not to a partner, parent or + # other benefit-unit member. The schedule (SI 2011/1986 reg 71(1)(h)(iii)) + # follows the special support grant test in reg 61(2)(b), which in turn + # follows HB Regs 2006 reg 56(2)(a): a full-time student "who is a person + # on ... an income-related employment and support allowance", that is, + # one to whom it "is payable" (reg 2(3A)). receives_income_related_esa = person("is_on_income_related_esa", period) return has_child | qualifying_disability_support | receives_income_related_esa diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 4462ef2927..651d32fff1 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -23,3 +23,30 @@ def is_payee_of_couple_award(person, period, reported): ) claimant = where(person.benunit.any(head), head, eldest) return where(person.benunit.any(reports), reports, claimant) + + +def own_report_is_paid(person, period, award): + """Whether income-related ESA or income-based JSA (award: "esa_income" or + "jsa_income") is paid on this person's own report, for a member who is + neither the claimant nor the partner and so claims in their own right. + It is paid when the award on their report alone is positive: the report + exceeds the tariff income from the benefit unit's capital, within the + capital limit, as income_related_esa_award and income_related_jsa_award + screen it. Their own capital is not observed, so the benefit unit's + stands in for it. The benefit unit's award must also be positive, so a + reform that removes or zeroes the benefit removes the status too. + + Another member's report never changes this: once this person reports, + the capital test reads the household's capital whoever else reports, and + the award on their report alone does not depend on other reports. On the + formula path a positive award on their report implies a positive benefit + unit award, since adding reports never lowers it.""" + benunit = person.benunit + return ( + benunit(f"{award}_eligible", period) + & ( + person(f"{award}_reported", period) + > benunit(f"{award}_tariff_income", period) + ) + & (benunit(award, period) > 0) + ) 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 665c7c6abf..d22686eb13 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 @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.dwp._legacy_award_payee import ( is_payee_of_couple_award, + own_report_is_paid, ) @@ -18,8 +19,10 @@ class is_on_income_based_jsa(Variable): "while their award (claimant_or_partner_jsa_income) is positive. The " "model has no joint-claim JSA input. Any other member of the benefit " "unit, such as a non-dependent adult, claims in their own right and " - "is on it only if they report an award themselves while the benefit " - "unit's modelled income-based JSA is positive." + "is on it only if the award on their own report alone is positive: " + "the report exceeds the tariff income from the benefit unit's " + "capital, within the capital limit, while the benefit unit's " + "modelled award is positive." ) definition_period = YEAR reference = ( @@ -31,9 +34,6 @@ def formula(person, period, parameters): couple_award = ( person.benunit("claimant_or_partner_jsa_income", period) > 0 ) & (is_payee_of_couple_award(person, period, "jsa_income_reported")) - # Their own report, while the model pays income-based JSA in the - # benefit unit (so not once it closes or a reform removes it). - own_award = (person("jsa_income_reported", period) > 0) & ( - person.benunit("jsa_income", period) > 0 - ) + # The award on their own report alone, never another member's. + own_award = own_report_is_paid(person, period, "jsa_income") return where(person("is_claimant_or_partner", period), couple_award, own_award) 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 cf71922a90..0989dfd6de 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 @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * from policyengine_uk.variables.gov.dwp._legacy_award_payee import ( is_payee_of_couple_award, + own_report_is_paid, ) @@ -18,8 +19,9 @@ class is_on_income_related_esa(Variable): "does, and only while their award (claimant_or_partner_esa_income) " "is positive. Any other member of the benefit unit, such as a " "non-dependent adult, claims in their own right and is on it only if " - "they report an award themselves while the benefit unit's modelled " - "income-related ESA is positive." + "the award on their own report alone is positive: the report exceeds " + "the tariff income from the benefit unit's capital, within the " + "capital limit, while the benefit unit's modelled award is positive." ) definition_period = YEAR reference = ( @@ -31,9 +33,6 @@ def formula(person, period, parameters): couple_award = ( person.benunit("claimant_or_partner_esa_income", period) > 0 ) & (is_payee_of_couple_award(person, period, "esa_income_reported")) - # Their own report, while the model pays income-related ESA in the - # benefit unit (so not once a reform removes or zeroes it). - own_award = (person("esa_income_reported", period) > 0) & ( - person.benunit("esa_income", period) > 0 - ) + # The award on their own report alone, never another member's. + own_award = own_report_is_paid(person, period, "esa_income") return where(person("is_claimant_or_partner", period), couple_award, own_award) From fc1ff55d173c3d33134409da6b2016f866c44b30 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:33:16 -0400 Subject: [PATCH 09/11] Add the HB reg 56(2)(c) premium route to the student loan schedule Review r4: with "on" payee-only, a student whose partner is the income-related ESA payee lost the maintenance-loan benefits schedule, even where the partner's disability benefit puts the disability premium in the couple's applicable amount. HB Regs 2006 reg 56(2)(c) lifts the student exclusion for a full-time student "whose applicable amount would ... include the ... disability premium or severe disability premium", and for a couple the premium turns on either partner (Sch 3 paras 12(b)(ii), 13(1)(a)). maintenance_loan_entitled_to_benefits now also qualifies a student who is the claimant or partner when the benefit unit has the model's disability or severe disability premium. A dependant's parent's premium does not count. New YAML cases cover both; the property generator gives adults disability inputs. Also from reviews r4 and r3c (wording only): the changelog states the Income Support own-report condition (active and not neutralised) apart from ESA/JSA's; the claimant-or-partner awards say values are compared to within half a penny at stored precision; is_on_income_support says a reform that removes Income Support must neutralise it or switch it off. Co-Authored-By: Claude Opus 5.5 --- ...cy-award-readers-claimant-partner.added.md | 4 +- ...cy-award-readers-claimant-partner.fixed.md | 2 +- .../legacy_award_readers/passports.yaml | 57 +++++++++++++++++++ .../test_legacy_award_readers_properties.py | 9 +++ .../maintenance_loan_entitled_to_benefits.py | 34 ++++++++++- .../gov/dwp/claimant_or_partner_esa_income.py | 4 +- .../gov/dwp/claimant_or_partner_jsa_income.py | 4 +- .../variables/gov/dwp/is_on_income_support.py | 6 +- 8 files changed, 111 insertions(+), 9 deletions(-) diff --git a/changelog.d/legacy-award-readers-claimant-partner.added.md b/changelog.d/legacy-award-readers-claimant-partner.added.md index 2c28bebf0d..a40af33b58 100644 --- a/changelog.d/legacy-award-readers-claimant-partner.added.md +++ b/changelog.d/legacy-award-readers-claimant-partner.added.md @@ -1,2 +1,2 @@ -- Add `claimant_or_partner_esa_income` and `claimant_or_partner_jsa_income`: the income-related award of the claimant and partner. When `esa_income` or `jsa_income` holds what the reported awards give (its formula, or their plain total), this is the same on the claimant's and partner's reports; any other stored value is taken to be theirs, and a stored zero is zero. An entered award equal to either reported amount is read through the reports, so enter these variables directly to state the claimant's or partner's award. -- Add `is_on_income_related_esa`, `is_on_income_based_jsa` and `is_on_income_support`: whether the award is payable to the person. Of the claimant and partner, that is the payee of their award (the one who reports it, or the claimant); for anyone else, the award on their own report alone (after tariff income from the benefit unit's capital, within the capital limit; for Income Support, their report) while the benefit is modelled as paid, so another member's report never changes it. +- Add `claimant_or_partner_esa_income` and `claimant_or_partner_jsa_income`: the income-related award of the claimant and partner. When `esa_income` or `jsa_income` holds what the reported awards give (its formula, or their plain total), this is the same on the claimant's and partner's reports; any other stored value is taken to be theirs, and a stored zero is zero. An entered award equal to either reported amount, to within half a penny at stored precision, is read through the reports, so enter these variables directly to state the claimant's or partner's award. +- Add `is_on_income_related_esa`, `is_on_income_based_jsa` and `is_on_income_support`: whether the award is payable to the person. Of the claimant and partner, that is the payee of their award (the one who reports it, or the claimant); for anyone else, the award on their own report alone, after tariff income from the benefit unit's capital and within the capital limit, while the benefit unit's award is positive (for Income Support: their own report while Income Support is active and not neutralised), so another member's report never changes it. diff --git a/changelog.d/legacy-award-readers-claimant-partner.fixed.md b/changelog.d/legacy-award-readers-claimant-partner.fixed.md index 901f024638..86a00fdb69 100644 --- a/changelog.d/legacy-award-readers-claimant-partner.fixed.md +++ b/changelog.d/legacy-award-readers-claimant-partner.fixed.md @@ -1,3 +1,3 @@ - Count only the claimant's and partner's income-related ESA and income-based JSA in Housing Benefit, council tax reduction and tax credit income, and in the passports for working-age council tax reduction, Scottish Child Payment and targeted childcare. Another member of the benefit unit, such as a non-dependent adult, who reports an award claims in their own right; their award still counts in household income. -- Exempt a council tax reduction non-dependant in the local working-age schemes only when Income Support, income-based JSA or income-related ESA is payable to them, not to their partner or another benefit-unit member. A non-dependant couple on one of these benefits therefore pays the partner's deduction. The maintenance loan schedule for students entitled to benefits now reads whether income-related ESA is payable to the student, alongside the existing parent and disability tests. +- Exempt a council tax reduction non-dependant in the local working-age schemes only when Income Support, income-based JSA or income-related ESA is payable to them, not to their partner or another benefit-unit member. A non-dependant couple on one of these benefits therefore pays the partner's deduction. The maintenance loan schedule for students entitled to benefits now reads whether income-related ESA is payable to the student (HB Regs 2006 reg 56(2)(a)), alongside the existing parent and disability tests, and adds reg 56(2)(c): a student who is the claimant or partner qualifies when their applicable amount would include the disability or severe disability premium, as when their partner receives a disability benefit. - Treat a family as claiming Income Support (`would_claim_IS`) when the claimant or partner reports it, or when every entitled benefit is claimed (`claims_all_entitled_benefits`); another member's report no longer counts. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml index 67bf142d83..932e48fc5d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml @@ -444,6 +444,63 @@ output: maintenance_loan_entitled_to_benefits: [false, true] +- name: A student whose partner receives PIP qualifies through the disability premium + period: 2025 + input: + people: + student: + age: 22 + is_claimant_or_partner: true + current_education: TERTIARY + partner: + age: 23 + is_claimant_or_partner: true + esa_income_reported: 5_000 + pip_dl: 2_000 + is_disabled_for_benefits: true + benunits: + benunit: + members: [student, partner] + households: + household: + members: [student, partner] + country: ENGLAND + output: + # ESA is payable to the partner, not the student (reg 2(3A)). But the + # partner is under State Pension Credit age and receives PIP, so the + # couple's applicable amount includes the disability premium (HB Regs 2006 + # Sch 3 paras 12(b)(ii) and 13(1)(a)(i)), and reg 56(2)(c) lifts the + # student exclusion for the student too. + is_on_income_related_esa: [false, true] + maintenance_loan_entitled_to_benefits: [true, true] + +- name: A parent's disability premium does not put a dependent student on the benefits schedule + period: 2025 + input: + people: + parent: + age: 50 + is_claimant_or_partner: true + is_parent: false + pip_dl: 2_000 + is_disabled_for_benefits: true + student: + age: 19 + is_claimant_or_partner: false + current_education: TERTIARY + benunits: + benunit: + members: [parent, student] + households: + household: + members: [parent, student] + country: ENGLAND + output: + # The disability premium is in the parent's applicable amount, not the + # student's: reg 56(2)(c) asks about the student's own applicable amount + # as a claimant. + maintenance_loan_entitled_to_benefits: [true, false] + - name: A student's own ESA report below the tariff income is not paid, whatever another member reports period: 2025 input: diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 3e7a334944..4f3f480234 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -112,6 +112,15 @@ def families(draw): "employment_income": draw(st.sampled_from([0, 0, 8_000, 20_000])), "is_parent": bool(dependants), "receives_carer_benefit": draw(st.booleans()), + # A disabled claimant or partner gives the family the + # disability premium, which can put a student partner on the + # maintenance-loan benefits schedule (HB Regs 2006 reg + # 56(2)(c)). + **draw( + st.sampled_from( + [{}, {"is_disabled_for_benefits": True, "pip_dl": 2_000}] + ) + ), "care_hours": draw(st.sampled_from([0, 35])), "current_education": draw( st.sampled_from(["NOT_IN_EDUCATION", "TERTIARY"]) diff --git a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py index 29b0326d14..5a19a40043 100644 --- a/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py +++ b/policyengine_uk/variables/gov/dfe/maintenance_loans/maintenance_loan_entitled_to_benefits.py @@ -7,10 +7,21 @@ class maintenance_loan_entitled_to_benefits(Variable): label = "Entitled to benefits for maintenance loan assessment" documentation = ( "Proxy for the Student Finance England 'students entitled to benefits' maintenance loan schedule. " - "This uses observable parent/disability/ESA-related signals already present in the model; " - "the ESA signal is income-related ESA payable to the student (HB Regs 2006 reg 56(2)(a))." + "This uses observable parent/disability/ESA-related signals already present in the model: " + "income-related ESA payable to the student (HB Regs 2006 reg 56(2)(a)), and, for a student " + "who is the claimant or partner, a disability or severe disability premium in their applicable " + "amount (reg 56(2)(c)), which a partner's disability benefit can give. The premiums are the " + "model's disability_premium and severe_disability_premium, which do not apply the State " + "Pension Credit age or limited capability for work conditions." ) definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2011/1986/regulation/71", + "https://www.legislation.gov.uk/uksi/2011/1986/regulation/61", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/56", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/12", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/13", + ) def formula(person, period, parameters): qualifying_disability_support = ( @@ -35,5 +46,22 @@ def formula(person, period, parameters): # on ... an income-related employment and support allowance", that is, # one to whom it "is payable" (reg 2(3A)). receives_income_related_esa = person("is_on_income_related_esa", period) + # Reg 56(2)(c): a full-time student "whose applicable amount would, but + # for paragraph (1), include the ... disability premium or severe + # disability premium". For a couple the disability premium turns on + # either partner's disability benefit (Sch 3 paras 12 and 13(1)(a)), so + # a student whose partner receives PIP qualifies though the student + # does not. The premiums are the claimant's family's, so this applies + # to a student who is the claimant or partner, not to a dependant or + # another member of the benefit unit. + premium_in_applicable_amount = person("is_claimant_or_partner", period) & ( + (person.benunit("disability_premium", period) > 0) + | (person.benunit("severe_disability_premium", period) > 0) + ) - return has_child | qualifying_disability_support | receives_income_related_esa + return ( + has_child + | qualifying_disability_support + | receives_income_related_esa + | premium_in_applicable_amount + ) diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py index e22e91beda..37bb90b712 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py @@ -20,7 +20,9 @@ class claimant_or_partner_esa_income(Variable): "disable_simulated_benefits reform), this is the plain total of the " "claimant's and partner's reports; when it holds anything else (an " "award entered directly, or a reform that replaces it), that value " - "is taken to be the claimant's or partner's. A stored zero is zero. " + "is taken to be the claimant's or partner's. Values are compared to " + "within half a penny after rounding to the precision esa_income is stored " + "in. A stored zero is zero. " "An entered esa_income equal to either reported amount is read through " "the reports, so to enter the claimant's or partner's award whatever " "other members report, enter this variable directly." diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py index d50a7a7883..39f971e65c 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py @@ -20,7 +20,9 @@ class claimant_or_partner_jsa_income(Variable): "disable_simulated_benefits reform), this is the plain total of the " "claimant's and partner's reports; when it holds anything else (an " "award entered directly, or a reform that replaces it), that value " - "is taken to be the claimant's or partner's. A stored zero is zero. " + "is taken to be the claimant's or partner's. Values are compared to " + "within half a penny after rounding to the precision jsa_income is stored " + "in. A stored zero is zero. " "An entered jsa_income equal to either reported amount is read through " "the reports, so to enter the claimant's or partner's award whatever " "other members report, enter this variable directly." diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_support.py b/policyengine_uk/variables/gov/dwp/is_on_income_support.py index c3f8dc5cee..efc258cdfa 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_support.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_support.py @@ -19,7 +19,11 @@ class is_on_income_support(Variable): "neither does, and only while it is positive. Any other member of the " "benefit unit, such as a non-dependent adult, claims in their own " "right and is on it only if they report an award themselves while " - "Income Support is in payment and no reform removes it." + "Income Support is in payment (gov.dwp.income_support.active) and not " + "neutralised. A reform that removes Income Support must do one of " + "those for the status to follow: replacing its formula with zero " + "leaves another member's own report standing, because the model " + "calculates the award only for the claimant and partner." ) definition_period = YEAR reference = ( From 360aef868bf20c41781557d4b90077a3be867cd9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 12:23:26 -0400 Subject: [PATCH 10/11] Scope the HB income-related benefit passport to the claimant and partner Main's Housing Benefit passport (#2000) read the benefit unit's totals, so another member's own award passported the claimant's earnings, income and capital (HB Regs 2006 Sch 4 para 12, Sch 5 para 4, Sch 6 para 5). On the merge with main, test_other_members_awards_never_change_the_readers failed on housing_benefit_applicable_income, as the review on #2027 found. - in_receipt_of_income_support_jsa_ib_or_esa_ir reads claimant_or_partner_jsa_income and claimant_or_partner_esa_income (the switch prepared for #2000 under task_6c880fe4). Main's earnings disregard and capital read it through housing_benefit_on_passporting_benefit. - YAML: the prepared passport and earnings-disregard cases, and the review's case of a working mother whose non-dependant son reports ESA. The five excluded-member cases fail on the merge commit without this. - A Merton case for a non-dependant couple whose partner works: only the payee is exempt, and the deduction follows joint income. - Properties: the passport, disregard and capital join the family readers. The in-full income test is split: CTR income still adds each award; HB income is passported to nil, re-derived from Sch 5 para 4. Co-Authored-By: Claude Opus 5.5 --- ...cy-award-readers-claimant-partner.fixed.md | 2 +- .../council_tax_reduction_passports.yaml | 40 +++++ .../housing_benefit_earnings_disregard.yaml | 106 ++++++++++++ ...nefit_income_related_benefit_passport.yaml | 157 ++++++++++++++++++ ...pt_of_income_support_jsa_ib_or_esa_ir.yaml | 4 +- .../test_legacy_award_readers_properties.py | 66 +++++++- .../housing_benefit_applicable_income.py | 23 +-- .../housing_benefit_on_passporting_benefit.py | 3 +- ...eipt_of_income_support_jsa_ib_or_esa_ir.py | 21 ++- .../council_tax_reduction/_legacy.py | 5 +- 10 files changed, 395 insertions(+), 32 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_earnings_disregard.yaml create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_income_related_benefit_passport.yaml diff --git a/changelog.d/legacy-award-readers-claimant-partner.fixed.md b/changelog.d/legacy-award-readers-claimant-partner.fixed.md index 86a00fdb69..12eda88728 100644 --- a/changelog.d/legacy-award-readers-claimant-partner.fixed.md +++ b/changelog.d/legacy-award-readers-claimant-partner.fixed.md @@ -1,3 +1,3 @@ -- Count only the claimant's and partner's income-related ESA and income-based JSA in Housing Benefit, council tax reduction and tax credit income, and in the passports for working-age council tax reduction, Scottish Child Payment and targeted childcare. Another member of the benefit unit, such as a non-dependent adult, who reports an award claims in their own right; their award still counts in household income. +- Count only the claimant's and partner's income-related ESA and income-based JSA in Housing Benefit, council tax reduction and tax credit income, and in the passports for Housing Benefit (`in_receipt_of_income_support_jsa_ib_or_esa_ir`), working-age council tax reduction, Scottish Child Payment and targeted childcare. Another member of the benefit unit, such as a non-dependent adult, who reports an award claims in their own right; their award still counts in household income. - Exempt a council tax reduction non-dependant in the local working-age schemes only when Income Support, income-based JSA or income-related ESA is payable to them, not to their partner or another benefit-unit member. A non-dependant couple on one of these benefits therefore pays the partner's deduction. The maintenance loan schedule for students entitled to benefits now reads whether income-related ESA is payable to the student (HB Regs 2006 reg 56(2)(a)), alongside the existing parent and disability tests, and adds reg 56(2)(c): a student who is the claimant or partner qualifies when their applicable amount would include the disability or severe disability premium, as when their partner receives a disability benefit. - Treat a family as claiming Income Support (`would_claim_IS`) when the claimant or partner reports it, or when every entitled benefit is claimed (`claims_all_entitled_benefits`); another member's report no longer counts. 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 8a41a02487..88a4f16cfa 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 @@ -230,6 +230,46 @@ is_on_income_related_esa: [false, true, false] merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 270.40] +- name: A non-dependant couple on ESA with a working partner pays the partner's deduction on their joint income + period: 2027 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 50 + payee: + age: 30 + is_claimant_or_partner: true + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + partner: + age: 29 + is_claimant_or_partner: true + current_education: NOT_IN_EDUCATION + employment_income: 15_000 + weekly_hours: 20 + benunits: + applicant_unit: + members: [applicant] + nondep_unit: + members: [payee, partner] + universal_credit: 0 + households: + household: + members: [applicant, payee, partner] + country: ENGLAND + local_authority: MERTON + output: + # Only the payee is on the couple's ESA (Default Scheme Sch para 2(4), + # "payable to him"), so only they are exempt (para 30(8)(a)). The partner + # works 20 hours, at least Merton's 16, so the deduction follows the + # couple's joint gross income (para 30(4)): 15,000 / 52 = £288.46 a week, + # in the band from £279, £10.60 a week. One deduction is made for the + # couple, the higher of the two (para 30(3)): 10.60 x 52 = 551.20. + is_on_income_related_esa: [false, true, false] + merton_council_tax_reduction_individual_non_dep_deduction: [0, 0, 551.20] + merton_council_tax_reduction_non_dep_deductions: [551.20, 0] + - name: A non-dependant's own ESA report below the tariff income does not exempt them, whatever another member reports period: 2027 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_earnings_disregard.yaml new file mode 100644 index 0000000000..2106b91ad1 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_earnings_disregard.yaml @@ -0,0 +1,106 @@ +# HB Regs 2006 Sch 4 para 12: where a claimant is on Universal Credit, Income +# Support, income-based JSA or income-related ESA, all their earnings are +# disregarded. The partner's earnings are the claimant's (reg 25) and a +# couple's award covers both of them. "other" is a member who is neither the +# claimant, the partner nor a child or young person they are responsible for, +# so an award they report is their own claim, and the claimant keeps the +# standard disregard: £5 a week for a single claimant (Sch 4 para 10), capped +# at net earnings. The claimant's £3,000 of earnings is below the income tax +# and National Insurance thresholds, so it is all net earnings. Income-based +# JSA is in payment until April 2026, so these cases are in 2025. + +- name: Another member's income-related ESA leaves the claimant's earnings with the standard disregard + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + employment_income: 3_000 + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant, other] + would_claim_uc: false + households: + household: + members: [claimant, other] + output: + housing_benefit_net_earnings: 3_000 + # 5 x 52 + housing_benefit_applicable_income_disregard: 260 + +- name: Another member's income-based JSA leaves the claimant's earnings with the standard disregard + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + employment_income: 3_000 + other: + age: 30 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + would_claim_uc: false + households: + household: + members: [claimant, other] + output: + housing_benefit_net_earnings: 3_000 + housing_benefit_applicable_income_disregard: 260 + +- name: The claimant's own income-related ESA disregards all their earnings + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + employment_income: 3_000 + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + output: + housing_benefit_net_earnings: 3_000 + housing_benefit_applicable_income_disregard: 3_000 + +- name: The partner's income-based JSA disregards all the couple's earnings + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + employment_income: 3_000 + partner: + age: 38 + is_claimant_or_partner: true + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + households: + household: + members: [claimant, partner] + output: + housing_benefit_net_earnings: 3_000 + housing_benefit_applicable_income_disregard: 3_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_income_related_benefit_passport.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_income_related_benefit_passport.yaml new file mode 100644 index 0000000000..5d897f5b7e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_income_related_benefit_passport.yaml @@ -0,0 +1,157 @@ +# HB Regs 2006 Sch 5 para 4 and Sch 6 para 5 (NI: SR 2006/405 Sch 6 para 4 +# and Sch 7 para 5): where the claimant is on Income Support, income-based JSA +# or income-related ESA, the whole of their income and capital is disregarded. +# The claimant's partner's income and capital are the claimant's (reg 25), and +# a couple's award covers both of them. "other" is a member who is neither the +# claimant, the partner nor a child or young person they are responsible for, +# so an award they report is their own claim and passports nobody. The +# household's £12,000 of savings all fall to the claimant's family, the only +# family in the household with a claimant or partner. Income-based JSA is in +# payment until April 2026, so these cases are in 2025. + +- name: Another member's income-related ESA does not passport the claimant's Housing Benefit + period: 2025 + 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: 5_000 + benunits: + benunit: + members: [claimant, other] + would_claim_uc: false + households: + household: + members: [claimant, other] + savings: 12_000 + output: + claimant_or_partner_esa_income: 0 + in_receipt_of_income_support_jsa_ib_or_esa_ir: false + housing_benefit_assessable_capital: 12_000 + +- name: Another member's income-based JSA does not passport the claimant's Housing Benefit + period: 2025 + 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 + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, other] + would_claim_uc: false + households: + household: + members: [claimant, other] + savings: 12_000 + output: + claimant_or_partner_jsa_income: 0 + in_receipt_of_income_support_jsa_ib_or_esa_ir: false + housing_benefit_assessable_capital: 12_000 + +- name: The claimant's own income-related ESA passports their income and capital + # Income-related ESA on £12,000 of capital: £1 a week of tariff income for + # each £250 above £6,000, so 5,000 - 24 x 52 = 3,752. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + savings: 12_000 + output: + claimant_or_partner_esa_income: 3_752 + in_receipt_of_income_support_jsa_ib_or_esa_ir: true + housing_benefit_assessable_capital: 0 + housing_benefit_applicable_income: 0 + +- name: The partner's income-based JSA passports the couple's income and capital + # 4,000 - 24 x 52 = 2,752 after tariff income on £12,000 of capital. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + is_claimant_or_partner: true + employment_income: 3_000 + partner: + age: 38 + is_claimant_or_partner: true + jsa_income_reported: 4_000 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: false + households: + household: + members: [claimant, partner] + savings: 12_000 + output: + claimant_or_partner_jsa_income: 2_752 + in_receipt_of_income_support_jsa_ib_or_esa_ir: true + housing_benefit_assessable_capital: 0 + housing_benefit_applicable_income: 0 + +- name: A non-dependant son's income-related ESA does not passport his working mother's Housing Benefit + # The mother earns £30,000 and rents from the council for £10,000 a year. + # Her 25-year-old son is neither her partner nor a young person she is + # responsible for, so the £5,000 of income-related ESA he reports is his own + # claim and her income is assessed. Income tax (30,000 - 12,570) x 20% = + # 3,486 and National Insurance (30,000 - 12,570) x 8% = 1,394.40 leave net + # earnings of 25,119.60; less the £5 a week single disregard (Sch 4 para 10), + # applicable income is 24,859.60. 65% of its excess over her applicable + # amount exceeds the rent whenever that amount is below £9,475.21, which a + # single person's personal allowance is, so Housing Benefit is nil. Read as + # hers, his award would passport her whole income and give the full £10,000. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 50 + is_claimant_or_partner: true + employment_income: 30_000 + housing_benefit_reported: 1 + son: + age: 25 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [mother, son] + would_claim_uc: false + households: + household: + members: [mother, son] + tenure_type: RENT_FROM_COUNCIL + rent: 10_000 + output: + esa_income: 5_000 + claimant_or_partner_esa_income: 0 + in_receipt_of_income_support_jsa_ib_or_esa_ir: false + housing_benefit_on_passporting_benefit: false + housing_benefit_applicable_income: 24_859.60 + housing_benefit: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.yaml index e15181a4f6..be53437147 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.yaml @@ -1,5 +1,7 @@ # Receipt of Income Support, income-based JSA or income-related ESA: a -# positive amount of any of them, held for the benefit unit. +# positive amount of any of them on the claimant's or partner's award. An +# award entered for the benefit unit is taken to be theirs. Another member's +# own award is tested in legacy_award_readers/. - name: No income-related benefit period: 2025 diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 4f3f480234..65bfba9f27 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -8,6 +8,9 @@ - Housing Benefit and council tax reduction income: SSCBA 1992 s.136(1), HB Regs 2006 reg 25(1); CTR (Prescribed Requirements) (England) Regs 2012 Sch 1 para 11. +- The Housing Benefit passport, which disregards the earnings, income and + capital of a claimant on Income Support, income-based JSA or income-related + ESA: HB Regs 2006 Sch 4 para 12, Sch 5 para 4 and Sch 6 para 5. - The working-age council tax reduction passport and non-dependant exemption: CTR (Default Scheme) (England) Regs 2012, Schedule. - The tax credit income test: TCA 2002 s.7(2), SI 2002/2008 reg 4. @@ -55,6 +58,10 @@ "claimant_or_partner_esa_income", "claimant_or_partner_jsa_income", "housing_benefit_applicable_income", + "in_receipt_of_income_support_jsa_ib_or_esa_ir", + "housing_benefit_on_passporting_benefit", + "housing_benefit_applicable_income_disregard", + "housing_benefit_assessable_capital", "council_tax_reduction_applicable_income", "council_tax_reduction_relevant_income_based_benefit", "tax_credits_applicable_income", @@ -497,17 +504,16 @@ def build(people): } -def test_claimant_and_partner_awards_count_in_full_as_their_income(): - """HB Regs 2006 reg 25(1): the partner's income is the claimant's. +def test_claimant_and_partner_awards_count_in_full_as_their_ctr_income(): + """CTR (Prescribed Requirements) (England) Regs 2012 Sch 1 para 11: the + partner's income is the applicant's. - The claimant earns £10,000, below the tax and NI thresholds and above any - disregard, so each award adds exactly its amount. + The claimant earns £10,000, below the tax and NI thresholds, so each + award adds exactly its amount. """ - hb = "housing_benefit_applicable_income" ctr = "council_tax_reduction_applicable_income" no_uc = {"universal_credit": 0} claimant_esa = {"claimant": {**CLAIMANT, "esa_income_reported": 5_000}} - assert np.isclose(_difference(hb, {"claimant": CLAIMANT}, claimant_esa), 5_000) assert np.isclose( _difference(ctr, {"claimant": CLAIMANT}, claimant_esa, no_uc), 5_000 ) @@ -517,7 +523,6 @@ def test_claimant_and_partner_awards_count_in_full_as_their_income(): "claimant": CLAIMANT, "partner": {**partner, "jsa_income_reported": 3_000}, } - assert np.isclose(_difference(hb, couple, couple_jsa), 3_000) assert np.isclose(_difference(ctr, couple, couple_jsa, no_uc), 3_000) # Another member's award adds nothing. other_esa = { @@ -525,10 +530,55 @@ def test_claimant_and_partner_awards_count_in_full_as_their_income(): "other": {**OTHER, "esa_income_reported": 5_000}, } with_other = {"claimant": CLAIMANT, "other": OTHER} - assert np.isclose(_difference(hb, with_other, other_esa), 0) assert np.isclose(_difference(ctr, with_other, other_esa, no_uc), 0) +def _value(variable, people): + # Not claiming Universal Credit, so the Universal Credit limb of the + # Housing Benefit passport cannot apply: only the legacy awards can. + names = list(people) + situation = { + "people": { + n: {k: {YEAR: v} for k, v in inputs.items()} for n, inputs in people.items() + }, + "benunits": {"b": {"members": names, "would_claim_uc": {YEAR: False}}}, + "households": {"h": {"members": names}}, + } + return Simulation(situation=situation).calculate(variable, YEAR)[0] + + +def test_claimant_and_partner_awards_passport_housing_benefit_income(): + """HB Regs 2006 Sch 5 para 4: the whole income of a claimant on + income-related ESA or income-based JSA is disregarded. The partner's + award is the couple's (reg 25(1)), so it passports them too, and another + member's award passports nobody. + + The claimant earns £10,000, below the tax and NI thresholds. Without an + award their income is assessed, less the £5 a week single or £10 a week + couple disregard (Sch 4 paras 10 and 7): 10,000 - 260 = 9,740 and + 10,000 - 520 = 9,480. + """ + hb = "housing_benefit_applicable_income" + assert np.isclose(_value(hb, {"claimant": CLAIMANT}), 9_740) + claimant_esa = {"claimant": {**CLAIMANT, "esa_income_reported": 5_000}} + assert np.isclose(_value(hb, claimant_esa), 0) + partner = {"age": 38, "is_claimant_or_partner": True} + assert np.isclose(_value(hb, {"claimant": CLAIMANT, "partner": partner}), 9_480) + couple_jsa = { + "claimant": CLAIMANT, + "partner": {**partner, "jsa_income_reported": 3_000}, + } + assert np.isclose(_value(hb, couple_jsa), 0) + # Another member's award: the claimant's income is assessed as before. + with_other = {"claimant": CLAIMANT, "other": OTHER} + other_esa = { + "claimant": CLAIMANT, + "other": {**OTHER, "esa_income_reported": 5_000}, + } + assert np.isclose(_value(hb, with_other), 9_740) + assert np.isclose(_value(hb, other_esa), 9_740) + + def _claimant_and_other(other_report): people = { "claimant": {"age": {YEAR: 40}, "is_claimant_or_partner": {YEAR: True}}, diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index bf062714d1..b97d9ac316 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -137,17 +137,18 @@ def formula(benunit, period, parameters): # income support, an income-based jobseeker's allowance or an # income-related employment and support allowance. Para 5 does the # same where the claimant's partner in a joint-claim couple is on - # income-based JSA. The model holds these awards for the benefit unit - # and cannot tell which member claims Housing Benefit (a couple - # choose, reg 82(1)), so it applies the disregard whichever member is - # on the benefit. There is no age condition: SI 2006/213 reg 5(1)(b) - # (NI: SR 2006/405 reg 5(1)(b)) applies these regulations to a - # claimant over the qualifying age for State Pension Credit whose - # partner is on one of these benefits, so a mixed-age couple whose - # younger member is on income-related ESA or Universal Credit is - # covered. Universal Credit is read before the benefit cap - # (housing_benefit_on_passporting_benefit), which avoids a circular - # dependency through the cap. + # income-based JSA. The model cannot tell which of the claimant and + # partner claims Housing Benefit (a couple choose, reg 82(1)), so it + # applies the disregard whichever of them is on the benefit. Another + # member's own award does not count: they claim in their own right + # (in_receipt_of_income_support_jsa_ib_or_esa_ir). There is no age + # condition: SI 2006/213 reg 5(1)(b) (NI: SR 2006/405 reg 5(1)(b)) + # applies these regulations to a claimant over the qualifying age for + # State Pension Credit whose partner is on one of these benefits, so a + # mixed-age couple whose younger member is on income-related ESA or + # Universal Credit is covered. Universal Credit is read before the + # benefit cap (housing_benefit_on_passporting_benefit), which avoids a + # circular dependency through the cap. on_passporting_benefit = benunit( "housing_benefit_on_passporting_benefit", period ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py index b0b3a3edc8..6f525e3d70 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py @@ -9,7 +9,8 @@ class housing_benefit_on_passporting_benefit(Variable): "Support Allowance, which disregards the whole of its earnings, other " "income and capital in the Housing Benefit means test. Income Support, " "income-based JSA and income-related ESA count when a positive amount " - "is paid (in_receipt_of_income_support_jsa_ib_or_esa_ir). A person is " + "is paid on the claimant's or partner's award, not another member's " + "(in_receipt_of_income_support_jsa_ib_or_esa_ir). A person is " "on Universal Credit on any day they are entitled to it, whether it is " "in payment or not (SI 2006/213 and SR 2006/405 reg 2(3B)). The model " "reads that as Universal Credit above nil before the benefit cap and " diff --git a/policyengine_uk/variables/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.py b/policyengine_uk/variables/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.py index 6603fedd03..d7496cc79b 100644 --- a/policyengine_uk/variables/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.py +++ b/policyengine_uk/variables/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.py @@ -6,12 +6,15 @@ class in_receipt_of_income_support_jsa_ib_or_esa_ir(Variable): documentation = ( "Whether this benefit unit is paid Income Support, income-based " "Jobseeker's Allowance or income-related Employment and Support " - "Allowance: a positive amount of any of them. The model holds these " - "awards at benefit-unit level, so it does not distinguish the claimant " - "from the partner. The regulations also count as on income-based JSA " - "or income-related ESA some days on which nothing is paid (a sanction, " - "a waiting day, a loss-of-benefit restriction), which the model does " - "not see." + "Allowance: a positive amount of any of them on the claimant's or " + "partner's award. A couple's award covers both of them, so the model " + "does not distinguish the claimant from the partner. Another member of " + "the benefit unit, who is neither the claimant, the partner nor a child " + "or young person they are responsible for, claims in their own right, " + "so their award does not count. The regulations also count as on " + "income-based JSA or income-related ESA some days on which nothing is " + "paid (a sanction, a waiting day, a loss-of-benefit restriction), which " + "the model does not see." ) entity = BenUnit definition_period = YEAR @@ -22,8 +25,10 @@ class in_receipt_of_income_support_jsa_ib_or_esa_ir(Variable): ) def formula(benunit, period, parameters): + # Income Support already needs the claimant's or partner's own award + # (income_support_eligible). return ( (benunit("income_support", period) > 0) - | (benunit("jsa_income", period) > 0) - | (benunit("esa_income", period) > 0) + | (benunit("claimant_or_partner_jsa_income", period) > 0) + | (benunit("claimant_or_partner_esa_income", period) > 0) ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py index 4b6a348ce8..db0d6ba794 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -144,8 +144,9 @@ def normal_gross_income_non_dep_deduction( ) full_time_student = is_full_time_student_non_dep(person, period) # No deduction for a non-dependant "who is on" Income Support, income-based - # JSA or income-related ESA: their own (or their couple's) award, not that of - # another member of their benefit unit. + # JSA or income-related ESA: an award payable to them (Default Scheme Sch + # para 2(3)-(4)). Of a couple, only the payee is on the couple's award, and + # another member of their benefit unit's award never counts. income_based_benefit = ( person("is_on_income_support", period) | person("is_on_income_based_jsa", period) From da6fa1208414624428fef66c189b60852eb88ab5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 11:33:13 -0400 Subject: [PATCH 11/11] Answer review r6's wording nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The scoped awards and the HB passport say that anyone other than the claimant and partner is left out, including a young person with an award of their own (r6 3b), and the passport says how an entered benefit-unit amount is read (r6 3c). - The passport YAML header no longer says every entered award is taken to be the claimant's or partner's (r6 3c). - The HB notebook says the passport turns on the claimant or partner (r6 3a). - The properties docstring cites HB reg 56(2)(c) as well as (a) (r6 3d). - The mum-and-son case's nil bound is £9,474.98, not £9,475.21 (r6 4). - The 19-year-old student case is named for what it tests: a student who is not the claimant or partner (r6 5). Comments, docstrings, a test name and docs only; no model change. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/housing-benefit.ipynb | 2 +- ...g_benefit_income_related_benefit_passport.yaml | 7 ++++--- .../family/legacy_award_readers/passports.yaml | 2 +- ...eceipt_of_income_support_jsa_ib_or_esa_ir.yaml | 8 +++++--- .../tests/test_legacy_award_readers_properties.py | 4 +++- .../gov/dwp/claimant_or_partner_esa_income.py | 10 +++++----- .../gov/dwp/claimant_or_partner_jsa_income.py | 10 +++++----- ..._receipt_of_income_support_jsa_ib_or_esa_ir.py | 15 +++++++++++---- 8 files changed, 35 insertions(+), 23 deletions(-) diff --git a/docs/book/programs/gov/dwp/housing-benefit.ipynb b/docs/book/programs/gov/dwp/housing-benefit.ipynb index 704cabf965..7328c12022 100644 --- a/docs/book/programs/gov/dwp/housing-benefit.ipynb +++ b/docs/book/programs/gov/dwp/housing-benefit.ipynb @@ -43,7 +43,7 @@ "1. **Eligible rent**: the rent the benunit is liable for (`benunit_rent`). Where the Local Housing Allowance applies (`LHA_eligible`: renting, and no member in social housing), the eligible rent is the maximum rent (LHA), which is the LHA rate or, where the rent is lower, the rent (`LHA_cap`; SI 2006/213 regs [12D(2)(a)](https://www.legislation.gov.uk/uksi/2006/213/regulation/12D) and [13D(5)](https://www.legislation.gov.uk/uksi/2006/213/regulation/13D)).\n", "2. **Applicable amount** (`housing_benefit_applicable_amount`): a personal allowance derived from the benunit's composition (single, couple, lone parent) and age, plus any disability/carer premiums. This is the \"minimum income\" the household needs before HB tapers begin.\n", "3. **Means test** (`housing_benefit_entitlement`): the appropriate maximum Housing Benefit is the eligible rent less `housing_benefit_non_dep_deductions` for any non-dependant adults living in the household ([reg. 70](https://www.legislation.gov.uk/uksi/2006/213/regulation/70)). The published `withdrawal_rate` (currently 0.65) of the benunit's `housing_benefit_applicable_income` in excess of the applicable amount is deducted from that maximum ([SSCBA 1992 s. 130(3)(b)](https://www.legislation.gov.uk/ukpga/1992/4/section/130), [reg. 71](https://www.legislation.gov.uk/uksi/2006/213/regulation/71)). Because the LHA caps the eligible rent, the taper comes off the LHA: a private tenant with rent above the LHA and income above the applicable amount receives the LHA less the taper, not the lower of the LHA and the rent less the taper. The pension-age regulations (SI 2006/214) have the same rules at regs 12D, 13D, 50 and 51, and the Northern Ireland regulations at SR 2006/405 regs 13C, 14D, 68 and 69 and SR 2006/406 regs 13C, 14D, 48 and 49.\n", - " If the benunit is on Universal Credit, Income Support, income-based Jobseeker's Allowance or income-related Employment and Support Allowance (`housing_benefit_on_passporting_benefit`), the whole of its earnings, other income and capital is disregarded ([SI 2006/213 Sch. 4 para. 12](https://www.legislation.gov.uk/uksi/2006/213/schedule/4/paragraph/12), [Sch. 5 para. 4](https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/4) and [Sch. 6 para. 5](https://www.legislation.gov.uk/uksi/2006/213/schedule/6/paragraph/5); Northern Ireland: [SR 2006/405 Sch. 5 para. 12](https://www.legislation.gov.uk/nisr/2006/405/schedule/5/paragraph/12), [Sch. 6 para. 4](https://www.legislation.gov.uk/nisr/2006/405/schedule/6/paragraph/4) and [Sch. 7 para. 5](https://www.legislation.gov.uk/nisr/2006/405/schedule/7/paragraph/5)). `housing_benefit_applicable_income` and `housing_benefit_assessable_capital` are then zero, so the award is that maximum. This applies at any age: a mixed-age couple whose younger member is on one of these benefits is assessed under the working-age regulations ([SI 2006/213 reg. 5(1)(b)](https://www.legislation.gov.uk/uksi/2006/213/regulation/5)). Income Support, income-based Jobseeker's Allowance and income-related Employment and Support Allowance count when a positive amount is paid (`in_receipt_of_income_support_jsa_ib_or_esa_ir`). A person is on Universal Credit on any day they are entitled to it, whether it is in payment or not ([reg. 2(3B)](https://www.legislation.gov.uk/uksi/2006/213/regulation/2)); PolicyEngine reads that as Universal Credit above nil before the benefit cap and deductions (`is_uc_entitled`), for a family that claims it, so the passport adds no circular dependency through the benefit cap. The universal credit limb applies from 28 October 2013 in Great Britain and 8 May 2018 in Northern Ireland (`gov.dwp.housing_benefit.means_test.universal_credit_passport`). It matters only where Housing Benefit is paid alongside Universal Credit, for specified or temporary accommodation, which the eligibility rules above do not yet model.\n", + " If the claimant or partner is on Universal Credit, Income Support, income-based Jobseeker's Allowance or income-related Employment and Support Allowance (`housing_benefit_on_passporting_benefit`), the whole of the family's earnings, other income and capital is disregarded ([SI 2006/213 Sch. 4 para. 12](https://www.legislation.gov.uk/uksi/2006/213/schedule/4/paragraph/12), [Sch. 5 para. 4](https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/4) and [Sch. 6 para. 5](https://www.legislation.gov.uk/uksi/2006/213/schedule/6/paragraph/5); Northern Ireland: [SR 2006/405 Sch. 5 para. 12](https://www.legislation.gov.uk/nisr/2006/405/schedule/5/paragraph/12), [Sch. 6 para. 4](https://www.legislation.gov.uk/nisr/2006/405/schedule/6/paragraph/4) and [Sch. 7 para. 5](https://www.legislation.gov.uk/nisr/2006/405/schedule/7/paragraph/5)). `housing_benefit_applicable_income` and `housing_benefit_assessable_capital` are then zero, so the award is that maximum. This applies at any age: a mixed-age couple whose younger member is on one of these benefits is assessed under the working-age regulations ([SI 2006/213 reg. 5(1)(b)](https://www.legislation.gov.uk/uksi/2006/213/regulation/5)). Income Support, income-based Jobseeker's Allowance and income-related Employment and Support Allowance count when a positive amount is paid on the claimant's or partner's award (`in_receipt_of_income_support_jsa_ib_or_esa_ir`, which reads `claimant_or_partner_jsa_income` and `claimant_or_partner_esa_income`). Another member's own award, such as a non-dependant's, is payable to them and does not passport the claimant. A person is on Universal Credit on any day they are entitled to it, whether it is in payment or not ([reg. 2(3B)](https://www.legislation.gov.uk/uksi/2006/213/regulation/2)); PolicyEngine reads that as Universal Credit above nil before the benefit cap and deductions (`is_uc_entitled`), for a family that claims it, so the passport adds no circular dependency through the benefit cap. The universal credit limb applies from 28 October 2013 in Great Britain and 8 May 2018 in Northern Ireland (`gov.dwp.housing_benefit.means_test.universal_credit_passport`). It matters only where Housing Benefit is paid alongside Universal Credit, for specified or temporary accommodation, which the eligibility rules above do not yet model.\n", "4. **Benefit cap** (`housing_benefit`): the post-means-test award is finally reduced by `benefit_cap_reduction` if the household's total benefit income exceeds the cap and no cap exemption applies.\n", "\n", "The take-up gate (`would_claim_housing_benefit`) is applied inside `housing_benefit_pre_benefit_cap`." diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_income_related_benefit_passport.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_income_related_benefit_passport.yaml index 5d897f5b7e..c481882eb2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_income_related_benefit_passport.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/housing_benefit_income_related_benefit_passport.yaml @@ -122,9 +122,10 @@ # 3,486 and National Insurance (30,000 - 12,570) x 8% = 1,394.40 leave net # earnings of 25,119.60; less the £5 a week single disregard (Sch 4 para 10), # applicable income is 24,859.60. 65% of its excess over her applicable - # amount exceeds the rent whenever that amount is below £9,475.21, which a - # single person's personal allowance is, so Housing Benefit is nil. Read as - # hers, his award would passport her whole income and give the full £10,000. + # amount covers the rent whenever that amount is at most £9,474.98 + # (24,859.60 - 10,000 / 0.65), which a single person's personal allowance + # is, so Housing Benefit is nil. Read as hers, his award would passport her + # whole income and give the full £10,000. period: 2025 absolute_error_margin: 0.01 input: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml index 932e48fc5d..c48cc616df 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml @@ -474,7 +474,7 @@ is_on_income_related_esa: [false, true] maintenance_loan_entitled_to_benefits: [true, true] -- name: A parent's disability premium does not put a dependent student on the benefits schedule +- name: A parent's disability premium does not put a student who is not the claimant or partner on the benefits schedule period: 2025 input: people: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.yaml index be53437147..4ba6eaf213 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.yaml @@ -1,7 +1,9 @@ # Receipt of Income Support, income-based JSA or income-related ESA: a -# positive amount of any of them on the claimant's or partner's award. An -# award entered for the benefit unit is taken to be theirs. Another member's -# own award is tested in legacy_award_readers/. +# positive amount of any of them on the claimant's or partner's award. The +# cases that enter an award report none, so the entered award is taken to be +# theirs; an entered amount equal to the award the members' reports give is +# read through the reports instead (claimant_or_partner_esa_income). +# Another member's own award is tested in legacy_award_readers/. - name: No income-related benefit period: 2025 diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 65bfba9f27..7c6ef57d72 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -17,7 +17,9 @@ - Scottish Child Payment: SSI 2020/351 reg 18(e)-(f). - Targeted childcare: SI 2014/2147 reg 1(2). - Maintenance loans for students entitled to benefits: SI 2011/1986 reg - 71(1)(h)(iii), through reg 61(2)(b) and HB Regs 2006 reg 56(2)(a). + 71(1)(h)(iii), through reg 61(2)(b) and HB Regs 2006 reg 56(2)(a) (the + student is on the award) and (c) (the student's applicable amount would + include a disability or severe disability premium). A member of the benefit unit who is neither the claimant, the partner nor a child or young person they are responsible for (for example a non-dependent diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py index 37bb90b712..ae5dabed94 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py @@ -9,11 +9,11 @@ class claimant_or_partner_esa_income(Variable): documentation = ( "Income-related ESA of the claimant and partner. Means tests and passports that " "ask whether the claimant or partner is on income-related ESA read this. " - "Another member of the benefit unit who is neither the claimant, the " - "partner nor a child or young person they are responsible for (for " - "example a non-dependent adult) claims in their own right, so their " - "award is left out here; it still counts in esa_income and so in " - "household income. Which awards esa_income holds is decided by value: " + "Anyone else in the benefit unit claims in their own right, so their " + "award is left out here: a non-dependent adult, or a young person " + "with an award of their own, which is payable to them. It still " + "counts in esa_income and so in household income. Which awards " + "esa_income holds is decided by value: " "when it holds the award its formula gives on all reported awards, " "this is the same award on the claimant's and partner's reports; " "when it holds the plain total of the reports (the " diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py index 39f971e65c..b528c41a52 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py @@ -9,11 +9,11 @@ class claimant_or_partner_jsa_income(Variable): documentation = ( "Income-based JSA of the claimant and partner. Means tests and passports that " "ask whether the claimant or partner is on income-based JSA read this. " - "Another member of the benefit unit who is neither the claimant, the " - "partner nor a child or young person they are responsible for (for " - "example a non-dependent adult) claims in their own right, so their " - "award is left out here; it still counts in jsa_income and so in " - "household income. Which awards jsa_income holds is decided by value: " + "Anyone else in the benefit unit claims in their own right, so their " + "award is left out here: a non-dependent adult, or a young person " + "with an award of their own, which is payable to them. It still " + "counts in jsa_income and so in household income. Which awards " + "jsa_income holds is decided by value: " "when it holds the award its formula gives on all reported awards, " "this is the same award on the claimant's and partner's reports; " "when it holds the plain total of the reports (the " diff --git a/policyengine_uk/variables/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.py b/policyengine_uk/variables/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.py index d7496cc79b..931bb81550 100644 --- a/policyengine_uk/variables/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.py +++ b/policyengine_uk/variables/gov/dwp/in_receipt_of_income_support_jsa_ib_or_esa_ir.py @@ -8,10 +8,17 @@ class in_receipt_of_income_support_jsa_ib_or_esa_ir(Variable): "Jobseeker's Allowance or income-related Employment and Support " "Allowance: a positive amount of any of them on the claimant's or " "partner's award. A couple's award covers both of them, so the model " - "does not distinguish the claimant from the partner. Another member of " - "the benefit unit, who is neither the claimant, the partner nor a child " - "or young person they are responsible for, claims in their own right, " - "so their award does not count. The regulations also count as on " + "does not distinguish the claimant from the partner. Anyone else in " + "the benefit unit claims in their own right, so their award does not " + "count: a non-dependent adult, or a young person with an award of " + "their own, which is payable to them, not to the claimant. Income " + "Support is already only the claimant's or partner's award. JSA and " + "ESA are read through claimant_or_partner_jsa_income and " + "claimant_or_partner_esa_income, so a jsa_income or esa_income " + "entered for the benefit unit is taken to be the claimant's or " + "partner's unless it equals the award the members' reports give, or " + "their plain total, in which case it is read through the reports. " + "The regulations also count as on " "income-based JSA or income-related ESA some days on which nothing is " "paid (a sanction, a waiting day, a loss-of-benefit restriction), which " "the model does not see."