From c07c4e2f93b494b390c3288ddfe0cd736b1cf92b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 14:05:26 -0400 Subject: [PATCH 01/11] Deduct only each person's own tax and NI on earnings from UC earned income uc_earned_income subtracted benunit_tax, the whole benefit unit's income tax and NI on all income, from the unit's gross earnings. UC Regs 2013 reg. 55(5)(b) and reg. 57(2) step 3 deduct only tax and NI paid by the person in respect of their employment or trade. - uc_income_tax_on_earnings: income tax on the person's earnings, taken as the lowest slice of their non-savings income after their allowances (savings and dividends sit above it under ITA 2007 s. 16, property income above it in the engine and under s. 16A from 2027-28). - uc_national_insurance_on_earnings: Class 1 employee, Class 2, Class 4. - uc_individual_earned_income: gross earnings less the person's own pension contributions, tax and NI, floored at nil. - uc_earned_income: sum over the unit, less the work allowance. Fixes #1942. Co-Authored-By: Claude Opus 5.5 --- changelog.d/uc-earned-income-own-tax.fixed.md | 1 + .../income/uc_earned_income.yaml | 46 +++- .../income/uc_earnings_deductions.yaml | 250 ++++++++++++++++++ .../income/uc_earned_income.py | 21 +- .../income/uc_income_tax_on_earnings.py | 81 ++++++ .../income/uc_individual_earned_income.py | 41 +++ .../uc_national_insurance_on_earnings.py | 30 +++ 7 files changed, 457 insertions(+), 13 deletions(-) create mode 100644 changelog.d/uc-earned-income-own-tax.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_earnings.py diff --git a/changelog.d/uc-earned-income-own-tax.fixed.md b/changelog.d/uc-earned-income-own-tax.fixed.md new file mode 100644 index 0000000000..0a800b871c --- /dev/null +++ b/changelog.d/uc-earned-income-own-tax.fixed.md @@ -0,0 +1 @@ +Deduct from each person's Universal Credit earnings only their own income tax and National Insurance on that employment or self-employment (UC Regs 2013 reg. 55(5)(b), reg. 57(2) step 3), not the whole benefit unit's tax on all income, so tax on pensions, State Pension, property, savings and dividends, the High Income Child Benefit Charge and voluntary Class 3 contributions no longer reduce earned income. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml index 3647f0e3d4..378941b5d7 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earned_income.yaml @@ -5,9 +5,10 @@ employment_income: 1_000 miscellaneous_income: 100 uc_work_allowance: 10 - benunit_tax: 1 + uc_income_tax_on_earnings: 1 output: - uc_earned_income: 1100 - 11 + uc_individual_earned_income: 1_100 - 1 + uc_earned_income: 1_100 - 11 - name: Capped at 0 period: 2025 @@ -16,6 +17,45 @@ employment_income: 1_000 miscellaneous_income: 100 uc_work_allowance: 2_000 - benunit_tax: 1 + uc_income_tax_on_earnings: 1 output: uc_earned_income: 0 + +- name: Each person's deductions come off only their own earnings + period: 2025 + absolute_error_margin: 0 + input: + people: + earner: + employment_income: 1_000 + uc_income_tax_on_earnings: 100 + uc_national_insurance_on_earnings: 10 + partner: + uc_income_tax_on_earnings: 500 + pension_contributions: 50 + benunits: + benunit: + members: [earner, partner] + uc_work_allowance: 0 + output: + uc_individual_earned_income: [890, 0] + uc_earned_income: 890 + +- name: Work allowance comes off combined earnings after each person's deductions + period: 2025 + absolute_error_margin: 0 + input: + people: + earner: + employment_income: 1_000 + uc_income_tax_on_earnings: 100 + partner: + employment_income: 500 + pension_contributions: 50 + benunits: + benunit: + members: [earner, partner] + uc_work_allowance: 300 + output: + uc_individual_earned_income: [900, 450] + uc_earned_income: 1_050 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml new file mode 100644 index 0000000000..2e9505a83e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -0,0 +1,250 @@ +# Universal Credit deducts from a person's earnings only the income tax and +# National Insurance that person pays in respect of their own employment +# (UC Regs 2013 reg. 55(5)(b)) or trade (reg. 57(2) step 3), plus their own +# relievable pension contributions (reg. 55(5)(a)). Tax on pensions, State +# Pension, property, savings and dividends is not deducted, and one partner's +# tax never reduces the other's earnings. Earnings are taken as the lowest +# slice of the person's non-savings income, after their allowances; savings +# and dividends sit above all non-savings income (ITA 2007 s. 16). +# +# Hand calculations use the 2026-27 rates: +# - personal allowance 12,570; basic rate 20% on the first 37,700 of taxable +# income; dividend allowance 500, dividend ordinary rate 10.75%; property +# allowance 1,000; trading allowance 1,000; +# - primary Class 1 NI 8% above the primary threshold of 241.73 a week +# (12,569.96 a year); Class 4 NI 6% above the lower profits limit of 12,570; +# Class 2 NI nil; +# - UC standard allowance 424.90 a month (single, 25 or over) or 666.97 +# (couple, one 25 or over); child element 303.94 a month; work allowance +# 427 a month with housing costs; taper 55%. + +- name: Single parent with earnings and property income, only tax on earnings is deducted + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 20_000 + property_income: 5_000 + child: + age: 5 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + tenure_type: RENT_FROM_COUNCIL + rent: 9_600 + output: + # Earnings take the personal allowance first: (20,000 - 12,570) x 20%. + # Property income is taxed on (5,000 - 1,000) x 20% = 800, not deducted. + income_tax: [2_286, 0] + uc_income_tax_on_earnings: [1_486, 0] + # (20,000 - 12,569.96) x 8%. + uc_national_insurance_on_earnings: [594.40, 0] + # 20,000 - 1,486 - 594.40. + uc_individual_earned_income: [17_919.60, 0] + # Less the work allowance of 427 x 12 = 5,124. + uc_earned_income: 12_795.60 + uc_unearned_income: 5_000 + # 424.90 x 12 + 303.94 x 12 + 9,600 = 5,098.80 + 3,647.28 + 9,600. + uc_maximum_amount: 18_346.08 + # 18,346.08 - (0.55 x 12,795.60 + 5,000). Deducting all of the tax, as + # before this fix, gave 6,748.50: 0.55 x 800 = 440 too much. + universal_credit: 6_308.50 + +- name: Mixed-age couple, the pensioner's tax on State Pension does not reduce the partner's earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 16_000 + partner: + age: 45 + employment_income: 13_000 + benunits: + benunit: + members: [pensioner, partner] + households: + household: + members: [pensioner, partner] + tenure_type: RENT_FROM_COUNCIL + rent: 20_000 + output: + # Pensioner: (16,000 - 12,570) x 20% = 686, on State Pension. + # Partner: (13,000 - 12,570) x 20% = 86 and (13,000 - 12,569.96) x 8% + # = 34.40. Both are above the personal allowance, so there is no + # Marriage Allowance. + income_tax: [686, 86] + uc_income_tax_on_earnings: [0, 86] + uc_national_insurance_on_earnings: [0, 34.40] + # 13,000 - 86 - 34.40. + uc_earned_income: 12_879.60 + uc_unearned_income: 16_000 + # 666.97 x 12 + 20,000. + uc_maximum_amount: 28_003.64 + # 28,003.64 - (0.55 x 12,879.60 + 16,000). Deducting the whole benefit + # unit's tax, as before this fix, gave 5,297.16: 0.55 x 686 = 377.30 + # too much. + universal_credit: 4_919.86 + +- name: Self-employed claimant with pension and dividend income, only tax on the trade is deducted + period: 2026 + absolute_error_margin: 0.01 + input: + age: 55 + self_employment_income: 18_000 + # In a start-up period, so the minimum income floor does not apply. + uc_is_in_startup_period: true + private_pension_income: 4_000 + dividend_income: 3_000 + tenure_type: RENT_FROM_COUNCIL + rent: 12_000 + output: + # Trade: taxable profit 18,000 - 1,000 trading allowance = 17,000; it + # takes the personal allowance first, (17,000 - 12,570) x 20% = 886. + # Pension: 4,000 x 20% = 800. Dividends: (3,000 - 500) x 10.75% = + # 268.75. Neither is deducted. + income_tax: 1_954.75 + uc_income_tax_on_earnings: 886 + # Class 4: (18,000 - 12,570) x 6%. + uc_national_insurance_on_earnings: 325.80 + # 18,000 - 886 - 325.80, with no work allowance. + uc_earned_income: 16_788.20 + uc_unearned_income: 7_000 + # 424.90 x 12 + 12,000. + uc_maximum_amount: 17_098.80 + # 17,098.80 - (0.55 x 16,788.20 + 7,000). Deducting all of the tax and + # NI, as before this fix, gave 1,453.10: 0.55 x 1,068.75 = 587.81 too + # much. + universal_credit: 865.29 + +- name: Scottish taxpayer, earnings sit below pension income in the Scottish bands + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 20_000 + private_pension_income: 10_000 + region: SCOTLAND + output: + # 2026-27 Scottish bands above the personal allowance: starter 19% on the + # first 3,967, basic 20% to 16,956, intermediate 21% to 31,092. + # Earnings slice 7,430: 3,967 x 19% + 3,463 x 20% = 753.73 + 692.60. + uc_income_tax_on_earnings: 1_446.33 + # Pension slice 7,430 to 17,430: 9,526 x 20% + 474 x 21% = 2,004.74. + income_tax: 3_451.07 + +- name: Couple with Marriage Allowance, the recipient's reduction lowers the tax on their earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 15_000 + dividend_income: 5_000 + transferor: + age: 38 + employment_income: 10_000 + benunits: + benunit: + members: [recipient, transferor] + households: + household: + members: [recipient, transferor] + output: + # The transferor's unused allowance of 2,570 exceeds the 1,257 cap, which + # rounds up to 1,260. Tax on the recipient's earnings: + # (15,000 - 12,570 - 1,260) x 20% = 234, the same as 486 less the + # 20% x 1,260 = 252 reduction. Dividends: (5,000 - 500) x 10.75% = 483.75, + # not deducted. + marriage_allowance: [1_260, 0] + income_tax: [717.75, 0] + uc_income_tax_on_earnings: [234, 0] + # (15,000 - 12,569.96) x 8%. + uc_national_insurance_on_earnings: [194.40, 0] + uc_individual_earned_income: [14_571.60, 10_000] + uc_earned_income: 24_571.60 + +- name: Voluntary Class 3 NI is not deducted + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 13_000 + ni_class_3: 900 + output: + national_insurance: 934.40 + uc_national_insurance_on_earnings: 34.40 + # 13,000 - 86 - 34.40. + uc_earned_income: 12_879.60 + +- name: Pension contributions come off only the contributor's own earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 20_000 + employee_pension_contributions: 1_000 + would_claim_marriage_allowance: false + partner: + age: 38 + personal_pension_contributions: 1_000 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [earner, partner] + households: + household: + members: [earner, partner] + output: + # Relief on the earner's contributions: (20,000 - 12,570 - 1,000) x 20%. + uc_income_tax_on_earnings: [1_286, 0] + # 20,000 - 1,000 - 1,286 - 594.40. The partner has no earnings, so their + # own contributions reduce nothing. + uc_individual_earned_income: [17_119.60, 0] + uc_earned_income: 17_119.60 + +- name: High Income Child Benefit Charge is not deducted + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 70_000 + child: + age: 3 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + output: + # Child Benefit 27.05 x 52 = 1,406.60; the charge takes + # (70,000 - 60,000) / 200 = 50% of it, 703.30. + CB_HITC: [703.30, 0] + income_tax: [16_135.30, 0] + # 37,700 x 20% + (57,430 - 37,700) x 40%. + uc_income_tax_on_earnings: [15_432, 0] + +- name: From 2027 property income has its own rates, and its tax is still not deducted + period: 2027 + absolute_error_margin: 0.01 + input: + age: 35 + employment_income: 20_000 + property_income: 5_000 + output: + # Property: (5,000 - 1,000) x 22% = 880. + property_income_tax: 880 + income_tax: 2_366 + uc_income_tax_on_earnings: 1_486 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py index 8ff40e4a84..1880148d36 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_earned_income.py @@ -4,17 +4,18 @@ class uc_earned_income(Variable): value_type = float entity = BenUnit - label = "Universal Credit earned income (after disregards and tax)" + label = "Universal Credit earned income (after deductions and work allowance)" definition_period = YEAR unit = GBP + reference = dict( + title="Universal Credit Regulations 2013 reg. 22(1)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/22", + ) def formula(benunit, period, parameters): - personal_gross_earned_income = add( - benunit, period, ["uc_mif_capped_earned_income"] - ) - disregards = add( - benunit, - period, - ["uc_work_allowance", "benunit_tax", "pension_contributions"], - ) - return max_(0, personal_gross_earned_income - disregards) + # Each person's earned income is net of their own deductions + # (reg. 55(5), reg. 57(2)); the work allowance then comes off the + # combined earned income before the taper (reg. 22(1)(b)). + earned_income = add(benunit, period, ["uc_individual_earned_income"]) + work_allowance = benunit("uc_work_allowance", period) + return max_(0, earned_income - work_allowance) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py new file mode 100644 index 0000000000..3ac15ab56f --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py @@ -0,0 +1,81 @@ +from policyengine_uk.model_api import * + + +class uc_income_tax_on_earnings(Variable): + value_type = float + entity = Person + label = "Universal Credit deduction for income tax on the person's earnings" + documentation = ( + "Income tax the person pays in respect of their own employment and " + "self-employment, which Universal Credit deducts from their earned " + "income. Tax on pensions, State Pension, property, savings and " + "dividends is not deducted, nor is the High Income Child Benefit " + "Charge or the pension annual allowance charge." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 55(5)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + ), + dict( + title="Universal Credit Regulations 2013 reg. 57(2), step 3", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), + dict( + title="Income Tax Act 2007 s. 16", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/16", + ), + ] + + def formula(person, period, parameters): + # Reg. 55(5)(b) deducts income tax paid by the person "in respect of + # the employment" and reg. 57(2) step 3 income tax paid "in respect + # of any trade". When the person has other income too, earnings are + # taken as the lowest slice of their non-savings income, after the + # allowances they actually have. Savings and dividends sit above all + # non-savings income (ITA 2007 s. 16) and property income above the + # rest of it, so tax on them never falls on earnings. Other + # non-savings income (private pensions, State Pension, taxable + # benefits) sits above earnings. This is the tax a standard tax code + # deducts from a sole or main employment. + p = parameters(period) + earnings_components = [ + "taxable_employment_income", + "taxable_self_employment_income", + "taxable_miscellaneous_income", + ] + # Match the gross earnings in uc_mif_capped_earned_income. + bi = p.gov.contrib.ubi_center.basic_income.interactions + if bi.include_in_means_tests and bi.include_in_taxable_income: + earnings_components.append("basic_income") + earnings = add(person, period, earnings_components) + # earned_taxable_income is non-savings, non-property income after + # allowances. The part of it above earnings belongs to the person's + # other non-savings income. + non_savings_non_property_income = person( + "adjusted_net_income", period + ) - add( + person, + period, + [ + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + ], + ) + other_income = max_(0, non_savings_non_property_income - earnings) + taxable_earnings = max_( + 0, person("earned_taxable_income", period) - other_income + ) + rates = p.gov.hmrc.income_tax.rates + tax = where( + person("pays_scottish_income_tax", period), + rates.scotland.rates.calc(taxable_earnings), + rates.uk.calc(taxable_earnings), + ) + # Tax reductions (for example the married couple's allowance) can + # leave total income tax below the tax on the earnings slice; never + # deduct more than the person pays. + return min_(tax, person("income_tax", period)) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py new file mode 100644 index 0000000000..7995416bcb --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * + + +class uc_individual_earned_income(Variable): + value_type = float + entity = Person + label = "Universal Credit earned income of the person" + documentation = ( + "The person's earned income for Universal Credit, after the " + "deductions for their own relievable pension contributions and their " + "own income tax and National Insurance in respect of their employment " + "and self-employment, before the work allowance." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 55(5)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + ), + dict( + title="Universal Credit Regulations 2013 reg. 57(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), + ] + + def formula(person, period, parameters): + # The deductions are the person's own and come off only their own + # earnings, so one partner's tax, NI or pension contributions never + # reduce the other partner's earned income. + gross_earnings = person("uc_mif_capped_earned_income", period) + deductions = add( + person, + period, + [ + "pension_contributions", + "uc_income_tax_on_earnings", + "uc_national_insurance_on_earnings", + ], + ) + return max_(0, gross_earnings - deductions) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_earnings.py new file mode 100644 index 0000000000..93ca6e8d71 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_national_insurance_on_earnings.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class uc_national_insurance_on_earnings(Variable): + value_type = float + entity = Person + label = "Universal Credit deduction for National Insurance on the person's earnings" + documentation = ( + "Primary Class 1 contributions on the person's employment and Class 2 " + "and Class 4 contributions on their trade, which Universal Credit " + "deducts from their earned income. Voluntary Class 3 contributions " + "are not in respect of an employment or trade, so are not deducted." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 55(5)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + ), + dict( + title="Universal Credit Regulations 2013 reg. 57(2), step 3", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), + ] + adds = [ + "ni_class_1_employee", + "ni_class_2", + "ni_class_4", + ] From d0fc22f39f3da2b65e4eb96caa8b739f42441045 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 14:25:23 -0400 Subject: [PATCH 02/11] Add UC earnings-deduction property tests; widen State Pension properties - test_uc_earnings_deductions_properties.py: unearned income never changes earned income; UC is non-increasing in every kind of unearned income; the tax deducted equals the tax on earnings alone; earned income is never lower, and UC never higher, than under the pre-fix formula. - test_uc_state_pension_properties.py: the #1942 strict xfail now passes and loses its marker; pound-for-pound and the property-income equivalence cover families with earnings; a new strict xfail pins the Marriage Allowance transfer, which the model books on the recipient as the transferor's unused allowance (ITA 2007 s. 55B gives a fixed reduction). - Document the earnings deductions in the UC docs page. Co-Authored-By: Claude Opus 5.5 --- .../programs/gov/dwp/universal-credit.ipynb | 2 +- .../test_uc_earnings_deductions_properties.py | 258 ++++++++++++++++++ .../tests/test_uc_state_pension_properties.py | 104 +++++-- .../income/uc_income_tax_on_earnings.py | 22 +- 4 files changed, 344 insertions(+), 42 deletions(-) create mode 100644 policyengine_uk/tests/test_uc_earnings_deductions_properties.py diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index abc35df06f..c87f8f2514 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, contribution-based JSA, 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. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. The combined earnings above the work allowance are then tapered (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, contribution-based JSA, 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/`." }, { "cell_type": "code", diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py new file mode 100644 index 0000000000..1d68ea2b29 --- /dev/null +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -0,0 +1,258 @@ +"""Property-based tests for the Universal Credit deductions from earnings. + +UC Regs 2013 reg. 55(5) and reg. 57(2) deduct from a person's earnings only +their own relievable pension contributions and the income tax and National +Insurance they pay in respect of their employment or trade. The model takes +earnings as the lowest slice of the person's non-savings income, after their +allowances, with other income above it (ITA 2007 s. 16 for savings and +dividends). + +Invariants, for any generated population of single people and couples with +earnings, self-employment and every kind of taxable unearned income: + +1. Unearned income never changes earned income: giving any adult more State + Pension, private pension, property, savings or dividend income leaves + every person's uc_individual_earned_income, and so uc_earned_income, + unchanged. +2. Monotone: the UC award, before and after the benefit cap, is + non-increasing in each kind of unearned income for each adult. +3. Differential against the tax engine: the income tax deducted equals the + income tax the same person pays when their earnings are their only + income, and the NI deducted equals their NI. +4. Differential against the formula this replaced: earned income is never + lower, and UC never higher, than when the whole benefit unit's tax was + deducted, because only deductions were removed. + +Invariants 1-3 hold only while unearned income leaves the person's +allowances alone, so the generated incomes keep adjusted net income below +the personal allowance taper (100,000), no one is old enough for the +married couple's allowance, and no one claims Marriage Allowance (see +test_uc_state_pension_properties.py for the Marriage Allowance deviation). +Invariant 4 runs with Marriage Allowance claimed. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.model_api import * +from policyengine_uk.utils.scenario import Scenario + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# 2020 has the temporary UC uplift; 2026 current rates; 2027 the new property +# and savings rates (Finance Act 2026). +YEARS = [2020, 2026, 2027] +TENURES = [ + "RENT_FROM_COUNCIL", + "RENT_FROM_HA", + "RENT_PRIVATELY", + "OWNED_OUTRIGHT", +] +REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND"] +UNEARNED = [ + "state_pension", + "private_pension_income", + "property_income", + "savings_interest_income", + "dividend_income", +] +# Married couple's allowance needs a birth before 6 April 1935 (85 in 2020). +PENSION_AGE = st.integers(67, 84) +WORKING_AGE = st.integers(18, 60) +SHAPES = { + "single": [WORKING_AGE], + "couple": [WORKING_AGE, WORKING_AGE], + "mixed_age": [PENSION_AGE, WORKING_AGE], +} +# Per adult: earnings up to 50,000 and unearned income up to 5 x 7,000, +# plus a bump up to 9,000, keeps adjusted net income under 100,000. +earnings = st.one_of(st.just(0.0), st.floats(0, 30_000)) +self_employment = st.one_of(st.just(0.0), st.floats(0, 20_000)) +unearned = st.one_of(st.just(0.0), st.floats(0, 7_000)) +bumps = st.floats(1, 9_000) +UC_VARIABLES = [ + "universal_credit", + "universal_credit_pre_benefit_cap", + "uc_earned_income", +] +PERSON_VARIABLES = [ + "uc_individual_earned_income", + "uc_income_tax_on_earnings", + "uc_national_insurance_on_earnings", + "income_tax", + "national_insurance", +] + + +@st.composite +def families(draw): + shape = draw(st.sampled_from(list(SHAPES))) + adults = [] + for age in [draw(age) for age in SHAPES[shape]]: + adult = dict( + age=age, + employment_income=draw(earnings), + self_employment_income=draw(self_employment), + # Some self-employed are in a start-up period, so the minimum + # income floor does not apply. + uc_is_in_startup_period=draw(st.booleans()), + ) + for variable in UNEARNED: + if variable == "state_pension" and age < 67: + continue + adult[variable] = draw(unearned) + adults.append(adult) + return dict( + adults=adults, + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], + tenure=draw(st.sampled_from(TENURES)), + rent=draw(st.floats(0, 15_000)), + region=draw(st.sampled_from(REGIONS)), + ) + + +def situation(units, year, bump=None, earnings_only=False, marriage_allowance=False): + """One simulation holding every family. + + ``bump`` is (family index, adult index, variable, amount) to add. + ``earnings_only`` drops every kind of unearned income. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + name = f"p{i}_{j}" + person = {"state_pension": {year: 0.0}} + for variable, value in adult.items(): + if earnings_only and variable in UNEARNED: + continue + person[variable] = {year: value} + if bump is not None and bump[:2] == (i, j) and not earnings_only: + variable, amount = bump[2], bump[3] + person[variable] = {year: adult.get(variable, 0.0) + amount} + if not marriage_allowance: + person["would_claim_marriage_allowance"] = {year: False} + 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) + benunits[f"b{i}"] = {"members": names} + households[f"h{i}"] = { + "members": names, + "rent": {year: unit["rent"]}, + "tenure_type": {year: unit["tenure"]}, + "region": {year: unit["region"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(units, year, scenario=None, **kwargs): + sim = Simulation(situation=situation(units, year, **kwargs), scenario=scenario) + values = {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + for v in PERSON_VARIABLES: + values[v] = np.asarray(sim.calculate(v, year)) + values["is_adult_person"] = np.asarray(sim.calculate("age", year)) >= 18 + return values + + +@st.composite +def bumped(draw): + units = draw(st.lists(families(), min_size=1, max_size=10)) + i = draw(st.integers(0, len(units) - 1)) + j = draw(st.integers(0, len(units[i]["adults"]) - 1)) + variables = UNEARNED if units[i]["adults"][j]["age"] >= 67 else UNEARNED[1:] + return units, (i, j, draw(st.sampled_from(variables)), draw(bumps)) + + +@PROPERTY_SETTINGS +@given(case=bumped(), year=st.sampled_from(YEARS)) +def test_unearned_income_never_changes_earned_income(case, year): + units, bump = case + low = calculate(units, year) + high = calculate(units, year, bump=bump) + for variable in ["uc_individual_earned_income", "uc_earned_income"]: + np.testing.assert_allclose( + high[variable], low[variable], atol=0.01, err_msg=f"{bump} {units}" + ) + + +@PROPERTY_SETTINGS +@given(case=bumped(), year=st.sampled_from(YEARS)) +def test_uc_is_non_increasing_in_unearned_income(case, year): + units, bump = case + low = calculate(units, year) + high = calculate(units, year, bump=bump) + for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: + assert np.all(high[variable] <= low[variable] + 0.01), (variable, bump, units) + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=10), + year=st.sampled_from(YEARS), +) +def test_deductions_equal_tax_and_ni_on_earnings_alone(units, year): + full = calculate(units, year) + alone = calculate(units, year, earnings_only=True) + adult = full["is_adult_person"] + np.testing.assert_allclose( + full["uc_income_tax_on_earnings"][adult], + alone["income_tax"][adult], + atol=0.01, + err_msg=str(units), + ) + np.testing.assert_allclose( + full["uc_national_insurance_on_earnings"][adult], + alone["national_insurance"][adult], + atol=0.01, + err_msg=str(units), + ) + # Never more than the person's own tax and NI. + assert np.all(full["uc_income_tax_on_earnings"] <= full["income_tax"] + 0.01) + assert np.all( + full["uc_national_insurance_on_earnings"] <= full["national_insurance"] + 0.01 + ) + + +def _use_formula_before_1942(simulation): + class uc_earned_income(Variable): + value_type = float + entity = BenUnit + label = "UC earned income as computed before #1942" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + gross = add(benunit, period, ["uc_mif_capped_earned_income"]) + disregards = add( + benunit, + period, + ["uc_work_allowance", "benunit_tax", "pension_contributions"], + ) + return max_(0, gross - disregards) + + simulation.tax_benefit_system.update_variable(uc_earned_income) + + +BEFORE_1942 = Scenario(simulation_modifier=_use_formula_before_1942) + + +@PROPERTY_SETTINGS +@given( + units=st.lists(families(), min_size=1, max_size=10), + year=st.sampled_from(YEARS), +) +def test_fix_only_removes_deductions(units, year): + after = calculate(units, year, marriage_allowance=True) + before = calculate(units, year, scenario=BEFORE_1942, marriage_allowance=True) + assert np.all(after["uc_earned_income"] >= before["uc_earned_income"] - 0.01), units + for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: + assert np.all(after[variable] <= before[variable] + 0.01), (variable, units) diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index 9b68022746..d3b3963724 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -12,20 +12,22 @@ 1. Monotone: the UC award, before and after the benefit cap, is non-increasing in State Pension, and more State Pension adds exactly that much to unearned income without changing the maximum amount. -2. Pound for pound: with no earnings in the family, raising State Pension by - d lowers the award before the benefit cap by exactly min(d, award). +2. Pound for pound: raising State Pension by d lowers the award before the + benefit cap by exactly min(d, award), whatever the partner earns, because + tax on State Pension never comes off earnings (reg. 55(5)(b), reg. 57(2) + step 3). 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). - -Invariants 2 and 3 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. + pension income, taxed the same way). It also equals UC with x of property + income (held without property capital, so it is not treated as capital + yield under reg. 72), although property income is taxed on less. + +Marriage Allowance is switched off for invariants 2 and 3. The model gives +the recipient min(partner's unused personal allowance, 10% of the personal +allowance) instead of the fixed transferable amount in ITA 2007 s. 55B(4)-(6), +so State Pension that uses up the pensioner's unused allowance raises the +earning partner's tax on earnings. The strict xfail at the end pins that +deviation (PolicyEngine/policyengine-uk#MA_ISSUE). """ import numpy as np @@ -71,12 +73,12 @@ @st.composite -def families(draw, with_earnings=True): +def families(draw): # Weight towards mixed-age couples, the only shape UC can reach. shape = draw( st.sampled_from(["mixed_age", "mixed_age", "single_pension", "couple_pension"]) ) - earnings = draw(st.one_of(st.just(0.0), money)) if with_earnings else 0.0 + earnings = draw(st.one_of(st.just(0.0), money)) return dict( ages=[draw(age) for age in SHAPES[shape]], children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], @@ -88,14 +90,21 @@ def families(draw, with_earnings=True): ) -def situation(units, year, income_variable="state_pension", pension_bump=0.0): +def situation( + units, + year, + income_variable="state_pension", + pension_bump=0.0, + marriage_allowance=True, +): """Build one simulation holding every family. The eldest adult receives the family's State Pension (plus ``pension_bump``) under ``income_variable``; when that is not ``state_pension``, their State Pension is set to zero so the same amount arrives as the other income instead. A working-age partner receives the - family's earnings. + family's earnings. With ``marriage_allowance=False`` no one claims + Marriage Allowance. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -103,6 +112,8 @@ def situation(units, year, income_variable="state_pension", pension_bump=0.0): for j, age in enumerate(unit["ages"]): name = f"p{i}_{j}" person = {"age": {year: age}, "state_pension": {year: 0.0}} + if not marriage_allowance: + person["would_claim_marriage_allowance"] = {year: False} if j == 0: amount = unit["state_pension"] + pension_bump person[income_variable] = {year: amount} @@ -165,13 +176,15 @@ def test_uc_is_non_increasing_in_state_pension(units, bump, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), bump=st.floats(0, 20_000, allow_nan=False, allow_infinity=False), year=st.sampled_from(YEARS), ) def test_uc_falls_pound_for_pound_in_state_pension(units, bump, year): - low = calculate(units, year) - high = calculate(units, year, pension_bump=bump) + # Earnings in the family change nothing: tax on State Pension is never + # deducted from anyone's earnings (reg. 55(5)(b), reg. 57(2) step 3). + low = calculate(units, year, marriage_allowance=False) + high = calculate(units, year, pension_bump=bump, marriage_allowance=False) award = low["universal_credit_pre_benefit_cap"] np.testing.assert_allclose( high["universal_credit_pre_benefit_cap"], @@ -196,25 +209,24 @@ def test_state_pension_counts_like_private_pension(units, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), year=st.sampled_from(YEARS), ) def test_state_pension_counts_like_property_income(units, year): + # Property income is taxed on less (the 1,000 property allowance), but + # neither tax comes off the partner's earnings. assert_same( - calculate(units, year), - calculate(units, year, income_variable="property_income"), + calculate(units, year, marriage_allowance=False), + calculate( + units, + year, + income_variable="property_income", + marriage_allowance=False, + ), str(units), ) -@pytest.mark.xfail( - strict=True, - reason=( - "PolicyEngine/policyengine-uk#1942: uc_earned_income deducts the whole " - "benefit unit's income tax, including the pensioner's tax on State " - "Pension, from the partner's earnings" - ), -) def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): # 2026: pensioner aged 70 with State Pension 16,000 pays income tax of # (16,000 - 12,570) x 20% = 686. The partner aged 45 earns 13,000 and pays @@ -235,3 +247,35 @@ def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): values = calculate([unit], 2026) assert values["uc_earned_income"][0] == pytest.approx(12_879.60, abs=0.01) assert values["universal_credit"][0] == pytest.approx(4_919.86, abs=0.01) + + +@pytest.mark.xfail( + strict=True, + reason=( + "PolicyEngine/policyengine-uk#MA_ISSUE: Marriage Allowance is booked on " + "the recipient as the transferor's unused personal allowance, so the " + "transferor's State Pension raises the recipient's tax on earnings" + ), +) +def test_state_pension_does_not_change_partners_marriage_allowance(): + # 2026: pensioner aged 70, partner aged 45 earning 20,000, council rent + # 20,000. Once the pensioner elects, ITA 2007 s. 55B gives the partner a + # fixed reduction of 20% x 1,260 = 252 and cuts the pensioner's own + # allowance by 1,260 (s. 55B(6)); the pensioner's State Pension of 12,000 + # keeps them within the basic rate, so they can elect (s. 55C(1)(c)). + # Partner's tax on earnings = (20,000 - 12,570) x 20% - 252 = 1,234 and + # NI (20,000 - 12,569.96) x 8% = 594.40, so earned income = 18,171.60 + # at any State Pension up to the pensioner's election limit. + # UC = 8,003.64 + 20,000 - (0.55 x 18,171.60 + 12,000) = 6,009.26. + unit = dict( + ages=[70, 45], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=20_000.0, + savings=0.0, + earnings=20_000.0, + state_pension=12_000.0, + ) + values = calculate([unit], 2026) + assert values["uc_earned_income"][0] == pytest.approx(18_171.60, abs=0.01) + assert values["universal_credit"][0] == pytest.approx(6_009.26, abs=0.01) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py index 3ac15ab56f..630b5320c9 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py @@ -32,14 +32,16 @@ class uc_income_tax_on_earnings(Variable): def formula(person, period, parameters): # Reg. 55(5)(b) deducts income tax paid by the person "in respect of # the employment" and reg. 57(2) step 3 income tax paid "in respect - # of any trade". When the person has other income too, earnings are - # taken as the lowest slice of their non-savings income, after the - # allowances they actually have. Savings and dividends sit above all - # non-savings income (ITA 2007 s. 16) and property income above the - # rest of it, so tax on them never falls on earnings. Other - # non-savings income (private pensions, State Pension, taxable - # benefits) sits above earnings. This is the tax a standard tax code - # deducts from a sole or main employment. + # of any trade". Neither says how to split a person's tax when they + # also have other income. Earnings are taken as the lowest slice of + # their non-savings income, after the allowances they actually have: + # savings and dividends sit above all non-savings income (as in ITA + # 2007 s. 16), property income above the rest of it (as in s. 16A + # from 2027-28), and other non-savings income (private pensions, + # State Pension, taxable benefits) above earnings. So tax on other + # income never comes off earnings. Under RTI, DWP deducts the PAYE + # actually taken on the job, which can include tax on a State + # Pension coded against it; that is not modelled. p = parameters(period) earnings_components = [ "taxable_employment_income", @@ -54,9 +56,7 @@ def formula(person, period, parameters): # earned_taxable_income is non-savings, non-property income after # allowances. The part of it above earnings belongs to the person's # other non-savings income. - non_savings_non_property_income = person( - "adjusted_net_income", period - ) - add( + non_savings_non_property_income = person("adjusted_net_income", period) - add( person, period, [ From 514132fc0c6cee963a0d3e3c67cfcfea1631c85c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 09:16:04 -0400 Subject: [PATCH 03/11] Fix the UC docs means-test sentence; point the Marriage Allowance pin at #1947 The docs edit left 'plus all of its unearned income' hanging off the earnings taper, which read as if unearned income were tapered. State the two deductions separately. The strict xfail's reason and the module docstring now cite PolicyEngine/policyengine-uk#1947. Co-Authored-By: Claude Opus 5.5 --- docs/book/programs/gov/dwp/universal-credit.ipynb | 2 +- policyengine_uk/tests/test_uc_state_pension_properties.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/book/programs/gov/dwp/universal-credit.ipynb b/docs/book/programs/gov/dwp/universal-credit.ipynb index c87f8f2514..074f529c60 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. Each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. The combined earnings above the work allowance are then tapered (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, contribution-based JSA, 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**: each person's earnings (`uc_individual_earned_income`) are net of their own relievable pension contributions and the income tax and National Insurance they pay on that employment or self-employment (UC Regulations 2013 regs 55(5) and 57(2)): `uc_income_tax_on_earnings` treats earnings as the lowest slice of the person's non-savings income, so tax on pensions, property, savings and dividends is never deducted, and one partner's tax never reduces the other's earnings. The maximum entitlement is then reduced by 55% (the `gov.dwp.universal_credit.means_test.reduction_rate` parameter) of the benunit's combined earnings above its work allowance (`uc_work_allowance`), and by 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, contribution-based JSA, 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/`." }, { "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 d3b3963724..9eefc6eeef 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -27,7 +27,7 @@ allowance) instead of the fixed transferable amount in ITA 2007 s. 55B(4)-(6), so State Pension that uses up the pensioner's unused allowance raises the earning partner's tax on earnings. The strict xfail at the end pins that -deviation (PolicyEngine/policyengine-uk#MA_ISSUE). +deviation (PolicyEngine/policyengine-uk#1947). """ import numpy as np @@ -252,7 +252,7 @@ def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): @pytest.mark.xfail( strict=True, reason=( - "PolicyEngine/policyengine-uk#MA_ISSUE: Marriage Allowance is booked on " + "PolicyEngine/policyengine-uk#1947: Marriage Allowance is booked on " "the recipient as the transferor's unused personal allowance, so the " "transferor's State Pension raises the recipient's tax on earnings" ), From f333e17f266b848a05eed5f3f113757b6e3f6d32 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:00:07 -0400 Subject: [PATCH 04/11] Take tax reductions off the tax on earnings first; deduct only relievable pension contributions Review of #1949 found two defects in the new deductions. - uc_income_tax_on_earnings capped the tax on the earnings slice at the person's income_tax. When a tax reduction (the married couple's allowance) made that cap bind, the pension annual allowance charge and the High Income Child Benefit Charge raised the deduction, and so did more pension income. Reductions now come off the earnings slice first, as allowances do, so the deduction never includes a charge and does not move with other income. - uc_individual_earned_income deducted every pension contribution. Contributions paid after 75 are not relievable (Finance Act 2004 s. 188(3)(a)), so reg. 55(5)(a) does not deduct them. Adds YAML cases for both, draws a married couple's allowance in the property tests, and says in the test docstring that payroll giving (reg. 55(5)(c)) is not modelled. Co-Authored-By: Claude Opus 5.5 --- .../income/uc_earnings_deductions.yaml | 94 +++++++++++++++++++ .../test_uc_earnings_deductions_properties.py | 29 ++++-- .../income/uc_income_tax_on_earnings.py | 21 +++-- .../income/uc_individual_earned_income.py | 27 ++++-- 4 files changed, 146 insertions(+), 25 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 2e9505a83e..43b86c108c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -248,3 +248,97 @@ property_income_tax: 880 income_tax: 2_366 uc_income_tax_on_earnings: 1_486 + +- name: A tax reduction comes off the tax on earnings first + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 92 + employment_income: 20_000 + married_couples_allowance: 11_000 + would_claim_marriage_allowance: false + partner: + age: 45 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [older, partner] + households: + household: + members: [older, partner] + output: + # Married couple's allowance reduction: 10% x 11,000 = 1,100. + capped_mcad: [1_100, 0] + # (20,000 - 12,570) x 20% = 1,486, less 1,100. + income_tax: [386, 0] + uc_income_tax_on_earnings: [386, 0] + +- name: Pension income does not change the tax on earnings when a reduction applies + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 92 + employment_income: 20_000 + private_pension_income: 5_000 + married_couples_allowance: 11_000 + would_claim_marriage_allowance: false + partner: + age: 45 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [older, partner] + households: + household: + members: [older, partner] + output: + # 1,486 on earnings + 5,000 x 20% on the pension - 1,100. + income_tax: [1_386, 0] + # The reduction still comes off the earnings slice: 1,486 - 1,100. + uc_income_tax_on_earnings: [386, 0] + +- name: The pension annual allowance charge is not deducted, even with a tax reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 92 + employment_income: 20_000 + employer_pension_contributions: 65_000 + married_couples_allowance: 11_000 + would_claim_marriage_allowance: false + partner: + age: 45 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [older, partner] + households: + household: + members: [older, partner] + output: + # Contributions of 65,000 exceed the 60,000 annual allowance by 5,000, + # charged at the basic rate: 1,000. + personal_pension_contributions_tax: [1_000, 0] + income_tax: [1_386, 0] + uc_income_tax_on_earnings: [386, 0] + +- name: Pension contributions paid after 75 are not relievable, so are not deducted + period: 2026 + absolute_error_margin: 0.01 + input: + age: 76 + employment_income: 15_000 + employee_pension_contributions: 1_000 + output: + # No relief after 75 (FA 2004 s. 188(3)(a)): (15,000 - 12,570) x 20%. + uc_income_tax_on_earnings: 486 + # Over State Pension age, so no Class 1 NI. + uc_national_insurance_on_earnings: 0 + # 15,000 - 486, with the 1,000 of contributions not deducted. + uc_individual_earned_income: 14_514 diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 1d68ea2b29..7a4546510e 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -1,8 +1,9 @@ """Property-based tests for the Universal Credit deductions from earnings. -UC Regs 2013 reg. 55(5) and reg. 57(2) deduct from a person's earnings only -their own relievable pension contributions and the income tax and National -Insurance they pay in respect of their employment or trade. The model takes +UC Regs 2013 reg. 55(5) and reg. 57(2) deduct from a person's earnings their +own relievable pension contributions, the income tax and National Insurance +they pay in respect of their employment or trade, and payroll giving (reg. +55(5)(c), which the model does not have). The model takes earnings as the lowest slice of the person's non-savings income, after their allowances, with other income above it (ITA 2007 s. 16 for savings and dividends). @@ -25,10 +26,11 @@ Invariants 1-3 hold only while unearned income leaves the person's allowances alone, so the generated incomes keep adjusted net income below -the personal allowance taper (100,000), no one is old enough for the -married couple's allowance, and no one claims Marriage Allowance (see -test_uc_state_pension_properties.py for the Marriage Allowance deviation). -Invariant 4 runs with Marriage Allowance claimed. +the personal allowance taper (100,000) and no one claims Marriage Allowance +(see test_uc_state_pension_properties.py for the Marriage Allowance +deviation). Tax reductions are covered: adults born before 6 April 1935 can +have a married couple's allowance, which comes off the tax on earnings +first. Invariant 4 runs with Marriage Allowance claimed. """ import numpy as np @@ -62,8 +64,9 @@ "savings_interest_income", "dividend_income", ] -# Married couple's allowance needs a birth before 6 April 1935 (85 in 2020). -PENSION_AGE = st.integers(67, 84) +# The married couple's allowance needs a birth before 6 April 1935. +MCA_BIRTH_YEAR = 1934 +PENSION_AGE = st.integers(67, 96) WORKING_AGE = st.integers(18, 60) SHAPES = { "single": [WORKING_AGE], @@ -107,6 +110,10 @@ def families(draw): if variable == "state_pension" and age < 67: continue adult[variable] = draw(unearned) + # Used only where the adult is old enough in the simulated year. + adult["married_couples_allowance"] = draw( + st.one_of(st.just(0.0), st.floats(0, 12_000)) + ) adults.append(adult) return dict( adults=adults, @@ -132,6 +139,10 @@ def situation(units, year, bump=None, earnings_only=False, marriage_allowance=Fa for variable, value in adult.items(): if earnings_only and variable in UNEARNED: continue + if variable == "married_couples_allowance" and ( + year - adult["age"] > MCA_BIRTH_YEAR + ): + continue person[variable] = {year: value} if bump is not None and bump[:2] == (i, j) and not earnings_only: variable, amount = bump[2], bump[3] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py index 630b5320c9..4c26346d9d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py @@ -35,11 +35,11 @@ def formula(person, period, parameters): # of any trade". Neither says how to split a person's tax when they # also have other income. Earnings are taken as the lowest slice of # their non-savings income, after the allowances they actually have: - # savings and dividends sit above all non-savings income (as in ITA - # 2007 s. 16), property income above the rest of it (as in s. 16A - # from 2027-28), and other non-savings income (private pensions, - # State Pension, taxable benefits) above earnings. So tax on other - # income never comes off earnings. Under RTI, DWP deducts the PAYE + # savings and dividends sit above earnings and other non-savings + # income (as in ITA 2007 s. 16), property income above the rest of + # it (as in s. 16A from 2027-28), and other non-savings income + # (private pensions, State Pension, taxable benefits) above + # earnings. So tax on other income never comes off earnings. Under RTI, DWP deducts the PAYE # actually taken on the job, which can include tax on a State # Pension coded against it; that is not modelled. p = parameters(period) @@ -75,7 +75,10 @@ def formula(person, period, parameters): rates.scotland.rates.calc(taxable_earnings), rates.uk.calc(taxable_earnings), ) - # Tax reductions (for example the married couple's allowance) can - # leave total income tax below the tax on the earnings slice; never - # deduct more than the person pays. - return min_(tax, person("income_tax", period)) + # Tax reductions (for example the married couple's allowance) come + # off the tax on earnings first, as allowances do. So the deduction + # never exceeds the income tax the person pays, never includes a + # charge such as the High Income Child Benefit Charge, and does not + # move when other income absorbs more or less of a reduction. + reductions = add(person, period, p.gov.hmrc.income_tax.income_tax_subtractions) + return max_(0, tax - reductions) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py index 7995416bcb..201eedbb24 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income.py @@ -22,6 +22,10 @@ class uc_individual_earned_income(Variable): title="Universal Credit Regulations 2013 reg. 57(2)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", ), + dict( + title="Finance Act 2004 s. 188 (relievable pension contributions)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", + ), ] def formula(person, period, parameters): @@ -29,13 +33,22 @@ def formula(person, period, parameters): # earnings, so one partner's tax, NI or pension contributions never # reduce the other partner's earned income. gross_earnings = person("uc_mif_capped_earned_income", period) - deductions = add( + # Contributions paid after the person reaches 75 are not relievable + # (Finance Act 2004 s. 188(3)(a)). + age_limit = parameters( + period + ).gov.hmrc.pensions.pension_contributions_relief_age_limit + relievable_pension_contributions = person("pension_contributions", period) * ( + person("age", period) < age_limit + ) + tax_and_national_insurance = add( person, period, - [ - "pension_contributions", - "uc_income_tax_on_earnings", - "uc_national_insurance_on_earnings", - ], + ["uc_income_tax_on_earnings", "uc_national_insurance_on_earnings"], + ) + return max_( + 0, + gross_earnings + - relievable_pension_contributions + - tax_and_national_insurance, ) - return max_(0, gross_earnings - deductions) From f0f150d3021ce701eb86168effc1633cbdc418d4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:10:45 -0400 Subject: [PATCH 05/11] Generate working pensioners with a tax reduction in the UC earnings property tests The random generator rarely produced an earner old enough for the married couple's allowance, so the properties did not exercise tax reductions. A dedicated family shape now does. With it, restoring the old min(tax, income_tax) cap fails both the invariance and the differential property. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_earnings_deductions_properties.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 7a4546510e..21672531fe 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -64,14 +64,19 @@ "savings_interest_income", "dividend_income", ] -# The married couple's allowance needs a birth before 6 April 1935. +# The married couple's allowance needs a birth before 6 April 1935, which +# every adult aged 93 or over meets in each simulated year. MCA_BIRTH_YEAR = 1934 PENSION_AGE = st.integers(67, 96) +MCA_AGE = st.integers(93, 96) WORKING_AGE = st.integers(18, 60) SHAPES = { "single": [WORKING_AGE], "couple": [WORKING_AGE, WORKING_AGE], "mixed_age": [PENSION_AGE, WORKING_AGE], + # An older partner who works and has a married couple's allowance, so a + # tax reduction applies to someone with earnings. + "mixed_age_with_tax_reduction": [MCA_AGE, WORKING_AGE], } # Per adult: earnings up to 50,000 and unearned income up to 5 x 7,000, # plus a bump up to 9,000, keeps adjusted net income under 100,000. @@ -98,9 +103,12 @@ def families(draw): shape = draw(st.sampled_from(list(SHAPES))) adults = [] for age in [draw(age) for age in SHAPES[shape]]: + has_tax_reduction = shape == "mixed_age_with_tax_reduction" and age >= 93 adult = dict( age=age, - employment_income=draw(earnings), + employment_income=( + draw(st.floats(15_000, 30_000)) if has_tax_reduction else draw(earnings) + ), self_employment_income=draw(self_employment), # Some self-employed are in a start-up period, so the minimum # income floor does not apply. @@ -111,8 +119,10 @@ def families(draw): continue adult[variable] = draw(unearned) # Used only where the adult is old enough in the simulated year. - adult["married_couples_allowance"] = draw( - st.one_of(st.just(0.0), st.floats(0, 12_000)) + adult["married_couples_allowance"] = ( + draw(st.floats(3_000, 12_000)) + if has_tax_reduction + else draw(st.one_of(st.just(0.0), st.floats(0, 12_000))) ) adults.append(adult) return dict( From b8555a2d0133771fc5ef009054b86e56a55bd963 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 1 Oct 2026 11:15:36 -0400 Subject: [PATCH 06/11] Follow the tax engine's exclusion list when slicing earnings uc_income_tax_on_earnings hard-coded savings, dividends and property as the incomes outside earned_taxable_income. A reform that adds another income to gov.hmrc.income_tax.earned_taxable_income_exclusions, such as one exempting pensions, then had that income subtracted twice, and the tax on earnings fell to nil. The formula now reads the list. Adds a YAML case with the reform as a parameter input. Co-Authored-By: Claude Opus 5.5 --- .../income/uc_earnings_deductions.yaml | 20 +++++++++++ .../income/uc_income_tax_on_earnings.py | 34 +++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 43b86c108c..ef2abd1a6e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -342,3 +342,23 @@ uc_national_insurance_on_earnings: 0 # 15,000 - 486, with the 1,000 of contributions not deducted. uc_individual_earned_income: 14_514 + +- name: A reform exempting pensions from income tax leaves the tax on earnings unchanged + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.earned_taxable_income_exclusions: + - taxable_savings_interest_income + - taxable_dividend_income + - taxable_property_income + - received_allowances_earned_income + - marriage_allowance + - taxable_pension_income + age: 40 + employment_income: 20_000 + private_pension_income: 10_000 + output: + # The pension is outside the taxed base, so it is not above earnings + # either: (20,000 - 12,570) x 20%. + income_tax: 1_486 + uc_income_tax_on_earnings: 1_486 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py index 4c26346d9d..49dff530cc 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py @@ -43,6 +43,17 @@ def formula(person, period, parameters): # actually taken on the job, which can include tax on a State # Pension coded against it; that is not modelled. p = parameters(period) + income_tax = p.gov.hmrc.income_tax + # earned_taxable_income is adjusted net income less the incomes and + # allowances on the exclusions list. Read that list, so a reform + # that changes it (for example one that exempts pensions) is + # followed here. + exclusions = list(income_tax.earned_taxable_income_exclusions) + excluded_incomes = [ + variable + for variable in exclusions + if variable in income_tax.adjusted_net_income_components + ] earnings_components = [ "taxable_employment_income", "taxable_self_employment_income", @@ -52,24 +63,25 @@ def formula(person, period, parameters): bi = p.gov.contrib.ubi_center.basic_income.interactions if bi.include_in_means_tests and bi.include_in_taxable_income: earnings_components.append("basic_income") - earnings = add(person, period, earnings_components) - # earned_taxable_income is non-savings, non-property income after - # allowances. The part of it above earnings belongs to the person's - # other non-savings income. - non_savings_non_property_income = person("adjusted_net_income", period) - add( + earnings = add( person, period, [ - "taxable_savings_interest_income", - "taxable_dividend_income", - "taxable_property_income", + variable + for variable in earnings_components + if variable not in exclusions ], ) - other_income = max_(0, non_savings_non_property_income - earnings) + # The income left in earned_taxable_income before allowances. The + # part of it above earnings is the person's other non-savings income. + income_in_base = person("adjusted_net_income", period) - add( + person, period, excluded_incomes + ) + other_income = max_(0, income_in_base - earnings) taxable_earnings = max_( 0, person("earned_taxable_income", period) - other_income ) - rates = p.gov.hmrc.income_tax.rates + rates = income_tax.rates tax = where( person("pays_scottish_income_tax", period), rates.scotland.rates.calc(taxable_earnings), @@ -80,5 +92,5 @@ def formula(person, period, parameters): # never exceeds the income tax the person pays, never includes a # charge such as the High Income Child Benefit Charge, and does not # move when other income absorbs more or less of a reduction. - reductions = add(person, period, p.gov.hmrc.income_tax.income_tax_subtractions) + reductions = add(person, period, income_tax.income_tax_subtractions) return max_(0, tax - reductions) From 19a7235486c28ab8fcf89f511ba37749a06734a4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:15:28 -0400 Subject: [PATCH 07/11] Remove main's #1942 xfail on Carer Support Payment; pin the tests review's counterexamples - test_uc_unearned_benefits_properties.py (added on main by #1958) had a strict xfail pinned to #1942, which this fix turns into a pass. Remove it and widen the earnings-free invariants to all families, with Marriage Allowance off as in the State Pension file (#1947). - The tests review found that random draws missed two mutations. Pin its minimized counterexamples as @example cases: a pound of State Pension for a pensioner just above the personal allowance with an earning partner, and a pound of pension for an earner just above it. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_earnings_deductions_properties.py | 28 +++++- .../tests/test_uc_state_pension_properties.py | 21 ++++- .../test_uc_unearned_benefits_properties.py | 85 +++++++++---------- 3 files changed, 88 insertions(+), 46 deletions(-) diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 21672531fe..435577e813 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -34,7 +34,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -195,6 +195,32 @@ def bumped(draw): @PROPERTY_SETTINGS @given(case=bumped(), year=st.sampled_from(YEARS)) +@example( + # An earner just above the personal allowance gains a pound of pension. + # Taking earnings as the top slice instead of the bottom one would tax + # that pound against earnings. + case=( + [ + dict( + adults=[ + dict( + age=18, + employment_income=12_571.0, + self_employment_income=0.0, + uc_is_in_startup_period=False, + married_couples_allowance=0.0, + ) + ], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=0.0, + region="LONDON", + ) + ], + (0, 0, "private_pension_income", 1.0), + ), + year=2026, +) def test_unearned_income_never_changes_earned_income(case, year): units, bump = case low = calculate(units, year) diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index 9eefc6eeef..41ae9c0250 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -32,7 +32,7 @@ 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 @@ -180,6 +180,25 @@ def test_uc_is_non_increasing_in_state_pension(units, bump, year): bump=st.floats(0, 20_000, allow_nan=False, allow_infinity=False), year=st.sampled_from(YEARS), ) +@example( + # A partner just above the personal allowance and a pensioner just above + # it too, so a pound of State Pension is taxed while the partner's + # earnings reduce UC. Deducting the pensioner's tax from the partner's + # earnings (the formula before #1942) breaks pound for pound here. + units=[ + dict( + ages=[67, 18], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=12_000.0, + savings=0.0, + earnings=12_571.0, + state_pension=12_571.0, + ) + ], + bump=1.0, + year=2026, +) def test_uc_falls_pound_for_pound_in_state_pension(units, bump, year): # Earnings in the family change nothing: tax on State Pension is never # deducted from anyone's earnings (reg. 55(5)(b), reg. 57(2) step 3). diff --git a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py index c07bf8d808..26ee84e1eb 100644 --- a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py @@ -17,23 +17,20 @@ families that already received the benefit; starting to receive contributory ESA or industrial injuries benefit can lift the cap, because receipt exempts the family from it. -2. Pound for pound: with no earnings in the family, raising one of the - benefits by d lowers the award before the benefit cap by exactly - min(counted increase, award). -3. Equivalence: with no earnings in the family, UC before the benefit cap - with x of the benefit equals UC with the counted amount of private pension - received by the same person instead. For carer support payment both - families keep the carer's caring hours, so both get the carer element. - -Invariants 2 and 3, and invariant 1 for carer support payment, 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 a taxable benefit or pension -reduces earned income. Carer support payment is taxable, so above the cap, -where more of it adds nothing to unearned income, the carer's extra tax -lowers the partner's earned income and raises the award. The strict xfail -below pins that case and will flip when #1942 is fixed; widen these -invariants to all families then. They compare the award before the benefit cap because +2. Pound for pound: raising one of the benefits by d lowers the award before + the benefit cap by exactly min(counted increase, award), whatever the + partner earns, because tax on a benefit never comes off anyone's earnings + (reg. 55(5)(b), reg. 57(2) step 3). +3. Equivalence: UC before the benefit cap with x of the benefit equals UC with + the counted amount of private pension received by the same person instead. + For carer support payment both families keep the carer's caring hours, so + both get the carer element. + +Marriage Allowance is switched off for invariants 2 and 3, and for invariant +1 for carer support payment. The model books it on the recipient as the +transferor's unused personal allowance (PolicyEngine/policyengine-uk#1947), +so a taxable benefit that uses up the first adult's allowance raises the +earning partner's tax on earnings. Invariants 2 and 3 compare the award before the benefit cap because contributory ESA and industrial injuries benefit trigger benefit cap exemptions and ESA counts towards the cap, which private pension does not. """ @@ -90,8 +87,8 @@ @st.composite -def families(draw, with_earnings=True): - earnings = draw(st.one_of(st.just(0.0), money)) if with_earnings else 0.0 +def families(draw): + earnings = draw(st.one_of(st.just(0.0), money)) 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)))], @@ -103,14 +100,17 @@ def families(draw, with_earnings=True): ) -def situation(units, year, income_variable, bump=0.0, carer=False): +def situation( + units, year, income_variable, bump=0.0, carer=False, marriage_allowance=True +): """Build one simulation holding every family. The first adult receives the family's ``amount`` (plus ``bump``) under ``income_variable``; a partner receives the family's earnings. With ``carer``, the first adult cares for 40 hours a week in Scotland and has carer support payment set explicitly (zero unless it is the income - variable), so the carer element applies whatever the income. + variable), so the carer element applies whatever the income. With + ``marriage_allowance=False`` no one claims Marriage Allowance. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -118,6 +118,8 @@ def situation(units, year, income_variable, bump=0.0, carer=False): for j, age in enumerate(unit["ages"]): name = f"p{i}_{j}" person = {"age": {year: age}} + if not marriage_allowance: + person["would_claim_marriage_allowance"] = {year: False} if j == 0: if carer: person["care_hours"] = {year: 40} @@ -203,13 +205,14 @@ def test_uc_is_non_increasing_in_each_benefit(units, benefit, bump, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), bump=st.floats(0, 20_000, allow_nan=False, allow_infinity=False), year=st.sampled_from(CSP_YEARS), ) def test_uc_is_non_increasing_in_carer_support_payment(units, bump, year): - low = calculate(units, year, "carer_support_payment", carer=True) - high = calculate(units, year, "carer_support_payment", bump=bump, carer=True) + kwargs = dict(carer=True, marriage_allowance=False) + low = calculate(units, year, "carer_support_payment", **kwargs) + high = calculate(units, year, "carer_support_payment", bump=bump, **kwargs) increase = high["counted_csp"] - low["counted_csp"] assert np.all(increase >= -0.01), units assert_monotone(low, high, increase, units) @@ -217,14 +220,14 @@ def test_uc_is_non_increasing_in_carer_support_payment(units, bump, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), benefit=st.sampled_from(BENEFITS), bump=st.floats(0, 20_000, allow_nan=False, allow_infinity=False), year=st.sampled_from(YEARS), ) def test_uc_falls_pound_for_pound_in_each_benefit(units, benefit, bump, year): - low = calculate(units, year, benefit) - high = calculate(units, year, benefit, bump=bump) + low = calculate(units, year, benefit, marriage_allowance=False) + high = calculate(units, year, benefit, bump=bump, marriage_allowance=False) award = low["universal_credit_pre_benefit_cap"] np.testing.assert_allclose( high["universal_credit_pre_benefit_cap"], @@ -236,13 +239,14 @@ def test_uc_falls_pound_for_pound_in_each_benefit(units, benefit, bump, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), bump=st.floats(0, 20_000, allow_nan=False, allow_infinity=False), year=st.sampled_from(CSP_YEARS), ) def test_uc_falls_pound_for_pound_in_counted_carer_support_payment(units, bump, year): - low = calculate(units, year, "carer_support_payment", carer=True) - high = calculate(units, year, "carer_support_payment", bump=bump, carer=True) + kwargs = dict(carer=True, marriage_allowance=False) + low = calculate(units, year, "carer_support_payment", **kwargs) + high = calculate(units, year, "carer_support_payment", bump=bump, **kwargs) award = low["universal_credit_pre_benefit_cap"] increase = high["counted_csp"] - low["counted_csp"] np.testing.assert_allclose( @@ -255,14 +259,14 @@ def test_uc_falls_pound_for_pound_in_counted_carer_support_payment(units, bump, @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), benefit=st.sampled_from(BENEFITS), year=st.sampled_from(YEARS), ) def test_each_benefit_counts_like_private_pension(units, benefit, year): assert_same( - calculate(units, year, benefit), - calculate(units, year, "private_pension_income"), + calculate(units, year, benefit, marriage_allowance=False), + calculate(units, year, "private_pension_income", marriage_allowance=False), [v for v in UC_VARIABLES if v != "universal_credit"], str(units), ) @@ -270,15 +274,16 @@ def test_each_benefit_counts_like_private_pension(units, benefit, year): @PROPERTY_SETTINGS @given( - units=st.lists(families(with_earnings=False), min_size=1, max_size=20), + units=st.lists(families(), min_size=1, max_size=20), year=st.sampled_from(CSP_YEARS), ) def test_carer_support_payment_counts_like_capped_private_pension(units, year): - with_csp = calculate(units, year, "carer_support_payment", carer=True) + kwargs = dict(carer=True, marriage_allowance=False) + with_csp = calculate(units, year, "carer_support_payment", **kwargs) # The same family with the counted amount of Carer Support Payment # received as private pension instead. counted = [dict(unit, amount=c) for unit, c in zip(units, with_csp["counted_csp"])] - with_pension = calculate(counted, year, "private_pension_income", carer=True) + with_pension = calculate(counted, year, "private_pension_income", **kwargs) assert_same( with_csp, with_pension, @@ -287,14 +292,6 @@ def test_carer_support_payment_counts_like_capped_private_pension(units, year): ) -@pytest.mark.xfail( - strict=True, - reason=( - "PolicyEngine/policyengine-uk#1942: uc_earned_income deducts the whole " - "benefit unit's income tax, including the carer's tax on Carer Support " - "Payment, from the partner's earnings" - ), -) def test_carer_support_payment_above_the_cap_does_not_change_uc(): # 2026, Scotland: a carer aged 40 caring 40 hours a week with private # pension of 9,000 and a partner aged 38 earning 4,000 (under the personal From a57caf1ddea25e32de7faa7a16f78b57a6cdd0aa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 10:18:35 -0400 Subject: [PATCH 08/11] Declare the claimant and partner in the UC earnings and State Pension property fixtures #1896 presumes that a member under 20 who is at least 16 years younger than the claimant is the claimant's child. The State Pension and earnings property tests generate couples such as 67 and 18 without relationship inputs, so the presumption would read them as a parent and child and the tests would pass without exercising a couple (including the tests review's pinned 67-and-18 example). Set is_claimant_or_partner from each generated role, as #1896 did for main's fixtures. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_uc_earnings_deductions_properties.py | 10 ++++++++-- .../tests/test_uc_state_pension_properties.py | 12 ++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 435577e813..5dae910538 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -145,7 +145,13 @@ def situation(units, year, bump=None, earnings_only=False, marriage_allowance=Fa names = [] for j, adult in enumerate(unit["adults"]): name = f"p{i}_{j}" - person = {"state_pension": {year: 0.0}} + # The generated adults are the claimant and partner; say so, so the + # claimant-or-partner presumption (a member under 20 and much + # younger is the head's child) does not apply. Children get False. + person = { + "state_pension": {year: 0.0}, + "is_claimant_or_partner": {year: True}, + } for variable, value in adult.items(): if earnings_only and variable in UNEARNED: continue @@ -163,7 +169,7 @@ def situation(units, year, bump=None, earnings_only=False, marriage_allowance=Fa names.append(name) for k, age in enumerate(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {year: age}} + people[name] = {"age": {year: age}, "is_claimant_or_partner": {year: False}} names.append(name) benunits[f"b{i}"] = {"members": names} households[f"h{i}"] = { diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index 41ae9c0250..31373ce0b3 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -111,7 +111,15 @@ def situation( names = [] for j, age in enumerate(unit["ages"]): name = f"p{i}_{j}" - person = {"age": {year: age}, "state_pension": {year: 0.0}} + # The generated adults are the claimant and partner; say so, so the + # claimant-or-partner presumption (a member under 20 and much + # younger is the head's child) does not turn a 67-and-18 couple + # into a parent and child. Children get False below. + person = { + "age": {year: age}, + "state_pension": {year: 0.0}, + "is_claimant_or_partner": {year: True}, + } if not marriage_allowance: person["would_claim_marriage_allowance"] = {year: False} if j == 0: @@ -123,7 +131,7 @@ def situation( names.append(name) for k, age in enumerate(unit["children"]): name = f"c{i}_{k}" - people[name] = {"age": {year: age}} + people[name] = {"age": {year: age}, "is_claimant_or_partner": {year: False}} names.append(name) benunits[f"b{i}"] = {"members": names} households[f"h{i}"] = { From 295ca1ee4f111234990751ea97c862a19b495699 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:29:37 -0400 Subject: [PATCH 09/11] Update two UC earnings YAML cases for #1950 and #1881 on main - #1950: rent and dividends are not UC unearned income (reg. 66(1) is a closed list), so the single parent's unearned income is nil (UC 11,308.50) and the self-employed claimant's is the pension alone (UC 3,975.29). - #1881: without gross receipts, a trade profit above the trading allowance gets no allowance (ITTOIA 2005 s. 783AI), so the tax on the trade is (18,000 - 12,570) x 20% = 1,086. The tax and NI this PR deducts are unchanged in method; the cases' gaps against the pre-fix formula are still 0.55 x the tax on other income. Co-Authored-By: Claude Opus 5.5 --- .../income/uc_earnings_deductions.yaml | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index ef2abd1a6e..2df9a8fbb6 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -10,7 +10,8 @@ # Hand calculations use the 2026-27 rates: # - personal allowance 12,570; basic rate 20% on the first 37,700 of taxable # income; dividend allowance 500, dividend ordinary rate 10.75%; property -# allowance 1,000; trading allowance 1,000; +# allowance 1,000; trading allowance 1,000 (only for a trade profit within +# it when gross receipts are not given, ITTOIA 2005 s. 783AI); # - primary Class 1 NI 8% above the primary threshold of 241.73 a week # (12,569.96 a year); Class 4 NI 6% above the lower profits limit of 12,570; # Class 2 NI nil; @@ -48,12 +49,14 @@ uc_individual_earned_income: [17_919.60, 0] # Less the work allowance of 427 x 12 = 5,124. uc_earned_income: 12_795.60 - uc_unearned_income: 5_000 + # Rent from property is not UC unearned income (reg. 66(1) is a closed + # list). + uc_unearned_income: 0 # 424.90 x 12 + 303.94 x 12 + 9,600 = 5,098.80 + 3,647.28 + 9,600. uc_maximum_amount: 18_346.08 - # 18,346.08 - (0.55 x 12,795.60 + 5,000). Deducting all of the tax, as - # before this fix, gave 6,748.50: 0.55 x 800 = 440 too much. - universal_credit: 6_308.50 + # 18,346.08 - 0.55 x 12,795.60. Deducting all of the tax, as before this + # fix, gave 11,748.50: 0.55 x 800 = 440 too much. + universal_credit: 11_308.50 - name: Mixed-age couple, the pensioner's tax on State Pension does not reduce the partner's earnings period: 2026 @@ -105,23 +108,24 @@ tenure_type: RENT_FROM_COUNCIL rent: 12_000 output: - # Trade: taxable profit 18,000 - 1,000 trading allowance = 17,000; it - # takes the personal allowance first, (17,000 - 12,570) x 20% = 886. - # Pension: 4,000 x 20% = 800. Dividends: (3,000 - 500) x 10.75% = - # 268.75. Neither is deducted. - income_tax: 1_954.75 - uc_income_tax_on_earnings: 886 + # Trade: a profit of 18,000 is above the trading allowance, so no + # allowance applies without gross receipts. It takes the personal + # allowance first: (18,000 - 12,570) x 20% = 1,086. Pension: 4,000 x 20% + # = 800. Dividends: (3,000 - 500) x 10.75% = 268.75. Neither is deducted. + income_tax: 2_154.75 + uc_income_tax_on_earnings: 1_086 # Class 4: (18,000 - 12,570) x 6%. uc_national_insurance_on_earnings: 325.80 - # 18,000 - 886 - 325.80, with no work allowance. - uc_earned_income: 16_788.20 - uc_unearned_income: 7_000 + # 18,000 - 1,086 - 325.80, with no work allowance. + uc_earned_income: 16_588.20 + # The pension counts; dividends are not UC unearned income. + uc_unearned_income: 4_000 # 424.90 x 12 + 12,000. uc_maximum_amount: 17_098.80 - # 17,098.80 - (0.55 x 16,788.20 + 7,000). Deducting all of the tax and - # NI, as before this fix, gave 1,453.10: 0.55 x 1,068.75 = 587.81 too + # 17,098.80 - (0.55 x 16,588.20 + 4,000). Deducting all of the tax and + # NI, as before this fix, gave 4,563.10: 0.55 x 1,068.75 = 587.81 too # much. - universal_credit: 865.29 + universal_credit: 3_975.29 - name: Scottish taxpayer, earnings sit below pension income in the Scottish bands period: 2026 From c6412ee99b4a1b3e57c984a0b6e007a41145fb0c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 09:08:54 -0400 Subject: [PATCH 10/11] Compare the capital-income award invariance only where allowances are unchanged The widened award clause of test_interest_dividends_and_rent_leave_the_award_unchanged gives one adult up to 270,000 of interest, dividends and rent beside up to 30,000 of earnings. Above 100,000 of adjusted net income the personal allowance tapers (ITA 2007 s. 35(2)), which raises the tax on the person's earnings and so the reg. 55(5)(b) deduction: the exception this PR states. Compare only benefit units whose personal allowances the income leaves unchanged, as test_uc_earnings_deductions_properties.py does by keeping adjusted net income under the taper. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_income_from_capital_properties.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) 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 acecac2023..8b532df799 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -27,7 +27,11 @@ Invariant 2's award clause runs with Marriage Allowance switched off. The model books it on the recipient as the transferor's unused personal allowance (PolicyEngine/policyengine-uk#1947), so capital income that uses up one -partner's allowance raises the other partner's tax on earnings. +partner's allowance raises the other partner's tax on earnings. The clause +also compares only families whose personal allowances the income leaves +unchanged: above 100,000 of adjusted net income the allowance tapers (ITA 2007 +s. 35), which raises the tax a person pays on their earnings and so changes +the deduction from them (see test_uc_earnings_deductions_properties.py). """ import numpy as np @@ -167,6 +171,9 @@ def listed_sources(sim, year): 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} + values["personal_allowances"] = np.asarray( + sim.calculate("personal_allowance", 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 @@ -235,9 +242,17 @@ def test_interest_dividends_and_rent_do_not_enter_the_means_test(units, scale, y def test_interest_dividends_and_rent_leave_the_award_unchanged(units, scale, year): base = calculate(units, year, marriage_allowance=False) scaled = calculate(units, year, income_scale=scale, marriage_allowance=False) + # Only one adult receives the capital income, so the unit's summed + # personal allowances are unchanged exactly when that adult's is. + unchanged = np.isclose( + scaled["personal_allowances"], base["personal_allowances"], atol=0.01 + ) for variable in UC_VARIABLES: np.testing.assert_allclose( - scaled[variable], base[variable], atol=0.01, err_msg=f"{variable}: {units}" + scaled[variable][unchanged], + base[variable][unchanged], + atol=0.01, + err_msg=f"{variable}: {units}", ) From 8c88c7cd94746c756012025bf7436b721f4305b5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:40:26 -0400 Subject: [PATCH 11/11] Compare the capped award only where the benefit cap exemption is unchanged Review of c6412ee99 found the widened capital-income award invariance still false for the award after the cap. The model's earnings exception to the cap (reg. 82(1)(a)) nets tax on all of a person's income off their earnings (#1986), so dividends can switch the cap on while allowances and UC earned income are unchanged: a single London claimant earning 11,000 with 10,000 of dividends loses 2,081.80 to the cap. - Compare universal_credit only where is_benefit_cap_exempt is also unchanged; the other variables keep the personal allowance filter. - Pin the case as a strict xfail against #1986. - Qualify the YAML header's ITA 2007 s. 16 ordering comment. Co-Authored-By: Claude Opus 5.5 --- .../income/uc_earnings_deductions.yaml | 3 +- .../test_uc_income_from_capital_properties.py | 60 ++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 2df9a8fbb6..0053eec145 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -5,7 +5,8 @@ # Pension, property, savings and dividends is not deducted, and one partner's # tax never reduces the other's earnings. Earnings are taken as the lowest # slice of the person's non-savings income, after their allowances; savings -# and dividends sit above all non-savings income (ITA 2007 s. 16). +# and dividends sit above it, as ITA 2007 s. 16 ranks them for income tax +# (apart from the items s. 1012 ranks higher, which the model does not have). # # Hand calculations use the 2026-27 rates: # - personal allowance 12,570; basic rate 20% on the first 37,700 of taxable 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 8b532df799..4a07d757fd 100644 --- a/policyengine_uk/tests/test_uc_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_uc_income_from_capital_properties.py @@ -32,6 +32,11 @@ unchanged: above 100,000 of adjusted net income the allowance tapers (ITA 2007 s. 35), which raises the tax a person pays on their earnings and so changes the deduction from them (see test_uc_earnings_deductions_properties.py). +The award after the benefit cap is compared only where the cap exemption is +also unchanged: the model's earnings exception to the cap still nets tax on +all of a person's income off their earnings +(PolicyEngine/policyengine-uk#1986), so capital income can switch the cap on. +A strict xfail pins that case. """ import numpy as np @@ -82,6 +87,7 @@ "uc_maximum_amount", "uc_assessable_capital", "is_uc_eligible", + "is_benefit_cap_exempt", ] @@ -247,10 +253,18 @@ def test_interest_dividends_and_rent_leave_the_award_unchanged(units, scale, yea unchanged = np.isclose( scaled["personal_allowances"], base["personal_allowances"], atol=0.01 ) + # The cap's earnings exception can change with the income (#1986), so + # the capped award is compared only where the exemption is the same. + same_cap = unchanged & ( + scaled["is_benefit_cap_exempt"] == base["is_benefit_cap_exempt"] + ) for variable in UC_VARIABLES: + if variable == "is_benefit_cap_exempt": + continue + compared = same_cap if variable == "universal_credit" else unchanged np.testing.assert_allclose( - scaled[variable][unchanged], - base[variable][unchanged], + scaled[variable][compared], + base[variable][compared], atol=0.01, err_msg=f"{variable}: {units}", ) @@ -307,3 +321,45 @@ def test_tax_on_dividends_does_not_raise_a_working_familys_award(): ) values = calculate([unit], 2026) assert values["uc_earned_income"][0] == pytest.approx(10_000, abs=0.01) + + +@pytest.mark.xfail( + strict=True, + reason=( + "PolicyEngine/policyengine-uk#1986: the benefit cap earnings exception " + "nets tax on all of a person's income off their earnings" + ), +) +def test_tax_on_dividends_does_not_remove_the_benefit_cap_earnings_exception(): + # 2026: single claimant aged 30 in London, council rent 20,000, earning + # 11,000 with no tax or NI on it. Without dividends the model applies no + # cap, because the earnings meet its exception test. 10,000 of dividends + # is taxed, but that tax is not in respect of the employment (reg. + # 55(5)(b)), so earned income, and the exception that rests on it (reg. + # 82(1)(a)), should not change. + unit = dict( + ages=[30], + children=[], + tenure="RENT_FROM_COUNCIL", + region="LONDON", + rent=20_000.0, + capital={source: 0.0 for source in CAPITAL_SOURCES}, + main_residence_value=0.0, + reported_capital=-1.0, + earnings=11_000.0, + private_pension_income=0.0, + capital_income={ + "savings_interest_income": 0.0, + "dividend_income": 10_000.0, + "property_income": 0.0, + }, + recipient=0, + ) + with_dividends = calculate([unit], 2026, marriage_allowance=False) + without = calculate([unit], 2026, income_scale=0.0, marriage_allowance=False) + assert with_dividends["uc_earned_income"][0] == pytest.approx( + without["uc_earned_income"][0], abs=0.01 + ) + assert with_dividends["universal_credit"][0] == pytest.approx( + without["universal_credit"][0], abs=0.01 + )