From b47ad842f016aab1398c9b7b5a66525fee18b682 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 14:49:12 -0400 Subject: [PATCH 1/8] Stop counting interest, dividends and rent as UC unearned income UC Regs 2013 reg 66(1) lists every description of unearned income, and none covers actual interest, dividends or rent: (m) reaches only ITTOIA 2005 Part 5 income, while interest and dividends are Part 4 and property income Part 3. Capital counts only through its assumed yield (reg 72(1), reg 66(1)(k)), and reg 72(3) treats actual income derived from capital as capital. The UC Regs (NI) 2016 are the same. DWP ADM H5002 and H5112 say income outside the list does not affect the award. The model counted all three unless tariff income applied and a matching household asset flag was set, so it both counted them below the tariff threshold and added interest on top of tariff income when savings were missing from the data. Remove them from the unearned list and delete the capital_derived parameter and its subtraction. Co-Authored-By: Claude Opus 5.5 --- .../uc-capital-derived-income.fixed.md | 1 + .../income_definitions/capital_derived.yaml | 30 ------------------- .../income_definitions/unearned.yaml | 30 +++++++++++++++---- .../income/uc_unearned_income.py | 25 +++++----------- 4 files changed, 34 insertions(+), 52 deletions(-) create mode 100644 changelog.d/uc-capital-derived-income.fixed.md delete mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/capital_derived.yaml diff --git a/changelog.d/uc-capital-derived-income.fixed.md b/changelog.d/uc-capital-derived-income.fixed.md new file mode 100644 index 000000000..76ead411c --- /dev/null +++ b/changelog.d/uc-capital-derived-income.fixed.md @@ -0,0 +1 @@ +Universal Credit no longer counts actual savings interest, dividends or rent as unearned income. Regulation 66(1) of the Universal Credit Regulations 2013 has no description that covers them, and regulation 72(3) treats income derived from capital as capital, so capital counts only through its assumed yield (tariff income). Previously the model counted them in full unless tariff income applied and a matching household asset was recorded. When savings were missing from the data, interest was added on top of tariff income from other capital. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/capital_derived.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/capital_derived.yaml deleted file mode 100644 index 211724065..000000000 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/capital_derived.yaml +++ /dev/null @@ -1,30 +0,0 @@ -description: > - Model income variables treated as capital-derived income for Universal Credit - when tariff income applies. Regulation 72(3) covers actual income derived - from capital, for example rental, interest or dividends. In the current - model, those map to `property_income` when countable property capital is - present, `savings_interest_income` when savings are present, and - `dividend_income` when corporate wealth is present. Those linked income - streams are removed from `uc_unearned_income` once `uc_tariff_income` - applies to avoid double counting the same capital. The model does not add - annual income flows back into `uc_assessable_capital`, because the dataset - does not identify whether those receipts are retained as capital within the - assessment year. When benunit-level `uc_reported_capital` is provided, the - model treats that as sufficient evidence of countable capital for all listed - capital-derived income variables, so `savings_interest_income`, - `dividend_income`, and `property_income` are all suppressed once tariff - income applies. -metadata: - economy: false - label: Universal Credit capital-derived unearned income sources - propagate_metadata_to_children: true - period: year - unit: list - reference: - - title: The Universal Credit Regulations 2013 reg. 72(3) - href: https://www.legislation.gov.uk/uksi/2013/376/regulation/72/3 -values: - 2013-04-29: - - savings_interest_income - - dividend_income - - property_income diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml index 25db4373d..3d9f9a274 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml @@ -1,4 +1,19 @@ -description: Unearned income sources for Universal Credit. +description: > + Unearned income sources for Universal Credit. Regulation 66(1) defines + unearned income as income "falling within the following descriptions" and + lists them in sub-paragraphs (a) to (m); income outside that list is not + taken into account (DWP Advice for Decision Making H5002 and H5112). Actual + interest, dividends and rent fall in none of them: sub-paragraph (m) reaches + only income taxable under Part 5 of the Income Tax (Trading and Other Income) + Act 2005, whereas interest and dividends are charged under Part 4 and + property income under Part 3. Capital instead counts through its assumed + yield under regulation 72(1) (`uc_tariff_income`, sub-paragraph (k)), and + regulation 72(3) treats actual income derived from capital, "for example + rental, interest or dividends", as part of the person's capital. So + `savings_interest_income`, `dividend_income` and `property_income` are not on + this list at any capital level. Letting that amounts to a trade, such as a + bed and breakfast, gives self-employed earnings under regulation 57, which + the model reads from `self_employment_income`. metadata: economy: false label: Universal Credit unearned income sources @@ -6,7 +21,7 @@ metadata: period: year unit: list reference: - - title: The Universal Credit Regulations 2013 reg. 66 + - title: The Universal Credit Regulations 2013 reg. 66(1) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/66 - title: The Universal Credit Regulations 2013 reg. 67 (retirement pension income) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/67 @@ -22,6 +37,14 @@ metadata: href: https://www.legislation.gov.uk/uksi/2026/246/article/25 - title: The Universal Credit Regulations 2013 reg. 72 href: https://www.legislation.gov.uk/uksi/2013/376/regulation/72 + - title: The Universal Credit Regulations 2013 reg. 57 + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/57 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 66(1) + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/66 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 72 + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/72 + - title: DWP Advice for Decision Making, Chapter H5 (unearned income), H5002, H5093-H5094 and H5112 + href: https://www.gov.uk/government/publications/advice-for-decision-making-staff-guide values: @@ -47,6 +70,3 @@ values: - private_pension_income - state_pension - uc_tariff_income - - savings_interest_income - - dividend_income - - property_income diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py index 82b6ec328..25f13198c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_unearned_income.py @@ -5,8 +5,15 @@ class uc_unearned_income(Variable): value_type = float entity = BenUnit label = "Universal Credit unearned income" + documentation = ( + "Income falling within the descriptions in regulation 66(1) of the " + "Universal Credit Regulations 2013. Capital counts only through its " + "assumed yield (uc_tariff_income, regulation 72(1)); actual interest, " + "dividends and rent are not unearned income at any capital level." + ) definition_period = YEAR unit = GBP + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/66" def formula(benunit, period, parameters): # Members whose income counts: the claimant and partner and, as the model did @@ -18,20 +25,4 @@ def formula(benunit, period, parameters): "is_child_or_qualifying_young_person_for_universal_credit", period ) p = parameters(period).gov.dwp.universal_credit.means_test - household = benunit.household - total = add_for_members(benunit, period, p.income_definitions.unearned, members) - tariff_income_applies = benunit("uc_tariff_income", period) > 0 - reported_capital = benunit("uc_reported_capital", period) - has_reported_capital = reported_capital >= 0 - property_capital = household( - "other_residential_property_value", period - ) + household("non_residential_property_value", period) - capital_derived_income = ( - ((household("savings", period) > 0) | has_reported_capital) - * add_for_members(benunit, period, ["savings_interest_income"], members) - + ((household("corporate_wealth", period) > 0) | has_reported_capital) - * add_for_members(benunit, period, ["dividend_income"], members) - + ((property_capital > 0) | has_reported_capital) - * add_for_members(benunit, period, ["property_income"], members) - ) - return total - tariff_income_applies * capital_derived_income + return add_for_members(benunit, period, p.income_definitions.unearned, members) From 9cc74cb79d9216b15194c29edbe1d7ba0f2b6289 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 15:06:22 -0400 Subject: [PATCH 2/8] Test that UC counts capital only through tariff income Rewrite the uc_unearned_income cases from UC Regs 2013 reg 66(1) and reg 72, add hand-computed award cases (single and couple, lodger, Northern Ireland), and add Hypothesis properties: capital yield counted once (checked against a reference reg 72(1) calculation), interest, dividends and rent changing nothing in the means test, and the award non-increasing in capital. A strict xfail pins the #1942 whole-unit tax deduction. Co-Authored-By: Claude Opus 5.5 --- .../income/uc_income_from_capital.yaml | 141 +++++++++ .../income/uc_unearned_income.yaml | 97 ++++-- .../test_uc_income_from_capital_properties.py | 279 ++++++++++++++++++ 3 files changed, 499 insertions(+), 18 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml create mode 100644 policyengine_uk/tests/test_uc_income_from_capital_properties.py diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml new file mode 100644 index 000000000..15048a39e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml @@ -0,0 +1,141 @@ +# Universal Credit awards with interest, dividends and rent, hand-computed from +# the UC Regs 2013 (and the UC Regs (NI) 2016, which are the same here). +# +# - Reg. 66(1): unearned income is income "falling within the following +# descriptions"; none covers actual interest, dividends or rent. Reg. +# 66(1)(m) reaches only ITTOIA 2005 Part 5 income (interest and dividends +# are Part 4, property income Part 3). DWP ADM H5002: "If a type of income +# is not listed above, it does not affect the claimant's award." +# - Reg. 72(1): capital above £6,000 yields £4.35 a month for each £250 or +# part of £250 (reg. 66(1)(k)). Reg. 72(3): actual income derived from that +# capital, "for example rental, interest or dividends", is capital. +# - Reg. 22(1): award = maximum amount - 55% of earned income - all unearned +# income. +# +# 2026 rates (gov.dwp.universal_credit.standard_allowance, 2026-27): single +# claimant 25 or over £424.90 a month, so 12 x 424.90 = £5,098.80 a year; +# joint claimants, one or both 25 or over, £666.97 a month, so £8,003.64. +# Every case below has income under the personal allowance, so no income tax +# or National Insurance, and no housing, child or disability element. + +- name: Savings interest, dividends and rent below £6,000 of capital leave the award unchanged + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + savings_interest_income: 200 + dividend_income: 300 + property_income: 1_000 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 5_000 + output: + # No tariff income (capital is not above £6,000) and no unearned income. + uc_tariff_income: 0 + uc_unearned_income: 0 + uc_maximum_amount: 5_098.80 + universal_credit: 5_098.80 + +- name: Rent and interest are not counted on top of tariff income from a let property + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + savings_interest_income: 500 + property_income: 4_800 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 0 + other_residential_property_value: 10_000 + output: + # Tariff income: (10,000 - 6,000) / 250 = 16 steps x £4.35 = £69.60 a + # month, £835.20 a year. UC = 5,098.80 - 835.20 = £4,263.60. + uc_tariff_income: 835.20 + uc_unearned_income: 835.20 + universal_credit: 4_263.60 + +- name: Northern Ireland treats rent and interest from capital the same way + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + savings_interest_income: 500 + property_income: 4_800 + benunits: + benunit: + members: person + households: + household: + members: person + region: NORTHERN_IRELAND + savings: 0 + other_residential_property_value: 10_000 + output: + # UC Regs (NI) 2016 regs. 66(1) and 72 match the GB text on these points. + uc_tariff_income: 835.20 + uc_unearned_income: 835.20 + universal_credit: 4_263.60 + +- name: Rent from a lodger in the home does not reduce the award + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + property_income: 3_000 + benunits: + benunit: + members: person + households: + household: + members: person + tenure_type: OWNED_OUTRIGHT + main_residence_value: 200_000 + output: + # The home is disregarded capital (Sch. 10 para. 1) and rent from a + # sub-tenant or boarder is not income (ADM H5112). + uc_tariff_income: 0 + uc_unearned_income: 0 + universal_credit: 5_098.80 + +- name: A partner's dividends do not reduce a working couple's award + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + employment_income: 10_000 + partner: + age: 32 + dividend_income: 2_000 + benunits: + benunit: + members: [person, partner] + households: + household: + members: [person, partner] + savings: 3_000 + output: + # No work allowance (no child, no limited capability for work), so the + # earnings reduction is 55% x 10,000 = 5,500. UC = 8,003.64 - 5,500 = + # £2,503.64. The dividends do not appear anywhere in the calculation. + uc_earned_income: 10_000 + uc_unearned_income: 0 + uc_maximum_amount: 8_003.64 + universal_credit: 2_503.64 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml index b1db7dcd7..422af718d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml @@ -1,4 +1,13 @@ -- name: All sources summed +# UC Regs 2013 reg. 66(1) lists the descriptions of unearned income, and income +# outside them is not taken into account (DWP ADM H5002, H5112). Actual +# interest, dividends and rent fall in none of them: reg. 66(1)(m) reaches only +# ITTOIA 2005 Part 5 income, while interest and dividends are Part 4 and +# property income is Part 3. Capital counts through its assumed yield instead +# (reg. 72(1), reg. 66(1)(k)): £4.35 a month for each £250, or part of £250, +# above £6,000. Reg. 72(3) treats actual income derived from capital, "for +# example rental, interest or dividends", as capital. + +- name: Listed sources are summed and interest and dividends are not period: 2020 absolute_error_margin: 0 input: @@ -8,7 +17,8 @@ savings_interest_income: 10 dividend_income: 1 output: - uc_unearned_income: 11_111 + # Reg. 66(1)(b)(iii) carer's allowance + (b)(i) JSA + (a) private pension. + uc_unearned_income: 11_100 - name: Tariff income is exposed in unearned income period: 2025 @@ -27,7 +37,7 @@ output: uc_unearned_income: 4.35 * 12 -- name: Savings interest is folded into capital when tariff income applies +- name: Savings interest is not unearned income when tariff income applies period: 2025 absolute_error_margin: 0.01 input: @@ -46,7 +56,7 @@ uc_tariff_income: 4.35 * 12 uc_unearned_income: 4.35 * 12 -- name: Dividend income is folded into capital when tariff income applies +- name: Dividend income is not unearned income when tariff income applies period: 2025 absolute_error_margin: 0.01 input: @@ -66,7 +76,7 @@ uc_tariff_income: 4.35 * 12 uc_unearned_income: 4.35 * 12 -- name: Property income is folded into capital when tariff income applies +- name: Rent from residential property is not unearned income when tariff income applies period: 2025 absolute_error_margin: 0.01 input: @@ -86,7 +96,7 @@ uc_tariff_income: 4.35 * 12 uc_unearned_income: 4.35 * 12 -- name: Non-residential property income is folded into capital when tariff income applies +- name: Rent from non-residential property is not unearned income when tariff income applies period: 2025 absolute_error_margin: 0.01 input: @@ -106,7 +116,7 @@ uc_tariff_income: 4.35 * 12 uc_unearned_income: 4.35 * 12 -- name: Dividend income stays in unearned income without corporate wealth +- name: Dividend income is not unearned income without share capital in the data period: 2025 absolute_error_margin: 0.01 input: @@ -122,10 +132,12 @@ members: person savings: 6_001 output: + # No reg. 66(1) description covers dividends, whether or not the data + # records the shares that pay them. uc_tariff_income: 4.35 * 12 - uc_unearned_income: 120 + 4.35 * 12 + uc_unearned_income: 4.35 * 12 -- name: Property income stays in unearned income without countable property capital +- name: Rent is not unearned income without property capital in the data period: 2025 absolute_error_margin: 0.01 input: @@ -142,9 +154,9 @@ savings: 6_001 output: uc_tariff_income: 4.35 * 12 - uc_unearned_income: 120 + 4.35 * 12 + uc_unearned_income: 4.35 * 12 -- name: Main residence value alone does not suppress property income +- name: Rent from letting part of the home is not unearned income period: 2025 absolute_error_margin: 0.01 input: @@ -161,8 +173,58 @@ savings: 6_001 main_residence_value: 1_000_000 output: + # The home is disregarded capital (Sch. 10 para. 1), so it yields no + # tariff income, and rent from a sub-tenant or boarder is not income + # either (ADM H5112, example of a room let at £60 a week). uc_tariff_income: 4.35 * 12 - uc_unearned_income: 120 + 4.35 * 12 + uc_unearned_income: 4.35 * 12 + +- name: Interest, dividends and rent are not unearned income below the tariff threshold + period: 2025 + absolute_error_margin: 0 + input: + people: + person: + age: 30 + savings_interest_income: 200 + dividend_income: 300 + property_income: 1_000 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 5_000 + output: + # Capital of £6,000 or less yields nothing (reg. 72(1); ADM H1764), and + # the actual income it pays is outside reg. 66(1). + uc_tariff_income: 0 + uc_unearned_income: 0 + +- name: Interest is not added to tariff income when the data records no savings + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + savings_interest_income: 500 + property_income: 4_800 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 0 + other_residential_property_value: 10_000 + output: + # Tariff income on £10,000 of let property: (10,000 - 6,000) / 250 = 16 + # steps x £4.35 x 12 = £835.20. Neither the rent nor the interest is + # added on top of it. + uc_tariff_income: 835.20 + uc_unearned_income: 835.20 - name: Reported capital drives tariff income for multi-benunit households period: 2025 @@ -188,7 +250,7 @@ uc_tariff_income: [52.2, 0] uc_unearned_income: [52.2, 0] -- name: Reported capital suppresses dividend income without household asset detail +- name: Dividend income is not unearned income with reported capital period: 2025 absolute_error_margin: 0.01 input: @@ -208,7 +270,7 @@ uc_tariff_income: 52.2 uc_unearned_income: 52.2 -- name: Reported capital suppresses property income without household asset detail +- name: Rent is not unearned income with reported capital period: 2025 absolute_error_margin: 0.01 input: @@ -228,7 +290,7 @@ uc_tariff_income: 52.2 uc_unearned_income: 52.2 -- name: Dividend income stays unearned when only disregarded pension wealth is held +- name: Dividend income is not unearned income when only disregarded pension wealth is held period: 2025 absolute_error_margin: 0.01 input: @@ -246,8 +308,7 @@ private_pension_wealth: 150_000 output: # Pension wealth is disregarded capital (UC Regs 2013 Sch 10 para 10), so - # the dividend gate keys on corporate_wealth alone: with no share-like - # holdings the dividends are not folded into capital and stay unearned. + # it adds no tariff income, and the dividends are outside reg. 66(1). uc_assessable_capital: 6_001 uc_tariff_income: 4.35 * 12 - uc_unearned_income: 4.35 * 12 + 120 + uc_unearned_income: 4.35 * 12 diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py new file mode 100644 index 000000000..0898ee81c --- /dev/null +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -0,0 +1,279 @@ +"""Property-based tests for income from capital in the Universal Credit means test. + +UC Regs 2013 reg. 66(1) makes unearned income only income "falling within the +following descriptions", and no description covers actual interest, dividends +or rent (reg. 66(1)(m) reaches only ITTOIA 2005 Part 5 income; interest and +dividends are Part 4, property income Part 3). Capital counts through its +assumed yield under reg. 72(1), which reg. 66(1)(k) brings into unearned +income, and reg. 72(3) treats actual income derived from capital as capital. +The UC Regs (NI) 2016 are the same on these points. + +Invariants, for any generated population of families: + +1. Single count (differential): unearned income equals the tariff income + computed independently from reg. 72(1) on assessable capital, plus the + other listed sources the model carries, whatever interest, dividends and + rent the family receives. So capital yield enters once, as tariff income. +2. Invariance: interest, dividends and rent change nothing in unearned income, + tariff income, eligibility or the maximum amount. For families without + earnings they change nothing in the award either. +3. Monotone in capital: adding capital to any countable source never raises + the award, before or after the benefit cap, and never lowers tariff + income. + +Invariant 2's award clause is restricted to families without earnings because +the model deducts the whole benefit unit's income tax from its earnings +(PolicyEngine/policyengine-uk#1942), so tax on dividends raises the award of a +working family. Reg. 55(5)(b) and reg. 57 step 3 allow only tax paid in respect +of the employment or trade. The strict xfail below pins that case and will +flip when #1942 is fixed; widen invariant 2 to all families then. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# 2020 has the temporary standard allowance uplift, 2026 the current rates. +# The capital rules have not changed since 29 April 2013. +YEARS = [2020, 2026] +TENURES = [ + "RENT_FROM_COUNCIL", + "RENT_FROM_HA", + "RENT_PRIVATELY", + "OWNED_OUTRIGHT", + "OWNED_WITH_MORTGAGE", +] +REGIONS = ["LONDON", "NORTH_WEST", "WALES", "SCOTLAND", "NORTHERN_IRELAND"] +# Countable capital sources (gov.dwp.universal_credit.means_test.capital.sources). +CAPITAL_SOURCES = [ + "savings", + "other_residential_property_value", + "non_residential_property_value", + "corporate_wealth", +] +CAPITAL_INCOME = ["savings_interest_income", "dividend_income", "property_income"] +# Reg. 72(1): £4.35 a month for each £250, or part of £250, above £6,000. +TARIFF_THRESHOLD, TARIFF_STEP, TARIFF_MONTHLY = 6_000, 250, 4.35 + +WORKING_AGE = st.integers(18, 60) +money = st.floats(0, 30_000, allow_nan=False, allow_infinity=False) +capital = st.one_of(st.just(0.0), st.floats(0, 20_000, allow_nan=False)) +UC_VARIABLES = [ + "universal_credit", + "universal_credit_pre_benefit_cap", + "uc_earned_income", + "uc_unearned_income", + "uc_tariff_income", + "uc_income_reduction", + "uc_maximum_amount", + "uc_assessable_capital", + "is_uc_eligible", +] +OTHER_LISTED_SOURCES = ["carers_allowance", "jsa_contrib", "private_pension_income"] + + +@st.composite +def families(draw, with_earnings=True): + earnings = draw(st.one_of(st.just(0.0), money)) if with_earnings else 0.0 + return dict( + ages=[draw(WORKING_AGE) for _ in range(draw(st.integers(1, 2)))], + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], + tenure=draw(st.sampled_from(TENURES)), + region=draw(st.sampled_from(REGIONS)), + rent=draw(money), + capital={source: draw(capital) for source in CAPITAL_SOURCES}, + main_residence_value=draw(st.one_of(st.just(0.0), st.floats(0, 400_000))), + # Reported capital overrides the household proxy (-1 means unreported). + reported_capital=draw(st.one_of(st.just(-1.0), capital)), + earnings=earnings, + private_pension_income=draw(st.one_of(st.just(0.0), money)), + capital_income={variable: draw(money) for variable in CAPITAL_INCOME}, + # Which adult receives the capital income. + recipient=draw(st.integers(0, 1)), + ) + + +def situation(units, year, income_scale=1.0, capital_bump=None): + """Build one simulation holding every family. + + ``income_scale`` multiplies each family's interest, dividends and rent; + ``capital_bump`` is an optional (source, amount) added to every family's + capital. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, age in enumerate(unit["ages"]): + name = f"p{i}_{j}" + person = {"age": {year: age}} + if j == 0: + person["employment_income"] = {year: unit["earnings"]} + person["private_pension_income"] = { + year: unit["private_pension_income"] + } + if j == min(unit["recipient"], len(unit["ages"]) - 1): + for variable, amount in unit["capital_income"].items(): + person[variable] = {year: amount * income_scale} + people[name] = person + names.append(name) + for k, age in enumerate(unit["children"]): + name = f"c{i}_{k}" + people[name] = {"age": {year: age}} + names.append(name) + benunit = {"members": names} + reported = unit["reported_capital"] + if capital_bump is not None and reported >= 0: + reported += capital_bump[1] + benunit["uc_reported_capital"] = {year: reported} + benunits[f"b{i}"] = benunit + stocks = dict(unit["capital"]) + if capital_bump is not None: + stocks[capital_bump[0]] += capital_bump[1] + households[f"h{i}"] = { + "members": names, + "rent": {year: unit["rent"]}, + "tenure_type": {year: unit["tenure"]}, + "region": {year: unit["region"]}, + "main_residence_value": {year: unit["main_residence_value"]}, + **{source: {year: value} for source, value in stocks.items()}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(units, year, **kwargs): + sim = Simulation(situation=situation(units, year, **kwargs)) + values = {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + for v in OTHER_LISTED_SOURCES: + values[v] = np.asarray(sim.calculate(v, year, map_to="benunit")) + return values + + +def reference_tariff_income(assessable_capital, eligible): + """Annual assumed yield under reg. 72(1), computed without the model.""" + excess = np.maximum(0.0, assessable_capital - TARIFF_THRESHOLD) + steps = np.ceil(excess / TARIFF_STEP) + return np.where(eligible, steps * TARIFF_MONTHLY * 12, 0.0) + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=20), + year=st.sampled_from(YEARS), +) +def test_capital_yield_is_counted_once_as_tariff_income(units, year): + values = calculate(units, year) + eligible = values["is_uc_eligible"].astype(bool) + tariff = reference_tariff_income(values["uc_assessable_capital"], eligible) + np.testing.assert_allclose( + values["uc_tariff_income"], tariff, atol=0.01, err_msg=str(units) + ) + listed = sum(values[v] for v in OTHER_LISTED_SOURCES) + np.testing.assert_allclose( + values["uc_unearned_income"], tariff + listed, atol=0.01, err_msg=str(units) + ) + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=20), + scale=st.sampled_from([0.0, 0.5, 3.0]), + year=st.sampled_from(YEARS), +) +def test_interest_dividends_and_rent_do_not_enter_the_means_test(units, scale, year): + base = calculate(units, year) + scaled = calculate(units, year, income_scale=scale) + for variable in [ + "uc_unearned_income", + "uc_tariff_income", + "uc_assessable_capital", + "is_uc_eligible", + "uc_maximum_amount", + ]: + np.testing.assert_allclose( + scaled[variable], base[variable], atol=0.01, err_msg=f"{variable}: {units}" + ) + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + scale=st.sampled_from([0.0, 0.5, 3.0]), + year=st.sampled_from(YEARS), +) +def test_interest_dividends_and_rent_leave_the_award_unchanged(units, scale, year): + base = calculate(units, year) + scaled = calculate(units, year, income_scale=scale) + for variable in UC_VARIABLES: + np.testing.assert_allclose( + scaled[variable], base[variable], atol=0.01, err_msg=f"{variable}: {units}" + ) + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=20), + source=st.sampled_from(CAPITAL_SOURCES), + bump=st.floats(0, 20_000, allow_nan=False, allow_infinity=False), + year=st.sampled_from(YEARS), +) +def test_uc_is_non_increasing_in_capital(units, source, bump, year): + low = calculate(units, year) + high = calculate(units, year, capital_bump=(source, bump)) + for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: + assert np.all(high[variable] <= low[variable] + 0.01), (variable, units) + both_eligible = low["is_uc_eligible"].astype(bool) & high["is_uc_eligible"].astype( + bool + ) + assert np.all( + high["uc_tariff_income"][both_eligible] + >= low["uc_tariff_income"][both_eligible] - 0.01 + ), units + # More capital never makes an ineligible family eligible. + assert not np.any( + high["is_uc_eligible"].astype(bool) & ~low["is_uc_eligible"].astype(bool) + ), units + + +@pytest.mark.xfail( + strict=True, + reason=( + "PolicyEngine/policyengine-uk#1942: uc_earned_income deducts the whole " + "benefit unit's income tax, including the partner's tax on dividends, " + "from earnings" + ), +) +def test_tax_on_dividends_does_not_raise_a_working_familys_award(): + # 2026: single claimant aged 30 earning 10,000, under the personal + # allowance and the NI primary threshold, so no tax or NI is paid in + # respect of the employment. The 30,000 of dividends is taxed, but that + # tax is not in respect of the employment (reg. 55(5)(b)), so earned + # income stays 10,000. + unit = dict( + ages=[30], + children=[], + tenure="OWNED_OUTRIGHT", + region="LONDON", + rent=0.0, + capital={source: 0.0 for source in CAPITAL_SOURCES}, + main_residence_value=0.0, + reported_capital=-1.0, + earnings=10_000.0, + private_pension_income=0.0, + capital_income={ + "savings_interest_income": 0.0, + "dividend_income": 30_000.0, + "property_income": 0.0, + }, + recipient=0, + ) + values = calculate([unit], 2026) + assert values["uc_earned_income"][0] == pytest.approx(10_000, abs=0.01) From baf9b1a172586b64d5caf25b543a6a6c510daa02 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 15:13:32 -0400 Subject: [PATCH 3/8] Pin UC monotonicity in savings at the tariff threshold Co-Authored-By: Claude Opus 5.5 --- .../income/uc_income_from_capital.yaml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml index 15048a39e..7c257f883 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml @@ -139,3 +139,48 @@ uc_unearned_income: 0 uc_maximum_amount: 8_003.64 universal_credit: 2_503.64 + +# The two cases below pin monotonicity at the tariff threshold. Before this +# fix, £1 more savings switched interest out of unearned income and raised the +# award from £2,098.80 to £5,046.60. +- name: Interest does not reduce the award with savings of £6,000 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + savings_interest_income: 3_000 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 6_000 + output: + uc_tariff_income: 0 + uc_unearned_income: 0 + universal_credit: 5_098.80 + +- name: One more pound of savings lowers the award by the tariff income only + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + savings_interest_income: 3_000 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 6_001 + output: + # £1 over £6,000 is an incomplete £250 step: £4.35 x 12 = £52.20. + # UC = 5,098.80 - 52.20 = £5,046.60. + uc_tariff_income: 52.20 + uc_unearned_income: 52.20 + universal_credit: 5_046.60 From 00f1ab4ccd3819b441160bcb94ea00dd1f773d32 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 17:09:24 -0400 Subject: [PATCH 4/8] Scope reg 72(3) to capital treated as yielding income; note what still counts Reg 72(3) applies only where capital is treated as yielding income; the exclusion of interest, dividends and rent rests on reg 66(1) being a closed list. Name the routes that still count (trust and annuity income, estate income) and record that whether other letting is a trade is unsettled. Co-Authored-By: Claude Opus 5.5 --- .../uc-capital-derived-income.fixed.md | 2 +- .../income_definitions/unearned.yaml | 22 ++++++++++++------- .../income/uc_unearned_income.yaml | 5 +++-- .../test_uc_income_from_capital_properties.py | 3 ++- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/changelog.d/uc-capital-derived-income.fixed.md b/changelog.d/uc-capital-derived-income.fixed.md index 76ead411c..4aeed6d92 100644 --- a/changelog.d/uc-capital-derived-income.fixed.md +++ b/changelog.d/uc-capital-derived-income.fixed.md @@ -1 +1 @@ -Universal Credit no longer counts actual savings interest, dividends or rent as unearned income. Regulation 66(1) of the Universal Credit Regulations 2013 has no description that covers them, and regulation 72(3) treats income derived from capital as capital, so capital counts only through its assumed yield (tariff income). Previously the model counted them in full unless tariff income applied and a matching household asset was recorded. When savings were missing from the data, interest was added on top of tariff income from other capital. +Universal Credit no longer counts actual savings interest, dividends or rent as unearned income. Regulation 66(1) of the Universal Credit Regulations 2013 has no description that covers them, so capital counts only through its assumed yield (tariff income). Where that yield applies, regulation 72(3) treats the actual income from the capital as capital. Previously the model counted them in full unless tariff income applied and a matching household asset was recorded. When savings were missing from the data, interest was added on top of tariff income from other capital. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml index 3d9f9a274..10e976b0b 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml @@ -6,14 +6,20 @@ description: > interest, dividends and rent fall in none of them: sub-paragraph (m) reaches only income taxable under Part 5 of the Income Tax (Trading and Other Income) Act 2005, whereas interest and dividends are charged under Part 4 and - property income under Part 3. Capital instead counts through its assumed - yield under regulation 72(1) (`uc_tariff_income`, sub-paragraph (k)), and - regulation 72(3) treats actual income derived from capital, "for example - rental, interest or dividends", as part of the person's capital. So - `savings_interest_income`, `dividend_income` and `property_income` are not on - this list at any capital level. Letting that amounts to a trade, such as a - bed and breakfast, gives self-employed earnings under regulation 57, which - the model reads from `self_employment_income`. + property income under Part 3. Capital counts instead through its assumed + yield under regulation 72(1) (`uc_tariff_income`, sub-paragraph (k)). Where + capital is treated as yielding income, regulation 72(3) makes the actual + income derived from it, "for example rental, interest or dividends", part of + the person's capital. So `savings_interest_income`, `dividend_income` and + `property_income` are not on this list at any capital level. Income from a + trust or an annuity (sub-paragraphs (i) and (j)) and estate income (ITTOIA + Part 5 Chapter 6, sub-paragraph (m)) still count, but none of them is in + those three variables. Letting that amounts to a trade, such as a bed and + breakfast, gives self-employed earnings under regulation 57, which the model + reads from `self_employment_income`. Whether other letting can be a trade is + unsettled: DWP staff guidance treats a self-employed landlord's rent as + self-employed income, while letting is not a trade for tax, and the model + does not identify such landlords. metadata: economy: false label: Universal Credit unearned income sources diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml index 422af718d..39f2da820 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml @@ -4,8 +4,9 @@ # ITTOIA 2005 Part 5 income, while interest and dividends are Part 4 and # property income is Part 3. Capital counts through its assumed yield instead # (reg. 72(1), reg. 66(1)(k)): £4.35 a month for each £250, or part of £250, -# above £6,000. Reg. 72(3) treats actual income derived from capital, "for -# example rental, interest or dividends", as capital. +# above £6,000. Where capital is treated as yielding income, reg. 72(3) treats +# the actual income derived from it, "for example rental, interest or +# dividends", as capital. - name: Listed sources are summed and interest and dividends are not period: 2020 diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index 0898ee81c..d53665d2c 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -5,7 +5,8 @@ or rent (reg. 66(1)(m) reaches only ITTOIA 2005 Part 5 income; interest and dividends are Part 4, property income Part 3). Capital counts through its assumed yield under reg. 72(1), which reg. 66(1)(k) brings into unearned -income, and reg. 72(3) treats actual income derived from capital as capital. +income. Where capital is treated as yielding income, reg. 72(3) treats the +actual income derived from it as capital. The UC Regs (NI) 2016 are the same on these points. Invariants, for any generated population of families: From dd4885c2f29b93359d95fc9dc320f15a726a46d3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 17:15:19 -0400 Subject: [PATCH 5/8] State that test property values are taken as capital (reg 49 is #1960) Co-Authored-By: Claude Opus 5.5 --- .../income/uc_income_from_capital.yaml | 11 +++++++++-- .../universal_credit/income/uc_unearned_income.yaml | 6 +++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml index 7c257f883..446765898 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_income_from_capital.yaml @@ -17,6 +17,12 @@ # joint claimants, one or both 25 or over, £666.97 a month, so £8,003.64. # Every case below has income under the personal allowance, so no income tax # or National Insurance, and no housing, child or disability element. +# +# Property values are taken as UC capital as entered. Reg. 49(1) would first +# deduct 10% for sale costs and any secured debt from a property's market +# value; the model does not yet (#1960). So "£10,000 of let property" below +# means £10,000 of assessable capital, for example a property with a market +# value of £11,111.11 and no mortgage. - name: Savings interest, dividends and rent below £6,000 of capital leave the award unchanged period: 2026 @@ -60,8 +66,9 @@ savings: 0 other_residential_property_value: 10_000 output: - # Tariff income: (10,000 - 6,000) / 250 = 16 steps x £4.35 = £69.60 a - # month, £835.20 a year. UC = 5,098.80 - 835.20 = £4,263.60. + # Tariff income on £10,000 of capital: (10,000 - 6,000) / 250 = 16 steps + # x £4.35 = £69.60 a month, £835.20 a year. UC = 5,098.80 - 835.20 = + # £4,263.60. uc_tariff_income: 835.20 uc_unearned_income: 835.20 universal_credit: 4_263.60 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml index 39f2da820..1ae608c9d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_unearned_income.yaml @@ -221,9 +221,9 @@ savings: 0 other_residential_property_value: 10_000 output: - # Tariff income on £10,000 of let property: (10,000 - 6,000) / 250 = 16 - # steps x £4.35 x 12 = £835.20. Neither the rent nor the interest is - # added on top of it. + # Tariff income on £10,000 of capital in let property (taken as entered; + # reg. 49 valuation is #1960): (10,000 - 6,000) / 250 = 16 steps x £4.35 + # x 12 = £835.20. Neither the rent nor the interest is added on top of it. uc_tariff_income: 835.20 uc_unearned_income: 835.20 From 197b0bc9a2a9ce10737674f223b575d4db24cb55 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 18:48:47 -0400 Subject: [PATCH 6/8] Pin the #1942 xfail to AssertionError; add a removed changelog fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-capital-derived-income.fixed.md | 2 +- changelog.d/uc-capital-derived-income.removed.md | 1 + .../tests/test_uc_income_from_capital_properties.py | 6 +++--- 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 changelog.d/uc-capital-derived-income.removed.md diff --git a/changelog.d/uc-capital-derived-income.fixed.md b/changelog.d/uc-capital-derived-income.fixed.md index 4aeed6d92..6294868b5 100644 --- a/changelog.d/uc-capital-derived-income.fixed.md +++ b/changelog.d/uc-capital-derived-income.fixed.md @@ -1 +1 @@ -Universal Credit no longer counts actual savings interest, dividends or rent as unearned income. Regulation 66(1) of the Universal Credit Regulations 2013 has no description that covers them, so capital counts only through its assumed yield (tariff income). Where that yield applies, regulation 72(3) treats the actual income from the capital as capital. Previously the model counted them in full unless tariff income applied and a matching household asset was recorded. When savings were missing from the data, interest was added on top of tariff income from other capital. +Universal Credit no longer counts actual savings interest, dividends or rent as unearned income: regulation 66(1) of the Universal Credit Regulations 2013 lists no description that covers them, so capital counts only through its assumed yield (tariff income). diff --git a/changelog.d/uc-capital-derived-income.removed.md b/changelog.d/uc-capital-derived-income.removed.md new file mode 100644 index 000000000..4119c877c --- /dev/null +++ b/changelog.d/uc-capital-derived-income.removed.md @@ -0,0 +1 @@ +Removed the `gov.dwp.universal_credit.means_test.income_definitions.capital_derived` parameter, which gated the removal of interest, dividends and rent from Universal Credit unearned income. diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index d53665d2c..aa5a9cb61 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -246,10 +246,10 @@ def test_uc_is_non_increasing_in_capital(units, source, bump, year): @pytest.mark.xfail( strict=True, + raises=AssertionError, reason=( - "PolicyEngine/policyengine-uk#1942: uc_earned_income deducts the whole " - "benefit unit's income tax, including the partner's tax on dividends, " - "from earnings" + "PolicyEngine/policyengine-uk#1942: uc_earned_income deducts the benefit " + "unit's whole income tax, including tax on dividends, from earnings" ), ) def test_tax_on_dividends_does_not_raise_a_working_familys_award(): From fbb7820e933f0b28d9087893f3d56f4fafa23adb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 10:27:23 -0400 Subject: [PATCH 7/8] Drop capital income from both dated blocks of the UC unearned list #1958 split the list into 2013-04-29 and 2023-11-19 blocks. The rebase removed interest, dividends and rent from the later block only, so remove them from the earlier one too. The single-count property now reads the listed sources from the parameter and asserts none of the three is on it. Co-Authored-By: Claude Opus 5.5 --- .../income_definitions/unearned.yaml | 3 -- .../test_uc_income_from_capital_properties.py | 29 ++++++++++++++----- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml index 10e976b0b..ff0d28245 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml @@ -63,9 +63,6 @@ values: - private_pension_income - state_pension - uc_tariff_income - - savings_interest_income - - dividend_income - - property_income 2023-11-19: - carers_allowance - uc_unearned_carer_support_payment diff --git a/policyengine_uk/tests/test_uc_income_from_capital_properties.py b/policyengine_uk/tests/test_uc_income_from_capital_properties.py index aa5a9cb61..0bc207f5a 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -13,8 +13,9 @@ 1. Single count (differential): unearned income equals the tariff income computed independently from reg. 72(1) on assessable capital, plus the - other listed sources the model carries, whatever interest, dividends and - rent the family receives. So capital yield enters once, as tariff income. + other sources on the model's reg. 66(1) list, whatever interest, dividends + and rent the family receives. So capital yield enters once, as tariff + income. None of those three is on the list in any year. 2. Invariance: interest, dividends and rent change nothing in unearned income, tariff income, eligibility or the maximum amount. For families without earnings they change nothing in the award either. @@ -79,7 +80,6 @@ "uc_assessable_capital", "is_uc_eligible", ] -OTHER_LISTED_SOURCES = ["carers_allowance", "jsa_contrib", "private_pension_income"] @st.composite @@ -150,11 +150,24 @@ def situation(units, year, income_scale=1.0, capital_bump=None): return {"people": people, "benunits": benunits, "households": households} +def listed_sources(sim, year): + """The model's reg. 66(1) list for the year, as variable names.""" + parameters = sim.tax_benefit_system.parameters(f"{year}-01-01") + means_test = parameters.gov.dwp.universal_credit.means_test + return list(means_test.income_definitions.unearned) + + def calculate(units, year, **kwargs): sim = Simulation(situation=situation(units, year, **kwargs)) values = {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} - for v in OTHER_LISTED_SOURCES: - values[v] = np.asarray(sim.calculate(v, year, map_to="benunit")) + sources = listed_sources(sim, year) + # No actual income from capital is on the list in any year. + assert not set(CAPITAL_INCOME) & set(sources), sources + values["other_listed_sources"] = sum( + np.asarray(sim.calculate(v, year, map_to="benunit")) + for v in sources + if v != "uc_tariff_income" + ) return values @@ -177,9 +190,11 @@ def test_capital_yield_is_counted_once_as_tariff_income(units, year): np.testing.assert_allclose( values["uc_tariff_income"], tariff, atol=0.01, err_msg=str(units) ) - listed = sum(values[v] for v in OTHER_LISTED_SOURCES) np.testing.assert_allclose( - values["uc_unearned_income"], tariff + listed, atol=0.01, err_msg=str(units) + values["uc_unearned_income"], + tariff + values["other_listed_sources"], + atol=0.01, + err_msg=str(units), ) From b2369e4e7bec601f7498ed0ef5097d662a0b463f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:49:49 +0100 Subject: [PATCH 8/8] Fix UC income integration tests and documentation --- .../programs/gov/dwp/universal-credit.ipynb | 2 +- .../tests/test_uc_state_pension_properties.py | 51 +++++++++++++++---- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index 9717bfba2..5e4ecbe44 100644 --- a/docs/book/programs/gov/dwp/universal-credit.ipynb +++ b/docs/book/programs/gov/dwp/universal-credit.ipynb @@ -44,7 +44,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, carer support payment (up to the Carer's Allowance rate, from November 2023), contribution-based JSA and ESA, maternity allowance, industrial injuries benefit, savings, dividend and property income, and the tariff income deemed from capital (reg 72), which replaces the actual yield of the capital it is charged on. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." + "source": "## How PolicyEngine computes Universal Credit\n\nPolicyEngine computes Universal Credit at the benefit-unit level in three stages:\n\n1. **Maximum entitlement** (`uc_maximum_amount`): sum the elements the benunit qualifies for — `uc_standard_allowance`, `uc_child_element`, `uc_disability_elements`, `uc_carer_element`, `uc_housing_costs_element`, and `uc_childcare_element`.\n2. **Means test**: the maximum entitlement is reduced by the benunit's countable earnings above its work allowance (`uc_work_allowance`) at the published taper rate (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter, currently 55%), plus all of its unearned income (`uc_unearned_income`), pound for pound. Unearned income is the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`: retirement pension income (State Pension and private pensions, UC Regulations 2013 regs 66(1)(a) and 67), Carer's Allowance, carer support payment (up to the Carer's Allowance rate, from November 2023), contribution-based JSA and ESA, maternity allowance, industrial injuries benefit, and the tariff income deemed from capital (reg 72). Actual savings interest, dividends and ordinary rental income are excluded from UC unearned income at every capital level; capital counts through its tariff income instead. Asset rules apply on top of this — capital above the lower threshold deems a tariff income, and capital above the upper threshold disqualifies the benunit entirely.\n3. **Benefit cap**: the post-means-test award (`universal_credit_pre_benefit_cap`) is finally reduced by `benefit_cap_reduction` to produce `universal_credit`. The benefit cap only applies to benunits without a benefit-cap exemption (working enough hours, having a qualifying disability benefit, etc.).\n\nThe take-up step is handled by the input variable `would_claim_uc`, which is populated stochastically when the dataset is built so that PolicyEngine's caseload aggregates match published DWP claimant numbers rather than the full eligible population.\n\nParameters live in `policyengine_uk/parameters/gov/dwp/universal_credit/` and the per-element formulas in `policyengine_uk/variables/gov/dwp/universal_credit/`." }, { "cell_type": "code", diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index 9b6802274..70ee6af40 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -16,21 +16,24 @@ d lowers the award before the benefit cap by exactly min(d, award). 3. Equivalence: UC with State Pension x equals UC with the same x of private pension income received by the same person instead (both are retirement - pension income, taxed the same way). With no earnings in the family, it - also equals UC with x of property income (held without property capital, - so it is not treated as capital yield under reg. 72). + pension income, taxed the same way). +4. Property income is outside reg. 66(1)'s list of unearned income, at any + capital level. With no earnings, replacing State Pension x with ordinary + property income leaves the award equal to the no-income case, while + State Pension reduces the pre-cap award by min(x, award). Any tariff + income from capital remains the same in all three cases. -Invariants 2 and 3 are restricted to families without earnings because the +Invariants 2 and 4 are restricted to families without earnings because the model deducts the whole benefit unit's income tax from its earnings (PolicyEngine/policyengine-uk#1942), so tax on State Pension reduces earned income. Reg. 55(5)(b) and reg. 57 step 3 allow only tax paid in respect of the employment or trade. The strict xfail below pins that case and will flip -when #1942 is fixed; widen invariants 2 and 3 to all families then. +when #1942 is fixed; widen invariants 2 and 4 to all families then. """ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -199,11 +202,37 @@ def test_state_pension_counts_like_private_pension(units, year): units=st.lists(families(with_earnings=False), min_size=1, max_size=20), year=st.sampled_from(YEARS), ) -def test_state_pension_counts_like_property_income(units, year): - assert_same( - calculate(units, year), - calculate(units, year, income_variable="property_income"), - str(units), +@example( + units=[ + dict( + ages=[70, 60], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=6_240.0, + savings=0.0, + earnings=0.0, + state_pension=9_000.0, + ) + ], + year=2026, +) +def test_property_income_is_excluded_while_state_pension_counts(units, year): + pension = calculate(units, year) + property_income = calculate(units, year, income_variable="property_income") + without_income = calculate([{**unit, "state_pension": 0.0} for unit in units], year) + assert_same(property_income, without_income, str(units)) + pensions = np.array([unit["state_pension"] for unit in units]) + np.testing.assert_allclose( + pension["uc_unearned_income"] - property_income["uc_unearned_income"], + pensions, + atol=0.01, + err_msg=str(units), + ) + np.testing.assert_allclose( + pension["universal_credit_pre_benefit_cap"], + np.maximum(0, property_income["universal_credit_pre_benefit_cap"] - pensions), + atol=0.01, + err_msg=str(units), )