From 9129c8a16af2ce377e4e52545aca5861880fc8bd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:45:33 -0400 Subject: [PATCH 01/28] Charge the winter fuel payment back through income tax, person by person MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ITEPA 2003 s.681I (FA 2026 Sch 10, from 2025-26) charges a person entitled to a winter fuel payment the whole payment when their own total income exceeds £35,000 and they are not entitled to IS, income-based JSA, Pension Credit, income-related ESA or UC. It covers the Winter Fuel Payment in England and Wales (SI 2025/969) and Northern Ireland (NISR 2025/142) and Scotland's Pension Age Winter Heating Payment (SSI 2024/351). - winter_fuel_payment_charge (Person) under gov.hmrc.income_tax.charges, added to income_tax after tax reductions (ITA 2007 s.23 Step 7, s.30). - Payments stay gross; from 2025 the WFP pays everyone of pensionable age (require_benefits false), so entitlement, and the shared amounts, come before the charge. - Means tests deduct income_tax_before_winter_fuel_payment_charge: the charge recovers a payment, and it depends on PC/UC receipt. - pawhp counts in household_benefits and HBAI income, like the WFP. - The household income test is deprecated (off from 2025; boundary <=). Co-Authored-By: Claude Opus 5.5 --- .../winter-fuel-payment-charge.added.md | 2 + .../winter-fuel-payment-charge.changed.md | 1 + .../winter-fuel-payment-charge.fixed.md | 2 + .../eligibility/require_benefits.yaml | 35 +- .../maximum_taxable_income.yaml | 10 +- .../use_maximum_taxable_income.yaml | 8 +- .../charges/winter_fuel_payment/README.md | 1 + .../winter_fuel_payment/in_effect.yaml | 14 + .../winter_fuel_payment/income_threshold.yaml | 13 + .../relevant_benefits.yaml | 15 + policyengine_uk/programs.yaml | 6 +- .../housing_benefit_applicable_income.yaml | 10 +- .../legacy_family_definitions.yaml | 36 +- .../income/uc_earned_income.yaml | 4 +- .../claimant_partner_income_and_carers.yaml | 6 +- .../relevant_benefit_receipt.yaml | 20 +- .../charges/winter_fuel_payment_charge.yaml | 485 ++++++++++++++++++ .../taxable_income_test.yaml | 85 ++- .../charge_parameters.yaml | 51 ++ .../test_winter_heating_payment_properties.py | 128 ++++- .../housing_benefit_applicable_income.py | 5 +- .../dwp/income_support_applicable_income.py | 5 +- .../gov/dwp/is_benefit_cap_exempt_earnings.py | 2 +- ...is_benefit_cap_exempt_health_disability.py | 2 +- .../gov/dwp/is_benefit_cap_exempt_other.py | 2 +- .../pension_credit/pension_credit_income.py | 6 +- .../income/uc_earned_income.py | 7 +- .../gov/dwp/winter_fuel_allowance.py | 5 +- .../variables/gov/dwp/winter_fuel_payment.py | 3 +- .../gov/dwp/winter_fuel_payment_eligible.py | 24 +- .../charges/winter_fuel_payment_charge.py | 38 ++ .../gov/hmrc/income_tax/income_tax.py | 30 +- ...e_tax_before_winter_fuel_payment_charge.py | 28 + ...council_tax_reduction_applicable_income.py | 5 +- .../gov/social_security_scotland/pawhp.py | 5 +- .../pension_age_winter_heating_payment.py | 3 +- .../household/income/hbai_benefits.py | 1 + .../income/hbai_household_net_income.py | 1 + .../household/income/household_benefits.py | 1 + 39 files changed, 983 insertions(+), 122 deletions(-) create mode 100644 changelog.d/winter-fuel-payment-charge.added.md create mode 100644 changelog.d/winter-fuel-payment-charge.changed.md create mode 100644 changelog.d/winter-fuel-payment-charge.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/README.md create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/in_effect.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/income_threshold.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/relevant_benefits.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml create mode 100644 policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/income_tax_before_winter_fuel_payment_charge.py diff --git a/changelog.d/winter-fuel-payment-charge.added.md b/changelog.d/winter-fuel-payment-charge.added.md new file mode 100644 index 0000000000..c368d8465d --- /dev/null +++ b/changelog.d/winter-fuel-payment-charge.added.md @@ -0,0 +1,2 @@ +- Add the winter fuel payment charge (`winter_fuel_payment_charge`, ITEPA 2003 s.681I, from 2025-26) with parameters under `gov.hmrc.income_tax.charges.winter_fuel_payment`. It is charged person by person: the whole Winter Fuel Payment (England, Wales and Northern Ireland) or Pension Age Winter Heating Payment, when the person's own total income exceeds £35,000 and they are not entitled to Income Support, income-based JSA, Pension Credit, income-related ESA or Universal Credit. It is added to `income_tax` after tax reductions (ITA 2007 s.23 Step 7). +- Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit, Income Support, Housing Benefit, Council Tax Reduction, Universal Credit and benefit cap means tests now deduct it instead of `income_tax` (Universal Credit deducted `tax`, income tax plus National Insurance). The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. diff --git a/changelog.d/winter-fuel-payment-charge.changed.md b/changelog.d/winter-fuel-payment-charge.changed.md new file mode 100644 index 0000000000..d99a959352 --- /dev/null +++ b/changelog.d/winter-fuel-payment-charge.changed.md @@ -0,0 +1 @@ +- Deprecate `gov.dwp.winter_fuel_payment.eligibility.taxable_income_test`: it is off from 2025 and has effect only in a reform that requires a relevant benefit. Its limit now admits total income equal to it. Use `gov.hmrc.income_tax.charges.winter_fuel_payment` for the charge. diff --git a/changelog.d/winter-fuel-payment-charge.fixed.md b/changelog.d/winter-fuel-payment-charge.fixed.md new file mode 100644 index 0000000000..97b3a984df --- /dev/null +++ b/changelog.d/winter-fuel-payment-charge.fixed.md @@ -0,0 +1,2 @@ +- From the 2025 qualifying week, pay the Winter Fuel Payment to everyone of pensionable age in England, Wales and Northern Ireland (SI 2025/969 reg. 2; NISR 2025/142 reg. 2) and recover it through the winter fuel payment charge, in place of a household income test. The test paid a whole household if any member of pensionable age had total income under £35,000. It applied only in England and Wales, and it charged nobody in Northern Ireland or on the Pension Age Winter Heating Payment. Its boundary was also wrong: total income of exactly £35,000 is not charged. +- Count the Pension Age Winter Heating Payment in `household_benefits`, `hbai_household_net_income` and `hbai_benefits`, as the Winter Fuel Payment is. diff --git a/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/require_benefits.yaml b/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/require_benefits.yaml index a85099df07..85dac65b48 100644 --- a/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/require_benefits.yaml +++ b/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/require_benefits.yaml @@ -1,19 +1,28 @@ -description: Whether a relevant benefit is a condition of the Winter Fuel Payment. It was for the 2024 qualifying week. From the 2025 qualifying week the regulations pay everyone of pensionable age, and the winter fuel payment charge (ITEPA 2003 s.681I) recovers the payment from people with total income over £35,000 who are not on a relevant benefit. The model keeps this true from 2025 and lets people who are not on a relevant benefit through the income test in taxable_income_test, which stands in for the charge. +description: Whether a relevant benefit is a condition of the Winter Fuel Payment. It was for the 2024 qualifying week only. Up to the 2023 week, and from the 2025 week, everyone of pensionable age resident where the payment is made is entitled; from 2025-26 the winter fuel payment charge (gov.hmrc.income_tax.charges.winter_fuel_payment) recovers the payment from people with total income over £35,000 who are not on a relevant benefit. values: - 2000-01-01: false + 2000-01-01: + value: false + reference: + - title: The Social Fund Winter Fuel Payment Regulations 2000, reg. 2 (as in force for the 2023 qualifying week) + href: https://www.legislation.gov.uk/uksi/2000/729/regulation/2/2023-09-18 + # Dated 1 January so that the year's value is the one for its September + # qualifying week; SI 2024/869 came into force on 16 September 2024. 2024-01-01: value: true - metadata: - reference: - - title: The Social Fund Winter Fuel Payment Regulations 2024 regulation 2 - href: https://www.legislation.gov.uk/uksi/2024/869/regulation/2 + reference: + - title: The Social Fund Winter Fuel Payment Regulations 2024, reg. 2(2)(b) + href: https://www.legislation.gov.uk/uksi/2024/869/regulation/2/made + - title: The Social Fund Winter Fuel Payment Regulations (Northern Ireland) 2024, reg. 2(2)(b) + href: https://www.legislation.gov.uk/nisr/2024/160/regulation/2/made + # SI 2025/969 came into force on 15 September 2025, the first day of the + # 2025 qualifying week. + 2025-01-01: + value: false + reference: + - title: The Social Fund Winter Fuel Payment Regulations 2025, reg. 2 + href: https://www.legislation.gov.uk/uksi/2025/969/regulation/2/made + - title: The Social Fund Winter Fuel Payment Regulations (Northern Ireland) 2025, reg. 2 + href: https://www.legislation.gov.uk/nisr/2025/142/regulation/2 metadata: unit: bool label: Winter Fuel Payment means-tested benefits requirement - reference: - - href: https://www.legislation.gov.uk/uksi/2000/729/regulation/2 - title: The Social Fund Winter Fuel Payment Regulations 2000 - - title: The Social Fund Winter Fuel Payment Regulations 2025, reg. 2 - href: https://www.legislation.gov.uk/uksi/2025/969/regulation/2/made - - title: Finance Act 2026, Sch. 10 (ITEPA 2003 s.681I, winter fuel payment charge) - href: https://www.legislation.gov.uk/ukpga/2026/11/schedule/10 diff --git a/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/maximum_taxable_income.yaml b/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/maximum_taxable_income.yaml index 29e4138a20..4b926ae091 100644 --- a/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/maximum_taxable_income.yaml +++ b/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/maximum_taxable_income.yaml @@ -1,11 +1,7 @@ -description: Households in England or Wales with pensioners earning under this amount of yearly taxable income are eligible for the Winter Fuel Payment. This stands in for the winter fuel payment charge, which recovers the payment from a person whose own total income exceeds £35,000 and who is not entitled to a relevant benefit. +description: Deprecated; use gov.hmrc.income_tax.charges.winter_fuel_payment.income_threshold for the winter fuel payment charge. The household income test (use_maximum_taxable_income) pays a person not on a relevant benefit if a member of their household who has reached pensionable age has total income at or under this amount. It has effect only when that test is switched on and a relevant benefit is required (require_benefits). values: 2000-01-01: 0 - 2025-01-01: - value: 35_000 - reference: - - title: Finance Act 2026, Sch. 10 para. 1 (ITEPA 2003 s.681I(1)(b), for 2025-26 on) - href: https://www.legislation.gov.uk/ukpga/2026/11/schedule/10 + 2025-01-01: 35_000 metadata: unit: currency-GBP - label: Winter Fuel Payment maximum taxable income + label: Winter Fuel Payment household income test limit (deprecated) diff --git a/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/use_maximum_taxable_income.yaml b/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/use_maximum_taxable_income.yaml index 594e55d980..1871b17ffd 100644 --- a/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/use_maximum_taxable_income.yaml +++ b/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/use_maximum_taxable_income.yaml @@ -1,10 +1,6 @@ -description: When this is true, households in England or Wales with pensioners earning under the maximum taxable income are eligible for the Winter Fuel Payment. This stands in for the winter fuel payment charge on people with total income over £35,000 (ITEPA 2003 s.681I, from 2025-26). +description: Deprecated; use gov.hmrc.income_tax.charges.winter_fuel_payment for the winter fuel payment charge. When this is true and a relevant benefit is required (require_benefits), a person who is not on one is still paid if a member of their household who has reached pensionable age has total income at or under maximum_taxable_income. No regulations have set such a test. It stood in for the winter fuel payment charge for 2025 before the model computed the charge, and is kept, off, so that reforms which set it still load. It has no effect while require_benefits is false, as it is from 2025. values: 2000-01-01: false - 2025-01-01: true metadata: unit: bool - label: WFA maximum taxable income eligibility - reference: - - title: Finance Act 2026, Sch. 10 para. 1 (ITEPA 2003 s.681I) - href: https://www.legislation.gov.uk/ukpga/2026/11/schedule/10 + label: Winter Fuel Payment household income test (deprecated) diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/README.md b/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/README.md new file mode 100644 index 0000000000..5bf15396bd --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/README.md @@ -0,0 +1 @@ +# Winter fuel payment charge diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/in_effect.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/in_effect.yaml new file mode 100644 index 0000000000..a5972ca01a --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/in_effect.yaml @@ -0,0 +1,14 @@ +description: Whether the winter fuel payment charge applies. It has effect for the tax year 2025-26 and later tax years. +values: + 2000-01-01: false + # Dated 1 January so that the year's value is the one for the tax year + # beginning in its April (2025 = 2025-26), whose qualifying week is in + # September 2025. + 2025-01-01: + value: true + reference: + - title: Finance Act 2026, Sch. 10 para. 5 (commencement) + href: https://www.legislation.gov.uk/ukpga/2026/11/schedule/10 +metadata: + unit: bool + label: Winter fuel payment charge in effect diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/income_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/income_threshold.yaml new file mode 100644 index 0000000000..f82887986d --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/income_threshold.yaml @@ -0,0 +1,13 @@ +description: A person entitled to a winter fuel payment is liable to the winter fuel payment charge when their total income for the tax year exceeds this amount, unless they are entitled to a relevant benefit in the qualifying week. Total income of exactly this amount is not charged. The Treasury may change the amount by order for later tax years. +values: + 2025-01-01: + value: 35_000 + reference: + - title: Income Tax (Earnings and Pensions) Act 2003, s. 681I(1)(b), inserted by Finance Act 2026, Sch. 10 para. 1 + href: https://www.legislation.gov.uk/ukpga/2003/1/section/681I + - title: Income Tax (Earnings and Pensions) Act 2003, s. 681J (alteration of income limit by Treasury order) + href: https://www.legislation.gov.uk/ukpga/2026/11/schedule/10 +metadata: + unit: currency-GBP + period: year + label: Winter fuel payment charge income threshold diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/relevant_benefits.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/relevant_benefits.yaml new file mode 100644 index 0000000000..5014f654e5 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/relevant_benefits.yaml @@ -0,0 +1,15 @@ +description: Benefits that exempt a person from the winter fuel payment charge when they are entitled to one on any day in the qualifying week. The model takes a person to be entitled when their own award, or for a claimant or partner their couple's award, is positive in the year. +values: + 2025-01-01: + value: + - is_on_income_support + - is_on_income_based_jsa + - is_on_pension_credit + - is_on_income_related_esa + - is_on_universal_credit + reference: + - title: Income Tax (Earnings and Pensions) Act 2003, s. 681I(4) and (5) + href: https://www.legislation.gov.uk/ukpga/2003/1/section/681I +metadata: + unit: list + label: Winter fuel payment charge relevant benefits diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index 9dddf799d9..4da990e017 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -15,7 +15,7 @@ programs: variable: income_tax parameter_prefix: gov.hmrc.income_tax verified_start_year: 2019 - notes: Includes Scottish rates via devolved tax bands + notes: Includes Scottish rates via devolved tax bands, the high income child benefit charge and, from 2025-26, the winter fuel payment charge - id: national_insurance name: National Insurance @@ -352,7 +352,7 @@ programs: variable: winter_fuel_payment parameter_prefix: gov.dwp.winter_fuel_payment verified_start_year: 2022 - notes: Great Britain to winter 2023-24, then England and Wales; Northern Ireland's mirror scheme uses the same parameters + notes: Great Britain to winter 2023-24, then England and Wales; Northern Ireland's mirror scheme uses the same parameters. From winter 2025-26 paid to everyone of pensionable age and recovered by the winter fuel payment charge (income tax) - id: ssmg name: Sure Start Maternity Grant @@ -593,4 +593,4 @@ programs: variable: pension_age_winter_heating_payment parameter_prefix: gov.social_security_scotland.pawhp verified_start_year: 2024 - notes: Replaced the Winter Fuel Payment in Scotland from winter 2024-25 + notes: Replaced the Winter Fuel Payment in Scotland from winter 2024-25. From winter 2025-26 recovered by the winter fuel payment charge (income tax) above £35,000 of total income diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml index 135bd6cebc..c648170b46 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml @@ -12,7 +12,7 @@ property_income: 0 private_pension_income: 0 # Tax - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 # Other tax_credits: 0 @@ -36,7 +36,7 @@ property_income: 0 private_pension_income: 0 # Tax - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 # Other tax_credits: 0 @@ -60,7 +60,7 @@ property_income: 0 private_pension_income: 0 # Tax - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 # Other tax_credits: 0 @@ -84,7 +84,7 @@ property_income: 1_000 private_pension_income: 0 # Tax - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 # Other tax_credits: 1_000 @@ -108,7 +108,7 @@ property_income: 1_000 private_pension_income: 0 # Tax - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 # Other tax_credits: 1_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_family_definitions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_family_definitions.yaml index 00ec78032a..1e12b9080e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_family_definitions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/legacy_family_definitions.yaml @@ -15,7 +15,7 @@ people: claimant: age: 17 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -37,14 +37,14 @@ claimant: age: 17 is_parent: true - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 employment_income: 10000 child: age: 1 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -66,7 +66,7 @@ claimant: age: 20 is_parent: true - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -74,7 +74,7 @@ young_person: age: 18 is_in_non_advanced_education: true - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -96,7 +96,7 @@ claimant: age: 20 is_parent: true - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -105,7 +105,7 @@ age: 17 is_in_non_advanced_education: false is_in_approved_training: false - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -126,7 +126,7 @@ people: claimant: age: 30 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -135,7 +135,7 @@ partner: age: 17 is_claimant_or_partner: true - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -156,7 +156,7 @@ people: claimant: age: 24 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -165,7 +165,7 @@ partner: age: 17 is_claimant_or_partner: true - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -186,7 +186,7 @@ people: claimant: age: 17 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -195,7 +195,7 @@ partner: age: 17 is_claimant_or_partner: true - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -217,21 +217,21 @@ claimant: age: 40 is_parent: true - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 employment_income: 10000 partner: age: 40 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 young_person: age: 19 is_in_non_advanced_education: true - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -253,7 +253,7 @@ people: claimant: age: 24 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 @@ -272,7 +272,7 @@ people: claimant: age: 25 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 social_security_income: 0 pension_contributions: 0 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 62a0fd3051..1ea4f6b2ef 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 @@ -6,7 +6,7 @@ miscellaneous_income: 100 uc_work_allowance: 10 # The claimant's own tax (UC reg 22 counts the claimant's earnings). - tax: 1 + income_tax_before_winter_fuel_payment_charge: 1 output: uc_earned_income: 1100 - 11 @@ -18,6 +18,6 @@ miscellaneous_income: 100 uc_work_allowance: 2_000 # The claimant's own tax (UC reg 22 counts the claimant's earnings). - tax: 1 + income_tax_before_winter_fuel_payment_charge: 1 output: uc_earned_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml index dbf4bb5dbf..d6023485ce 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml @@ -90,7 +90,7 @@ is_in_non_advanced_education: true age_started_or_accepted_current_education_or_training: 18 employment_income: 3_000 - income_tax: 100 + income_tax_before_winter_fuel_payment_charge: 100 national_insurance: 100 pension_contributions: 2_000 benunits: @@ -139,7 +139,7 @@ state_pension: 5_000 employment_income: 1_000 jsa_contrib: 100 - income_tax: 100 + income_tax_before_winter_fuel_payment_charge: 100 national_insurance: 0 pension_contributions: 200 is_carer_for_benefits: true @@ -148,7 +148,7 @@ private_pension_income: 2_000 self_employment_income: 500 esa_contrib: 200 - income_tax: 50 + income_tax_before_winter_fuel_payment_charge: 50 national_insurance: 25 pension_contributions: 100 is_carer_for_benefits: true diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_payment/relevant_benefit_receipt.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_payment/relevant_benefit_receipt.yaml index 7c650a46e6..e4cfce1804 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_payment/relevant_benefit_receipt.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_payment/relevant_benefit_receipt.yaml @@ -4,10 +4,10 @@ # - 2024: SI 2024/869 regs 2 to 4; # - 2025 on: SI 2025/969 regs 1(3) and 2 to 4. # Pension Credit and the other benefit-unit awards are entered on the benefit -# unit so that each case turns only on the receipt test. From 2025 the model -# stands in for the winter fuel payment charge (ITEPA 2003 s.681I) with its -# income test: a household passes when a member of pensionable age has total -# income below £35,000. +# unit so that each case turns only on the receipt test. From 2025 everyone +# of pensionable age is entitled (SI 2025/969 reg. 2), and the winter fuel +# payment charge (ITEPA 2003 s.681I) recovers the payment through income tax: +# see tests/policy/baseline/gov/hmrc/income_tax/charges/. - name: 2024 a non-dependant's income-related ESA does not entitle the pensioner # The pensioner is not entitled to a relevant benefit, nor is any partner, @@ -290,9 +290,9 @@ tax_credit_award: [0, 26] winter_fuel_payment: [0, 200] -- name: 2025 a non-dependant's income-related ESA does not exempt the pensioner from the income test +- name: 2025 a non-dependant's income-related ESA does not exempt the pensioner from the charge # Under SI 2025/969 the pensioner is entitled (reg. 2) to £200 (reg. 3(1)), - # but the winter fuel payment charge recovers it: total income over + # and the winter fuel payment charge recovers it: total income over # £35,000 and not entitled to a relevant benefit (ITEPA 2003 s.681I(1) and # (4)). The son's ESA is his own award, so it does not exempt the pensioner. period: 2025 @@ -316,10 +316,11 @@ country: ENGLAND output: is_on_winter_fuel_payment_relevant_benefit: [false, true] - winter_fuel_payment: [0, 0] - winter_fuel_allowance: 0 + winter_fuel_payment: [200, 0] + winter_fuel_payment_charge: [200, 0] + winter_fuel_allowance: 200 -- name: 2025 a pensioner under the income limit living with a non-dependant on ESA gets £200 +- name: 2025 a pensioner with total income under £35,000 living with a non-dependant on ESA keeps £200 # Reg. 3(1): under 80 and not living with another person entitled (the son # is under pensionable age). period: 2025 @@ -343,6 +344,7 @@ country: ENGLAND output: winter_fuel_payment: [200, 0] + winter_fuel_payment_charge: [0, 0] winter_fuel_allowance: 200 - name: 2025 a pensioner on Pension Credit and one who is not, in separate benefit units diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml new file mode 100644 index 0000000000..3678a95928 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -0,0 +1,485 @@ +# The winter fuel payment charge, ITEPA 2003 s.681I (inserted by FA 2026 +# Sch. 10 para. 1, for 2025-26 on). Expected values are worked from the law: +# - P is liable if entitled to a winter fuel payment for the qualifying week +# and P's own total income for the tax year exceeds £35,000 (s.681I(1)); +# - the charge equals the payment (s.681I(3)); +# - P is not liable if entitled to income support, income-based JSA, Pension +# Credit, income-related ESA or Universal Credit (s.681I(4) and (5)); +# - "winter fuel payment" covers SI 2025/969 (England and Wales), SSI 2024/351 +# (Scotland's Pension Age Winter Heating Payment) and NISR 2025/142 +# (Northern Ireland) (s.681I(6)(a)); +# - the charge is additional tax at Step 7 of the income tax calculation (ITA +# 2007 ss.23 and 30). +# The payments are worked from SI 2025/969 regs 2 to 4, NISR 2025/142 regs 2 +# and 3, and SSI 2024/351 reg 10 as substituted by SSI 2025/282 (amounts from +# SSI 2025/100 and SSI 2026/170). Entitlement to a payment, and so who counts +# as "a person entitled to a winter fuel payment" for the shared amounts, does +# not depend on the charge. Benefit-unit awards are entered directly. + +- name: 2025 total income of exactly £35,000 is not charged + # s.681I(1)(b): total income must exceed £35,000. Reg. 2 and 3(1): £200. + period: 2025 + input: + people: + pensioner: + age: 70 + total_income: 35_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + winter_fuel_payment: [200] + winter_fuel_payment_charge: [0] + winter_fuel_allowance: 200 + +- name: 2025 total income of £35,001 is charged the whole payment + # s.681I(1) and (3). + period: 2025 + input: + people: + pensioner: + age: 70 + total_income: 35_001 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + winter_fuel_payment: [200] + winter_fuel_payment_charge: [200] + winter_fuel_allowance: 200 + +- name: 2025 a pensioner aged 85 with £50,000 is charged £300 + # Reg. 3(4): £300 at 80 or over, living alone. s.681I(3): charge £300. + period: 2025 + input: + people: + pensioner: + age: 85 + total_income: 50_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: WALES + output: + winter_fuel_payment: [300] + winter_fuel_payment_charge: [300] + +- name: 2025 a couple is charged on each partner's own income + # Neither is on a relevant benefit and each lives with a person entitled: + # reg. 3(2), £100 each. Only the partner with total income over £35,000 is + # charged (s.681I(1)(b)); the other keeps £100. + period: 2025 + input: + people: + partner_1: + age: 70 + total_income: 50_000 + partner_2: + age: 72 + total_income: 10_000 + benunits: + couple: + members: [partner_1, partner_2] + pension_credit: 0 + households: + household: + members: [partner_1, partner_2] + country: ENGLAND + output: + winter_fuel_payment: [100, 100] + winter_fuel_payment_charge: [100, 0] + winter_fuel_allowance: 200 + +- name: 2025 a couple aged 82 and 85 both over £35,000 are each charged £150 + # Reg. 3(6): over 80, no relevant benefit, living with an entitled person + # who has reached 80: £150 each. Both charged in full. + period: 2025 + input: + people: + partner_1: + age: 82 + total_income: 40_000 + partner_2: + age: 85 + total_income: 40_000 + benunits: + couple: + members: [partner_1, partner_2] + pension_credit: 0 + households: + household: + members: [partner_1, partner_2] + country: ENGLAND + output: + winter_fuel_payment: [150, 150] + winter_fuel_payment_charge: [150, 150] + winter_fuel_allowance: 300 + +- name: 2025 a person charged in full still counts as entitled for the shared amounts + # Two pensioners in separate benefit units. The 70-year-old is entitled + # under reg. 2 whatever their income, so each lives with a person entitled: + # the 70-year-old gets £100 (reg. 3(2)) and the 85-year-old £200 (reg. + # 3(5), living with an entitled person under 80), not £300. The charge + # recovers the 70-year-old's £100 (total income over £35,000). + period: 2025 + input: + people: + high_income: + age: 70 + total_income: 50_000 + aged_85: + age: 85 + total_income: 10_000 + benunits: + unit_1: + members: [high_income] + pension_credit: 0 + unit_2: + members: [aged_85] + pension_credit: 0 + households: + household: + members: [high_income, aged_85] + country: ENGLAND + output: + winter_fuel_payment: [100, 200] + winter_fuel_payment_charge: [100, 0] + winter_fuel_allowance: 300 + +- name: 2025 a lodger's own income is charged, not the Pension Credit recipient's + # The Pension Credit recipient: reg. 3(1), £200, not liable (s.681I(4)). + # The lodger, aged 72, on no relevant benefit and living with a person + # entitled: reg. 3(2), £100, charged (total income £40,000). + period: 2025 + input: + people: + on_pension_credit: + age: 70 + lodger: + age: 72 + total_income: 40_000 + benunits: + unit_1: + members: [on_pension_credit] + pension_credit: 1_000 + lodger_unit: + members: [lodger] + pension_credit: 0 + households: + household: + members: [on_pension_credit, lodger] + country: ENGLAND + output: + winter_fuel_payment: [200, 100] + winter_fuel_payment_charge: [0, 100] + winter_fuel_allowance: 300 + +- name: 2025 a non-dependant's income-related ESA does not exempt the pensioner + # s.681I(4) asks whether P is entitled to a relevant benefit. The son's ESA + # is his own award. The pensioner: reg. 3(1), £200, charged in full. + period: 2025 + input: + people: + pensioner: + age: 70 + total_income: 50_000 + son: + age: 40 + benunits: + pensioner_unit: + members: [pensioner] + pension_credit: 0 + son_unit: + members: [son] + esa_income: 4_000 + households: + household: + members: [pensioner, son] + country: ENGLAND + output: + winter_fuel_payment: [200, 0] + winter_fuel_payment_charge: [200, 0] + winter_fuel_allowance: 200 + +- name: 2025 a pensioner in a Universal Credit couple is not charged + # A couple claims Universal Credit jointly. The 70-year-old is the only + # member of pensionable age: reg. 3(1), £200, not liable (s.681I(4) and + # (5)(e)) despite total income of £40,000. + period: 2025 + input: + people: + claimant: + age: 60 + pensioner: + age: 70 + total_income: 40_000 + benunits: + couple: + members: [claimant, pensioner] + universal_credit: 1_000 + households: + household: + members: [claimant, pensioner] + country: ENGLAND + output: + winter_fuel_payment: [0, 200] + winter_fuel_payment_charge: [0, 0] + +- name: 2025 the partner of a Pension Credit claimant is not entitled and so not charged + # One payment for the couple, to the claimant who reports the award (reg. + # 4(2)(a)). The partner is not entitled to a winter fuel payment, so + # s.681I(1)(a) is not met whatever their income; the claimant is on Pension + # Credit (s.681I(4)). + period: 2025 + input: + people: + claimant: + age: 70 + pension_credit_reported: 1_000 + partner: + age: 72 + total_income: 50_000 + benunits: + couple: + members: [claimant, partner] + pension_credit: 1_000 + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + winter_fuel_payment: [200, 0] + winter_fuel_payment_charge: [0, 0] + winter_fuel_allowance: 200 + +- name: 2025 Northern Ireland pays everyone of pensionable age + # NISR 2025/142 reg. 2 has no relevant-benefit condition; reg. 3(1): £200. + period: 2025 + input: + people: + pensioner: + age: 70 + total_income: 20_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: NORTHERN_IRELAND + output: + winter_fuel_payment: [200] + winter_fuel_payment_charge: [0] + winter_fuel_allowance: 200 + +- name: 2025 the charge recovers a Northern Ireland payment + # s.681I(6)(a) includes payments under NISR 2025/142. + period: 2025 + input: + people: + pensioner: + age: 70 + total_income: 50_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: NORTHERN_IRELAND + output: + winter_fuel_payment: [200] + winter_fuel_payment_charge: [200] + +- name: 2025 the charge recovers Scotland's Pension Age Winter Heating Payment + # SSI 2024/351 reg. 10(5)(a) as substituted by SSI 2025/282: £203.40. + # s.681I(6)(a) includes payments under SSI 2024/351. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + total_income: 50_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: SCOTLAND + output: + winter_fuel_payment: [0] + pension_age_winter_heating_payment: [203.40] + winter_fuel_payment_charge: [203.40] + pawhp: 203.40 + +- name: 2026 a Scottish couple aged 82 and 85 is charged on each partner's own income + # Reg. 10(6)(c) at the SSI 2026/170 amount: £158.35 each. Only the partner + # with total income over £35,000 is charged. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + partner_1: + age: 82 + total_income: 40_000 + partner_2: + age: 85 + total_income: 20_000 + benunits: + couple: + members: [partner_1, partner_2] + pension_credit: 0 + households: + household: + members: [partner_1, partner_2] + country: SCOTLAND + output: + pension_age_winter_heating_payment: [158.35, 158.35] + winter_fuel_payment_charge: [158.35, 0] + pawhp: 316.70 + +- name: 2025 a Scottish Pension Credit recipient is not charged + # Reg. 10(4)(a): £305.10 at 80 or over on a relevant benefit. Not liable + # (s.681I(4)). + period: 2025 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 82 + total_income: 40_000 + benunits: + unit: + members: [pensioner] + pension_credit: 1_000 + households: + household: + members: [pensioner] + country: SCOTLAND + output: + pension_age_winter_heating_payment: [305.10] + winter_fuel_payment_charge: [0] + +- name: 2024 no charge before the tax year 2025-26 + # FA 2026 Sch. 10 para. 5: the charge has effect from 2025-26. For the 2024 + # week a relevant benefit was a condition of entitlement (SI 2024/869 reg. + # 2(2)(b)): £200 on Pension Credit, not charged. + period: 2024 + input: + people: + pensioner: + age: 70 + total_income: 50_000 + benunits: + unit: + members: [pensioner] + pension_credit: 1_000 + households: + household: + members: [pensioner] + country: ENGLAND + output: + winter_fuel_payment: [200] + winter_fuel_payment_charge: [0] + +- name: 2024 Northern Ireland paid only people on a relevant benefit + # NISR 2024/160 reg. 2(2)(b), as SI 2024/869 for England and Wales. + period: 2024 + input: + people: + pensioner: + age: 70 + total_income: 20_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: NORTHERN_IRELAND + output: + winter_fuel_payment: [0] + winter_fuel_payment_charge: [0] + +- name: 2025 the charge is added to income tax + # Private pension £50,000, no other income, England. Personal allowance + # £12,570; £37,430 at the basic rate of 20% = £7,486 (within the £37,700 + # basic rate band). The charge, £200, is added at Step 7: £7,686. HBAI net + # income counts the payment and deducts the tax: 50,000 + 200 - 7,686 = + # £42,514. + period: 2025 + input: + people: + pensioner: + age: 70 + private_pension_income: 50_000 + households: + household: + members: [pensioner] + country: ENGLAND + output: + total_income: [50_000] + income_tax_before_winter_fuel_payment_charge: [7_486] + winter_fuel_payment_charge: [200] + income_tax: [7_686] + winter_fuel_allowance: 200 + household_benefits: 200 + hbai_household_net_income: 42_514 + +- name: 2025 the payment is kept at £35,000 of private pension + # £22,430 at 20% = £4,486; no charge. HBAI net income: 35,000 + 200 - + # 4,486 = £30,714. + period: 2025 + input: + people: + pensioner: + age: 70 + private_pension_income: 35_000 + households: + household: + members: [pensioner] + country: ENGLAND + output: + winter_fuel_payment_charge: [0] + income_tax: [4_486] + hbai_household_net_income: 30_714 + +- name: 2025 Scotland's payment is household income + # PAWHP counts in household benefits and HBAI income, like the Winter Fuel + # Payment it replaced in Scotland. Reg. 10(5)(a): £203.40; no charge. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_000 + households: + household: + members: [pensioner] + country: SCOTLAND + output: + winter_fuel_payment_charge: [0] + pawhp: 203.40 + household_benefits: 203.40 + hbai_benefits: 203.40 diff --git a/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_allowance/taxable_income_test.yaml b/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_allowance/taxable_income_test.yaml index be70a4d8a8..8b30f9c78b 100644 --- a/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_allowance/taxable_income_test.yaml +++ b/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_allowance/taxable_income_test.yaml @@ -1,3 +1,10 @@ +# The household income test in +# gov.dwp.winter_fuel_payment.eligibility.taxable_income_test is deprecated: +# no regulations set it, and from 2025 the winter fuel payment charge (ITEPA +# 2003 s.681I, gov.hmrc.income_tax.charges.winter_fuel_payment) recovers the +# payment instead. The test still works in a reform that requires a relevant +# benefit (require_benefits), as for the 2024 qualifying week. + - name: 2024 baseline - pensioner without benefits gets no WFA period: 2024 absolute_error_margin: 1 @@ -45,7 +52,10 @@ output: winter_fuel_allowance: 0 -- name: Enable income test - pensioner exactly at threshold gets no WFA +- name: Enable income test - pensioner exactly at threshold gets WFA + # The limit is a maximum: total income at the limit passes, as the winter + # fuel payment charge reaches only total income exceeding £35,000 (ITEPA + # 2003 s.681I(1)(b)). period: 2024 absolute_error_margin: 1 input: @@ -59,7 +69,7 @@ esa_income: 0 jsa_income: 0 output: - winter_fuel_allowance: 0 + winter_fuel_allowance: 200 - name: Enable income test - pensioner £1 under threshold gets WFA period: 2024 @@ -199,4 +209,73 @@ country: "ENGLAND" total_income: 50000 output: - winter_fuel_allowance: 200 \ No newline at end of file + winter_fuel_allowance: 200 + +- name: 2025 baseline - pensioner without benefits is paid, and the charge recovers it above £35,000 + # SI 2025/969 reg. 2 pays everyone of pensionable age; ITEPA 2003 s.681I. + period: 2025 + absolute_error_margin: 1 + input: + age: 67 + country: "ENGLAND" + total_income: 50000 + pension_credit: 0 + income_support: 0 + esa_income: 0 + jsa_income: 0 + output: + winter_fuel_allowance: 200 + winter_fuel_payment_charge: 200 + +- name: 2025 the deprecated household income test has no effect while no relevant benefit is required + period: 2025 + absolute_error_margin: 1 + input: + gov.dwp.winter_fuel_payment.eligibility.taxable_income_test.use_maximum_taxable_income: true + gov.dwp.winter_fuel_payment.eligibility.taxable_income_test.maximum_taxable_income: 20000 + age: 67 + country: "ENGLAND" + total_income: 50000 + pension_credit: 0 + income_support: 0 + esa_income: 0 + jsa_income: 0 + output: + winter_fuel_allowance: 200 + winter_fuel_payment_charge: 200 + +- name: 2025 the deprecated household income test applies when a reform requires a relevant benefit - over the limit + period: 2025 + absolute_error_margin: 1 + input: + gov.dwp.winter_fuel_payment.eligibility.require_benefits: true + gov.dwp.winter_fuel_payment.eligibility.taxable_income_test.use_maximum_taxable_income: true + gov.dwp.winter_fuel_payment.eligibility.taxable_income_test.maximum_taxable_income: 20000 + age: 67 + country: "ENGLAND" + total_income: 50000 + pension_credit: 0 + income_support: 0 + esa_income: 0 + jsa_income: 0 + output: + winter_fuel_allowance: 0 + winter_fuel_payment_charge: 0 + +- name: 2025 the deprecated household income test applies when a reform requires a relevant benefit - under the limit + period: 2025 + absolute_error_margin: 1 + input: + gov.dwp.winter_fuel_payment.eligibility.require_benefits: true + gov.dwp.winter_fuel_payment.eligibility.taxable_income_test.use_maximum_taxable_income: true + gov.dwp.winter_fuel_payment.eligibility.taxable_income_test.maximum_taxable_income: 20000 + age: 67 + country: "ENGLAND" + total_income: 15000 + pension_credit: 0 + income_support: 0 + esa_income: 0 + jsa_income: 0 + output: + winter_fuel_allowance: 200 + winter_fuel_payment_charge: 0 diff --git a/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml b/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml new file mode 100644 index 0000000000..e92b38e9b4 --- /dev/null +++ b/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml @@ -0,0 +1,51 @@ +# Reforms to the winter fuel payment charge parameters +# (gov.hmrc.income_tax.charges.winter_fuel_payment). + +- name: A higher income threshold - total income under it is not charged + period: 2025 + input: + gov.hmrc.income_tax.charges.winter_fuel_payment.income_threshold: 50_000 + age: 70 + country: ENGLAND + total_income: 45_000 + pension_credit: 0 + output: + winter_fuel_payment: 200 + winter_fuel_payment_charge: 0 + +- name: A higher income threshold - total income over it is charged + period: 2025 + input: + gov.hmrc.income_tax.charges.winter_fuel_payment.income_threshold: 50_000 + age: 70 + country: ENGLAND + total_income: 50_001 + pension_credit: 0 + output: + winter_fuel_payment: 200 + winter_fuel_payment_charge: 200 + +- name: Repealing the charge leaves the payment with everyone + period: 2025 + input: + gov.hmrc.income_tax.charges.winter_fuel_payment.in_effect: false + age: 70 + country: ENGLAND + total_income: 50_000 + pension_credit: 0 + output: + winter_fuel_payment: 200 + winter_fuel_payment_charge: 0 + +- name: Charging a universal payment for the 2024 qualifying week + period: 2024 + input: + gov.dwp.winter_fuel_payment.eligibility.require_benefits: false + gov.hmrc.income_tax.charges.winter_fuel_payment.in_effect: true + age: 70 + country: ENGLAND + total_income: 50_000 + pension_credit: 0 + output: + winter_fuel_payment: 200 + winter_fuel_payment_charge: 200 diff --git a/policyengine_uk/tests/test_winter_heating_payment_properties.py b/policyengine_uk/tests/test_winter_heating_payment_properties.py index 325878fae5..fd3e84e0f4 100644 --- a/policyengine_uk/tests/test_winter_heating_payment_properties.py +++ b/policyengine_uk/tests/test_winter_heating_payment_properties.py @@ -1,14 +1,22 @@ """The Winter Fuel Payment and the Pension Age Winter Heating Payment follow -each person's own receipt of a relevant benefit. +each person's own receipt of a relevant benefit, and the winter fuel payment +charge follows each person's own total income. Both schemes entitle and pay a person (SI 2000/729 reg 2; SI 2024/869 regs -2 to 4; SI 2025/969 regs 2 to 4; SSI 2024/351 regs 5, 9 and 10). A person is -on a relevant benefit through their own award or, for the claimant and -partner, their couple's award. Properties: +2 to 4; SI 2025/969 regs 2 to 4; NISR 2025/142 regs 2 to 4; SSI 2024/351 +regs 5, 9 and 10). A person is on a relevant benefit through their own award +or, for the claimant and partner, their couple's award. From 2025-26 the +winter fuel payment charge (ITEPA 2003 s.681I) recovers a person's payment +through income tax when their own total income exceeds £35,000 and they are +not entitled to a relevant benefit. Properties: - the model matches a reference implementation written directly from the - regulations, person by person, over households of up to three benefit - units, in every country and in the 2023 to 2026 qualifying weeks; + regulations and s.681I, person by person, over households of up to three + benefit units, in every country and in the 2023 to 2026 qualifying weeks: + payments, charges, and income tax with and without the charge; +- incomes never change anyone's payment, only the charge: entitlement, and + so who counts as another entitled person for the shared amounts, comes + before the charge; - adding a non-dependant under pensionable age, with or without a relevant benefit of their own, never changes what anyone else is paid; - from the 2025 qualifying week, a household whose pension-age members all @@ -35,7 +43,8 @@ # Ages either side of pensionable age (66 to 67 over these years) and of 80. PENSION_AGES = [67, 70, 79, 80, 85] AGES = [40, 60, *PENSION_AGES] -INCOMES = [5_000, 20_000, 50_000] +# Either side of the £35,000 charge threshold, and exactly on it. +INCOMES = [5_000, 20_000, 35_000, 35_001, 50_000] AWARD = 1_000 # The benefit-unit award a couple can be on, and the input that carries it. AWARD_INPUTS = { @@ -93,7 +102,9 @@ # SSI 2026/170 reg 15(3). 2026: (211.15, 316.70, 105.55, 211.15, 158.35), } -INCOME_LIMIT = 35_000 +# ITEPA 2003 s.681I(1)(b) and (5), from the tax year 2025-26. +CHARGE_THRESHOLD = 35_000 +CHARGE_RELEVANT = {"IS", "JSA", "PC", "ESA", "UC"} @st.composite @@ -259,17 +270,11 @@ def scheme(resident, relevant, means_test, amounts): if year <= 2023: wfp_resident = True - wfp_means = True else: wfp_resident = country in ("ENGLAND", "WALES", "NORTHERN_IRELAND") - if year == 2024: - wfp_means = False - else: - # The model's stand-in for the winter fuel payment charge. - wfp_means = country in ("ENGLAND", "WALES") and any( - p[0]["age"] >= 67 and p[0]["total_income"] < INCOME_LIMIT - for p in people - ) + # Only the 2024 regulations (SI 2024/869 reg 2(2)(b); NISR 2024/160) + # made a relevant benefit a condition of entitlement. + wfp_means = year != 2024 wfp = scheme(wfp_resident, WFP_RELEVANT[year], wfp_means, WFP_AMOUNTS) if year >= 2024 and country == "SCOTLAND": pawhp = scheme(True, PAWHP_RELEVANT[year], year >= 2025, PAWHP_AMOUNTS[year]) @@ -278,6 +283,27 @@ def scheme(resident, relevant, means_test, amounts): return wfp, pawhp +def reference_charges(household, year, payments): + """The winter fuel payment charge on each person (ITEPA 2003 s.681I). + + ``payments`` is each person's winter fuel payment of any kind (s.681I(6) + (a)). The charge is the whole payment (s.681I(3)) when the person's own + total income exceeds £35,000 (s.681I(1)(b)) and they are not entitled to + a relevant benefit (s.681I(4) and (5)), from 2025-26. + """ + if year < 2025: + return [0.0] * len(payments) + units = household["units"] + charges = [] + for (inputs, u, cp), payment in zip(household_people(household), payments): + award = units[u]["award"] if cp else inputs.get("own_award") + liable = ( + inputs["total_income"] > CHARGE_THRESHOLD and award not in CHARGE_RELEVANT + ) + charges.append(payment if liable else 0.0) + return charges + + SETTINGS = settings( max_examples=12, deadline=None, @@ -292,14 +318,34 @@ def test_payments_match_reference(year, drawn): sim = Simulation(situation=situation(drawn, year)) wfp = sim.calculate("winter_fuel_payment", year) pawhp = sim.calculate("pension_age_winter_heating_payment", year) + charge = sim.calculate("winter_fuel_payment_charge", year) + income_tax = sim.calculate("income_tax", year) + before_charge = sim.calculate("income_tax_before_winter_fuel_payment_charge", year) wfa_household = sim.calculate("winter_fuel_allowance", year) pawhp_household = sim.calculate("pawhp", year) start = 0 for h, household in enumerate(drawn): expected_wfp, expected_pawhp = reference_payments(household, year) + expected_charge = reference_charges( + household, year, [a + b for a, b in zip(expected_wfp, expected_pawhp)] + ) n = len(expected_wfp) got_wfp = wfp[start : start + n] got_pawhp = pawhp[start : start + n] + got_charge = charge[start : start + n] + assert np.allclose(got_charge, expected_charge, atol=0.005), ( + year, + household, + got_charge, + expected_charge, + ) + # The charge is added to income tax after everything else (ITA 2007 + # s.23 Step 7). + assert np.allclose( + income_tax[start : start + n] - before_charge[start : start + n], + expected_charge, + atol=0.005, + ) assert np.allclose(got_wfp, expected_wfp, atol=0.005), ( year, household, @@ -400,7 +446,7 @@ def one_unit_households(draw): if not any(a["age"] >= 67 for a in adults): adults[0]["age"] = draw(st.sampled_from(PENSION_AGES)) return { - "country": draw(st.sampled_from(["ENGLAND", "WALES", "SCOTLAND"])), + "country": draw(st.sampled_from(COUNTRIES)), "units": [ { "adults": adults, @@ -438,3 +484,49 @@ def test_one_benefit_unit_receives_the_full_amount(year, drawn): else: assert wfa[h] == (300 if any_80 else 200), (year, household) assert pawhp[h] == 0 + + +def with_incomes(household, income): + """The household with every member's total income set to ``income``.""" + units = [ + {**u, "adults": [{**a, "total_income": income} for a in u["adults"]]} + for u in household["units"] + ] + other = household["other"] + if other is not None: + other = {**other, "total_income": income} + return {**household, "units": units, "other": other} + + +@SETTINGS +@given( + st.sampled_from([2025, 2026]), + st.lists(households(), min_size=1, max_size=6), +) +def test_incomes_change_only_the_charge(year, drawn): + """The same households with everyone on £5,000 and everyone on £50,000: + the payments are identical (entitlement, and so the shared amounts, does + not depend on income), nobody on £5,000 is charged, and everyone on + £50,000 who is paid and not on a relevant benefit is charged their whole + payment.""" + low = [with_incomes(h, 5_000) for h in drawn] + high = [with_incomes(h, 50_000) for h in drawn] + sim = Simulation(situation=situation(low + high, year)) + paid = sim.calculate("winter_fuel_payment", year) + sim.calculate( + "pension_age_winter_heating_payment", year + ) + charge = sim.calculate("winter_fuel_payment_charge", year) + relevant = np.zeros_like(charge, dtype=bool) + for variable in [ + "is_on_income_support", + "is_on_income_based_jsa", + "is_on_pension_credit", + "is_on_income_related_esa", + "is_on_universal_credit", + ]: + relevant |= sim.calculate(variable, year).astype(bool) + n = len(paid) // 2 + assert np.allclose(paid[:n], paid[n:], atol=0.005), (year, drawn) + assert np.all(charge[:n] == 0), (year, drawn) + assert np.allclose(charge[n:], np.where(relevant[n:], 0, paid[n:]), atol=0.005) + assert np.all(charge <= paid + 0.005) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index e90ebf86a6..9a839041f2 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -65,7 +65,10 @@ def formula(benunit, period, parameters): pension_contributions = ( add_for_members(benunit, period, ["pension_contributions"], members) * 0.5 ) - TAX_COMPONENTS = ["income_tax", "national_insurance"] + TAX_COMPONENTS = [ + "income_tax_before_winter_fuel_payment_charge", + "national_insurance", + ] tax = add_for_members(benunit, period, TAX_COMPONENTS, members) increased_income_reduced_by_tax_and_pensions = ( increased_income - tax - pension_contributions diff --git a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py index 17aa562d36..3eb631ad99 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py @@ -30,7 +30,10 @@ def formula(benunit, period, parameters): INCOME_COMPONENTS.append("basic_income") income = add_for_members(benunit, period, INCOME_COMPONENTS, members) tax = add_for_members( - benunit, period, ["income_tax", "national_insurance"], members + benunit, + period, + ["income_tax_before_winter_fuel_payment_charge", "national_insurance"], + members, ) income += add_for_members(benunit, period, ["social_security_income"], members) income += benunit("income_support_tariff_income", period) diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py index c27e13c39d..949999022a 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py @@ -26,7 +26,7 @@ def formula(benunit, period, parameters): uc_earned = benunit.sum( benunit.members("employment_income", period) + benunit.members("self_employment_income", period) - - benunit.members("income_tax", period) + - benunit.members("income_tax_before_winter_fuel_payment_charge", period) - benunit.members("national_insurance", period) ) earnings_threshold = 10_152 diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 473155bfd2..79debe651f 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -26,7 +26,7 @@ def formula(benunit, period, parameters): uc_earned = benunit.sum( benunit.members("employment_income", period) + benunit.members("self_employment_income", period) - - benunit.members("income_tax", period) + - benunit.members("income_tax_before_winter_fuel_payment_charge", period) - benunit.members("national_insurance", period) ) earnings_threshold = 10_152 diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py index 0fb1547506..17ac279ac0 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_other.py @@ -26,7 +26,7 @@ def formula(benunit, period, parameters): uc_earned = benunit.sum( benunit.members("employment_income", period) + benunit.members("self_employment_income", period) - - benunit.members("income_tax", period) + - benunit.members("income_tax_before_winter_fuel_payment_charge", period) - benunit.members("national_insurance", period) ) earnings_threshold = 10_152 diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py index 1160800f3a..60381b3033 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py @@ -41,7 +41,11 @@ def formula(benunit, period, parameters): person("pension_contributions", period) * is_claimant_or_partner ) tax = benunit.sum( - add(person, period, ["income_tax", "national_insurance"]) + add( + person, + period, + ["income_tax_before_winter_fuel_payment_charge", "national_insurance"], + ) * is_claimant_or_partner ) pen_con_deduction_rate = pc.income.pension_contributions_deduction 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 6e496b8681..becb81249d 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 @@ -25,7 +25,12 @@ def formula(benunit, period, parameters): disregards = add_for_members( benunit, period, - ["uc_work_allowance", "tax", "pension_contributions"], + [ + "uc_work_allowance", + "income_tax_before_winter_fuel_payment_charge", + "national_insurance", + "pension_contributions", + ], members, ) return max_(0, personal_gross_earned_income - disregards) diff --git a/policyengine_uk/variables/gov/dwp/winter_fuel_allowance.py b/policyengine_uk/variables/gov/dwp/winter_fuel_allowance.py index b0a404dec5..51c01a5e0d 100644 --- a/policyengine_uk/variables/gov/dwp/winter_fuel_allowance.py +++ b/policyengine_uk/variables/gov/dwp/winter_fuel_allowance.py @@ -6,7 +6,10 @@ class winter_fuel_allowance(Variable): documentation = ( "Winter Fuel Payments made to the members of this household " "(winter_fuel_payment). Payments in Scotland from the 2024 " - "qualifying week are the Pension Age Winter Heating Payment (pawhp)." + "qualifying week are the Pension Age Winter Heating Payment (pawhp). " + "The payments are counted in full: from 2025-26 the winter fuel " + "payment charge recovers them from some members through income tax " + "(winter_fuel_payment_charge)." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/gov/dwp/winter_fuel_payment.py b/policyengine_uk/variables/gov/dwp/winter_fuel_payment.py index 33fda18f95..13e5205d65 100644 --- a/policyengine_uk/variables/gov/dwp/winter_fuel_payment.py +++ b/policyengine_uk/variables/gov/dwp/winter_fuel_payment.py @@ -11,7 +11,8 @@ class winter_fuel_payment(Variable): "benefit receives the full amount (the higher amount if they or their " "partner have reached 80), once per couple. Anyone else receives the " "full amount if no other person in their household is eligible, and " - "a shared amount otherwise." + "a shared amount otherwise. This is the amount paid, before any " + "winter fuel payment charge (winter_fuel_payment_charge)." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/dwp/winter_fuel_payment_eligible.py b/policyengine_uk/variables/gov/dwp/winter_fuel_payment_eligible.py index 12927fd4ad..6f200dd788 100644 --- a/policyengine_uk/variables/gov/dwp/winter_fuel_payment_eligible.py +++ b/policyengine_uk/variables/gov/dwp/winter_fuel_payment_eligible.py @@ -13,16 +13,17 @@ class winter_fuel_payment_eligible(Variable): "Whether this person is entitled to a Winter Fuel Payment: they have " "reached pensionable age and live where the payment is made, they " "are not the partner of the person paid for a couple on a relevant " - "benefit, and they meet the model's means condition. The means " - "condition is a relevant benefit of their own (or their couple's) " - "where gov.dwp.winter_fuel_payment.eligibility.require_benefits is " - "set, as for the 2024 qualifying week (SI 2024/869 reg 2(2)(b)). " - "From 2025 the regulations pay everyone of pensionable age (SI " - "2025/969 reg 2) and the winter fuel payment charge recovers the " - "payment from people with total income over £35,000 who are not on a " - "relevant benefit (ITEPA 2003 s.681I); the model represents the " - "charge with the household income test in " - "gov.dwp.winter_fuel_payment.eligibility.taxable_income_test." + "benefit, and, for the 2024 qualifying week only, they are on a " + "relevant benefit of their own or their couple's (SI 2024/869 reg " + "2(2)(b)). Up to the 2023 week and from the 2025 week everyone of " + "pensionable age is entitled (SI 2000/729 reg 2; SI 2025/969 reg 2; " + "NISR 2025/142 reg 2). From 2025-26 the winter fuel payment charge " + "recovers the payment through income tax from a person whose own " + "total income exceeds £35,000 and who is not on a relevant benefit " + "(winter_fuel_payment_charge); entitlement, and so the shared " + "amounts, are unaffected by it. The deprecated household income test " + "in gov.dwp.winter_fuel_payment.eligibility.taxable_income_test " + "applies only in a reform that requires a relevant benefit." ) definition_period = YEAR reference = ( @@ -31,6 +32,7 @@ class winter_fuel_payment_eligible(Variable): "https://www.legislation.gov.uk/uksi/2024/869/regulation/4", "https://www.legislation.gov.uk/uksi/2025/969/regulation/2", "https://www.legislation.gov.uk/uksi/2025/969/regulation/4", + "https://www.legislation.gov.uk/nisr/2025/142/regulation/2", ) def formula(person, period, parameters): @@ -47,7 +49,7 @@ def formula(person, period, parameters): meets_income_test = ( person.household.any( is_SP_age - & (person("total_income", period) < income_test.maximum_taxable_income) + & (person("total_income", period) <= income_test.maximum_taxable_income) ) & np.isin(country, ["ENGLAND", "WALES"]) & income_test.use_maximum_taxable_income diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py b/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py new file mode 100644 index 0000000000..c1dab9a309 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + +# The payments s.681I(6)(a) defines as a "winter fuel payment": the Winter +# Fuel Payment in England and Wales (SI 2025/969) and Northern Ireland (NISR +# 2025/142), and Scotland's Pension Age Winter Heating Payment (SSI 2024/351). +WINTER_FUEL_PAYMENTS = [ + "winter_fuel_payment", + "pension_age_winter_heating_payment", +] + + +class winter_fuel_payment_charge(Variable): + value_type = float + entity = Person + label = "winter fuel payment charge" + documentation = ( + "The charge to income tax that recovers a person's winter fuel " + "payment: the whole payment, when the person's own total income for " + "the tax year exceeds the threshold and they are not entitled to a " + "relevant benefit in the qualifying week. It is additional tax at " + "Step 7 of the income tax calculation, so tax reductions do not " + "offset it. The payment itself, and who counts as another entitled " + "person for the shared amounts, are unaffected by the charge." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/2003/1/section/681I", + "https://www.legislation.gov.uk/ukpga/2007/3/section/30", + "https://www.legislation.gov.uk/ukpga/2026/11/schedule/10", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.income_tax.charges.winter_fuel_payment + payment = add(person, period, WINTER_FUEL_PAYMENTS) + over_threshold = person("total_income", period) > p.income_threshold + on_relevant_benefit = add(person, period, p.relevant_benefits) > 0 + return p.in_effect * payment * over_threshold * ~on_relevant_benefit diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax.py index 8a1596bc94..352fb5396e 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax.py @@ -5,19 +5,25 @@ class income_tax(Variable): value_type = float entity = Person label = "Income Tax" - documentation = "Total Income Tax liability" + documentation = ( + "Total Income Tax liability: the liability before the winter fuel " + "payment charge, plus the charge, which is additional tax added at " + "Step 7 after tax reductions." + ) definition_period = YEAR unit = GBP - reference = dict( - title="Income Tax Act 2007 s. 23", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", + reference = ( + dict( + title="Income Tax Act 2007 s. 23", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", + ), + dict( + title="Income Tax Act 2007 s. 30", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/30", + ), ) category = TAX - - def formula(person, period, parameters): - p = parameters(period).gov.hmrc.income_tax - - additions = add(person, period, p.income_tax_additions) - subtractions = add(person, period, p.income_tax_subtractions) - - return max_(0, additions - subtractions) + adds = [ + "income_tax_before_winter_fuel_payment_charge", + "winter_fuel_payment_charge", + ] diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_before_winter_fuel_payment_charge.py b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_before_winter_fuel_payment_charge.py new file mode 100644 index 0000000000..dfd8b453d9 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_before_winter_fuel_payment_charge.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class income_tax_before_winter_fuel_payment_charge(Variable): + value_type = float + entity = Person + label = "Income Tax before the winter fuel payment charge" + documentation = ( + "Income Tax liability before the winter fuel payment charge is added " + "at Step 7. The means tests deduct this rather than income_tax: the " + "charge is not tax on the income they assess but the recovery of a " + "winter fuel payment, and it depends on receipt of Pension Credit " + "and Universal Credit, so deducting it would make the model circular." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Income Tax Act 2007 s. 23", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.income_tax + + additions = add(person, period, p.income_tax_additions) + subtractions = add(person, period, p.income_tax_subtractions) + + return max_(0, additions - subtractions) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 2d20faa8a1..fa49f6adcf 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py @@ -66,6 +66,9 @@ def formula(benunit, period, parameters): add_for_members(benunit, period, ["pension_contributions"], members) * 0.5 ) tax = add_for_members( - benunit, period, ["income_tax", "national_insurance"], members + benunit, + period, + ["income_tax_before_winter_fuel_payment_charge", "national_insurance"], + members, ) return max_(0, increased_income - tax - pension_contributions) diff --git a/policyengine_uk/variables/gov/social_security_scotland/pawhp.py b/policyengine_uk/variables/gov/social_security_scotland/pawhp.py index d1c565a7e7..a282bdbc62 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/pawhp.py +++ b/policyengine_uk/variables/gov/social_security_scotland/pawhp.py @@ -5,7 +5,10 @@ class pawhp(Variable): label = "Pension Age Winter Heating Payment" documentation = ( "Pension Age Winter Heating Payments made to the members of this " - "household (pension_age_winter_heating_payment)." + "household (pension_age_winter_heating_payment). The payments are " + "counted in full: from 2025-26 the winter fuel payment charge " + "recovers them from some members through income tax " + "(winter_fuel_payment_charge)." ) entity = Household definition_period = YEAR diff --git a/policyengine_uk/variables/gov/social_security_scotland/pension_age_winter_heating_payment.py b/policyengine_uk/variables/gov/social_security_scotland/pension_age_winter_heating_payment.py index aa3e0c0cca..c869030212 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/pension_age_winter_heating_payment.py +++ b/policyengine_uk/variables/gov/social_security_scotland/pension_age_winter_heating_payment.py @@ -12,7 +12,8 @@ class pension_age_winter_heating_payment(Variable): "(the higher amount if they or their partner have reached 80), once " "per couple. From the 2025 qualifying week anyone else receives the " "full amount if they do not live with another entitled individual, " - "and a shared amount otherwise." + "and a shared amount otherwise. This is the amount paid, before any " + "winter fuel payment charge (winter_fuel_payment_charge)." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/household/income/hbai_benefits.py b/policyengine_uk/variables/household/income/hbai_benefits.py index 0adf25977d..2edd99bfda 100644 --- a/policyengine_uk/variables/household/income/hbai_benefits.py +++ b/policyengine_uk/variables/household/income/hbai_benefits.py @@ -35,6 +35,7 @@ class hbai_benefits(Variable): "ssmg", "cost_of_living_support_payment", "winter_fuel_allowance", + "pawhp", "tax_free_childcare", "childcare_grant", "parents_learning_allowance", diff --git a/policyengine_uk/variables/household/income/hbai_household_net_income.py b/policyengine_uk/variables/household/income/hbai_household_net_income.py index f3272ebc5b..aedfb3e19a 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -45,6 +45,7 @@ "ssmg", "cost_of_living_support_payment", "winter_fuel_allowance", + "pawhp", "tax_free_childcare", "healthy_start_vouchers", "scottish_child_payment", diff --git a/policyengine_uk/variables/household/income/household_benefits.py b/policyengine_uk/variables/household/income/household_benefits.py index 77f35aaa7d..e69f62ec76 100644 --- a/policyengine_uk/variables/household/income/household_benefits.py +++ b/policyengine_uk/variables/household/income/household_benefits.py @@ -34,6 +34,7 @@ "cost_of_living_support_payment", "energy_bills_rebate", "winter_fuel_allowance", + "pawhp", "tax_free_childcare", "extended_childcare_entitlement", "universal_childcare_entitlement", From eeb4698a8ac0d4c97d176c9cf0cea9dc0352d195 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:30:52 -0400 Subject: [PATCH 02/28] Test that tax credits do not exempt a person from the winter fuel payment charge ITEPA 2003 s.681I(5) lists five relevant benefits and no tax credits. A mutant that exempted tax credit recipients survived the other cases. Co-Authored-By: Claude Opus 5.5 --- .../charge_parameters.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml b/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml index e92b38e9b4..2a5c82c656 100644 --- a/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml +++ b/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml @@ -49,3 +49,21 @@ output: winter_fuel_payment: 200 winter_fuel_payment_charge: 200 + +- name: Tax credits do not exempt a person from the charge + # Charging the 2024 qualifying week's payments: a tax credit award of at + # least £26 entitled a person to that payment (SI 2024/869 reg. 2(2)(b) + # (ii)), but tax credits are not among the relevant benefits of ITEPA 2003 + # s.681I(5), so the person is charged. + period: 2024 + input: + gov.hmrc.income_tax.charges.winter_fuel_payment.in_effect: true + age: 70 + country: ENGLAND + total_income: 50_000 + pension_credit: 0 + tax_credits: 1_000 + output: + is_on_winter_fuel_payment_relevant_benefit: true + winter_fuel_payment: 200 + winter_fuel_payment_charge: 200 From 4cdedd604e76cf8cc0e7484a68f0a8e1c6fa5130 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:47:13 -0400 Subject: [PATCH 03/28] Say why the property allowance route compares tax before the charge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model's total_income is before the property allowance, so the charge is the same on either route; reading income_tax in the route branches would only recompute the means-tested benefits there (a probe with income_tax in the route made the same choice and raised no CycleError). Correct the YAML case's comment to match: total income is £35,300 on either route. Co-Authored-By: Claude Opus 5.5 --- .../charges/winter_fuel_payment_charge.yaml | 18 ++++++++---------- .../allowances/uses_property_allowance.py | 11 ++++++----- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index f03d8a811c..6f6f0d2842 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -512,16 +512,14 @@ household_benefits: 203.40 hbai_benefits: 203.40 -- name: 2025 the property allowance election compares tax before the charge - # Private pension £32,000; property receipts £3,500, expenses £200, finance - # costs £1,500. With expenses: total income £35,300, tax 22,730 x 20% = - # £4,546 less the finance-cost reduction 20% x £1,500 = £300, so £4,246. - # With partial relief (£1,000 allowance, no reduction): total income - # £34,500, tax 21,930 x 20% = £4,386. The election lowers the tax before the - # charge only by keeping expenses, so they are kept. The charge depends on - # Pension Credit, which depends on property income and so on this choice, - # so the election does not weigh it: total income £35,300 is charged £200, - # which partial relief would have avoided. +- name: 2025 a landlord's property allowance election is made before the charge + # Private pension £32,000; property receipts £3,500, expenses £200 (profit + # £3,300), finance costs £1,500. With expenses: (35,300 - 12,570) x 20% = + # £4,546, less the finance-cost reduction 20% x £1,500 = £300, so £4,246. + # With partial relief (£1,000 allowance for £200 of expenses, no + # reduction): (34,500 - 12,570) x 20% = £4,386. Expenses are kept. The + # model's total_income is before the property allowance, £35,300 on either + # route, so the £200 charge is added on either. period: 2025 input: people: diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py index 8aa5a649f9..f63250b5c8 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py @@ -22,11 +22,12 @@ def income_tax_with_route(simulation, name, period, uses_allowance): """Each person's income tax with the given choices of property allowance. - The tax is before the winter fuel payment charge: the charge depends on - receipt of Pension Credit and Universal Credit, which depend on property - income and so on this choice. The branch shares the simulation's cached - arrays until it writes, and is dropped afterwards so a later period - starts from a fresh branch. + The tax is before the winter fuel payment charge. The charge turns on + total_income, which is before the property allowance and so the same on + either route, and on receipt of means-tested benefits, which reading it + here would compute again in each route's branch. The branch shares the + simulation's cached arrays until it writes, and is dropped afterwards so + a later period starts from a fresh branch. """ while name in simulation.branches or name == simulation.branch_name: name += "_" From 03154a1e9004a2679570bd0350db4ec0b58fb498 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:59:20 -0400 Subject: [PATCH 04/28] Enter only the Income Support award in the IS partner case Without universal_credit: 0 the model computes a Universal Credit award for the couple, which makes both members joint claimants and exempts the partner. Zero Universal Credit and Pension Credit, as the other cases do. Co-Authored-By: Claude Opus 5.5 --- .../hmrc/income_tax/charges/winter_fuel_payment_charge.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index 6f6f0d2842..9951c75687 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -285,6 +285,10 @@ couple: members: [claimant, partner] income_support: 1_000 + # Only the Income Support award: a Universal Credit award would make + # both members joint claimants, entitled to a relevant benefit. + universal_credit: 0 + pension_credit: 0 households: household: members: [claimant, partner] From aede16d237c02f3f0e54312f05a0787a87218538 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 22:25:58 -0400 Subject: [PATCH 05/28] Compare the means-test property tests with tax before the charge Three property tests that CI failed on 03154a1e9 compared a means test with income_tax, which now includes the winter fuel payment charge: - test_uc_earnings_deductions_properties: the pre-#1942 UC earned income formula deducted benunit_tax, which now reads the charge and so UC receipt (CycleError). It deducts the benefit unit's income tax before the charge and its NI instead. - test_council_tax_reduction_carer_income_properties and test_housing_benefit_savings_credit_only_properties: CTR and HB income deduct income_tax_before_winter_fuel_payment_charge, so the identities add back that liability, not income_tax. The HB boundary test also excludes the charge, as it excludes the other payments that depend on Pension Credit receipt. A YAML case pins CTR income to the tax before the charge, which no YAML case covered. Co-Authored-By: Claude Opus 5.5 --- .../charges/winter_fuel_payment_charge.yaml | 18 +++++++++++ ...l_tax_reduction_carer_income_properties.py | 17 ++++++---- ..._benefit_savings_credit_only_properties.py | 31 +++++++++++++------ .../test_uc_earnings_deductions_properties.py | 11 ++++++- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index 9951c75687..bc02de5e3b 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -478,6 +478,24 @@ household_benefits: 200 hbai_household_net_income: 42_514 +- name: 2025 Council Tax Reduction income deducts income tax before the charge + # The pensioner above. Council Tax Reduction income under the general rules + # deducts income tax and National Insurance, but not the charge, which + # recovers the payment: 50,000 - 7,486 = £42,514, not 50,000 - 7,686. + period: 2025 + input: + people: + pensioner: + age: 70 + private_pension_income: 50_000 + households: + household: + members: [pensioner] + country: ENGLAND + output: + winter_fuel_payment_charge: [200] + council_tax_reduction_applicable_income: [42_514] + - name: 2025 the payment is kept at £35,000 of private pension # £22,430 at 20% = £4,486; no charge. HBAI net income: 35,000 + 200 - # 4,486 = £30,714. diff --git a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index 8795cdd8c6..0f698c3dce 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -20,7 +20,9 @@ all of a family's income tax from its Council Tax Reduction income, an existing approximation, so there the supplement, which is taxable (SI 2026/93), reaches that income through the tax charged on it and in no other -way. +way. The tax deducted is the liability before the winter fuel payment charge +(ITEPA 2003 s. 681I), which recovers a winter payment rather than taxing the +income assessed; "income tax" below means that liability. Invariants, for single people and couples in Scotland in 2026 in which one member is a carer, all of working age and taking no Universal Credit or all @@ -82,6 +84,9 @@ CARER_SUPPORT_PAYMENT = 86.45 * WEEKS SCOTTISH_CARER_SUPPLEMENT = 11.70 * WEEKS WITHDRAWAL_RATE = 0.2 +# The income tax Council Tax Reduction income deducts: the liability before +# the winter fuel payment charge. +INCOME_TAX = "income_tax_before_winter_fuel_payment_charge" PROPERTY_SETTINGS = settings( max_examples=10, deadline=None, @@ -128,7 +133,7 @@ "carer_support_payment", "carer_support_payment_pre_overlap", "scottish_carer_supplement", - "income_tax", + INCOME_TAX, "national_insurance", ] CLAIMS = (True, False) @@ -230,7 +235,7 @@ def grid(families, reform=None): # from the other benefits it counts. g["income_before_tax"] = ( g["council_tax_reduction_applicable_income"] - + g["income_tax"] + + g[INCOME_TAX] + g["national_insurance"] - g["other_counted_benefits"] ) @@ -330,8 +335,8 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( compared = (income > 0) & (income_without > 0) & general assert compared.any(), fam assert np.allclose( - (income + g["income_tax"][i])[compared], - (income_without + without["income_tax"][i])[compared], + (income + g[INCOME_TAX][i])[compared], + (income_without + without[INCOME_TAX][i])[compared], atol=0.05, ), fam award = g["council_tax_benefit"][i] @@ -341,7 +346,7 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( # follows invariant 4). assert np.all(award >= award_without - 0.01), fam both_partial = partial(g, i) & partial(without, i) & general - tax_on_supplement = g["income_tax"][i] - without["income_tax"][i] + tax_on_supplement = g[INCOME_TAX][i] - without[INCOME_TAX][i] assert np.allclose( (award - award_without)[both_partial], WITHDRAWAL_RATE * tax_on_supplement[both_partial], diff --git a/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py b/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py index 6d19738615..7ff84bd23a 100644 --- a/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py @@ -27,19 +27,24 @@ award at both ends, household net income, excluding the TV licence, does not fall. 2. Boundary: on the step where savings credit ends, household net income does - not fall, excluding the TV licence, the Winter Fuel Payment and the - Scottish Pension Age Winter Heating Payment, which the model makes depend - on receiving Pension Credit (the free licence at 75, the Scottish payment's - Pension Credit rates, and the Winter Fuel Payment above £35,000 of taxable - income). Housing Benefit switches from the Secretary of State's figure to - its own rules there. + not fall, excluding the TV licence, the Scottish Pension Age Winter + Heating Payment and the winter fuel payment charge, which the model makes + depend on receiving Pension Credit (the free licence at 75, the Scottish + payment's Pension Credit rates, and the charge, from which Pension Credit + exempts a person with more than £35,000 of total income: ITEPA 2003 s. + 681I), and the Winter Fuel Payment, which depended on it before 2025-26. + Housing Benefit switches from the Secretary of State's figure to its own + rules there. 3. Differential: a savings-credit-only family's Housing Benefit income is the Pension Credit income, recomputed here from pensions, income tax and the deemed income on savings, plus the savings credit, less the earnings disregard; its tariff income is zero and its capital is its savings. Other families keep the Housing Benefit rules: zero income with a guarantee credit, otherwise pensions less income tax plus tariff income less the - earnings disregard. + earnings disregard. The income tax both routes deduct is the liability + before the winter fuel payment charge (ITEPA 2003 s. 681I), which recovers + the Winter Fuel Payment or the Scottish payment rather than taxing the + pensions. Carers are not drawn. """ @@ -176,7 +181,12 @@ def grid(families): variable: np.asarray(simulation.calculate(variable, YEAR)).reshape(shape) for variable in BENUNIT_VARIABLES + HOUSEHOLD_VARIABLES } - for variable in ["state_pension", "private_pension_income", "income_tax"]: + for variable in [ + "state_pension", + "private_pension_income", + "income_tax_before_winter_fuel_payment_charge", + "winter_fuel_payment_charge", + ]: values[variable] = np.asarray( simulation.calculate(variable, YEAR, map_to="benunit") ).reshape(shape) @@ -230,6 +240,7 @@ def test_net_income_does_not_fall_where_savings_credit_ends(families): + g["tv_licence"] - g["winter_fuel_allowance"] - g["pawhp"] + + g["winter_fuel_payment_charge"] ) step = np.diff(net, axis=1) ends = g["savings_credit_only"][:, :-1] & (g["pension_credit"][:, 1:] <= 0) @@ -254,7 +265,9 @@ def test_income_follows_the_route_for_the_award(families): savings = np.array([fam["savings"] for fam in families])[:, None] capital_income = deemed_income(savings) pensions_after_tax = ( - g["state_pension"] + g["private_pension_income"] - g["income_tax"] + g["state_pension"] + + g["private_pension_income"] + - g["income_tax_before_winter_fuel_payment_charge"] ) disregard = g["housing_benefit_applicable_income_disregard"] sc_only = g["savings_credit_only"] diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index bc961fe791..6032a8c3b5 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -292,10 +292,19 @@ def formula(benunit, period, parameters): period, ["employment_income", "self_employment_income", "miscellaneous_income"], ) + # The whole benefit unit's tax, as benunit_tax was before the + # winter fuel payment charge joined income tax: the charge depends + # on Universal Credit receipt, so deducting it here would be + # circular. disregards = add( benunit, period, - ["uc_work_allowance", "benunit_tax", "pension_contributions"], + [ + "uc_work_allowance", + "income_tax_before_winter_fuel_payment_charge", + "national_insurance", + "pension_contributions", + ], ) return max_(0, gross - disregards) From 97e0c57fa798f662b7dc76720b64b6352b667911 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 08:40:48 -0400 Subject: [PATCH 06/28] Compare the UC and CTR property tests with tax before the charge; tidy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r3 (lost to the 10/12 reboot) found two properties false inside their own generators on aede16d23, both comparing a means test with income_tax, which now includes the winter fuel payment charge: - test_deductions_equal_tax_and_ni_on_earnings_alone: a pensioner in a mixed-age couple earning £40,000 off UC is charged £200. UC deducts tax on earnings (reg 55(5)), so the test compares with the tax before the charge. - check_routes in the CTR Pension Credit properties: MAIN_DEDUCTIONS now uses the tax before the charge, as the CTR means test does. Both counterexamples are pinned as plain tests that also assert the charge is levied. Also: - the inert income_tax: 0 inputs in two means-test YAML files now set the variable the means tests read; - the property-allowance YAML case is labelled a known gap: the law's partial relief would bring total income under £35,000; - s.681J links to ITEPA, not FA 2026; - the changelog no longer says the household test "charged" anyone; - the HB property's reason for excluding the WFP fits its 2026 year. Co-Authored-By: Claude Opus 5.5 --- .../winter-fuel-payment-charge.fixed.md | 2 +- .../winter_fuel_payment/income_threshold.yaml | 2 +- ...ousing_benefit_pension_age_means_test.yaml | 4 +- .../claimant_partner_income_and_carers.yaml | 10 ++-- .../charges/winter_fuel_payment_charge.yaml | 11 +++-- ...tax_reduction_pension_credit_properties.py | 33 ++++++++++++- ..._benefit_savings_credit_only_properties.py | 3 +- .../test_uc_earnings_deductions_properties.py | 47 ++++++++++++++++++- 8 files changed, 96 insertions(+), 16 deletions(-) diff --git a/changelog.d/winter-fuel-payment-charge.fixed.md b/changelog.d/winter-fuel-payment-charge.fixed.md index 97b3a984df..6d862676e3 100644 --- a/changelog.d/winter-fuel-payment-charge.fixed.md +++ b/changelog.d/winter-fuel-payment-charge.fixed.md @@ -1,2 +1,2 @@ -- From the 2025 qualifying week, pay the Winter Fuel Payment to everyone of pensionable age in England, Wales and Northern Ireland (SI 2025/969 reg. 2; NISR 2025/142 reg. 2) and recover it through the winter fuel payment charge, in place of a household income test. The test paid a whole household if any member of pensionable age had total income under £35,000. It applied only in England and Wales, and it charged nobody in Northern Ireland or on the Pension Age Winter Heating Payment. Its boundary was also wrong: total income of exactly £35,000 is not charged. +- From the 2025 qualifying week, pay the Winter Fuel Payment to everyone of pensionable age in England, Wales and Northern Ireland (SI 2025/969 reg. 2; NISR 2025/142 reg. 2) and recover it through the winter fuel payment charge, in place of a household income test. The test paid a whole household if any member of pensionable age had total income under £35,000. It applied only in England and Wales, so pensioners in Northern Ireland not on a relevant benefit were paid nothing, and nothing stood in for the charge on the Pension Age Winter Heating Payment. Its boundary was also wrong: total income of exactly £35,000 is not charged. - Count the Pension Age Winter Heating Payment in `household_benefits`, `hbai_household_net_income` and `hbai_benefits`, as the Winter Fuel Payment is. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/income_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/income_threshold.yaml index f82887986d..e2e1f93805 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/income_threshold.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/charges/winter_fuel_payment/income_threshold.yaml @@ -6,7 +6,7 @@ values: - title: Income Tax (Earnings and Pensions) Act 2003, s. 681I(1)(b), inserted by Finance Act 2026, Sch. 10 para. 1 href: https://www.legislation.gov.uk/ukpga/2003/1/section/681I - title: Income Tax (Earnings and Pensions) Act 2003, s. 681J (alteration of income limit by Treasury order) - href: https://www.legislation.gov.uk/ukpga/2026/11/schedule/10 + href: https://www.legislation.gov.uk/ukpga/2003/1/section/681J metadata: unit: currency-GBP period: year diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/housing_benefit_pension_age_means_test.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/housing_benefit_pension_age_means_test.yaml index 4e721cbe6b..6f0d4263ec 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/housing_benefit_pension_age_means_test.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/housing_benefit_pension_age_means_test.yaml @@ -82,7 +82,7 @@ months_since_last_birthday: 4 is_male: true employment_income: 1_000 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 pension_contributions: 0 benunits: @@ -123,7 +123,7 @@ months_since_last_birthday: 4 is_male: true employment_income: 1_000 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 pension_contributions: 0 benunits: diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml index 6602c17a7d..9d58b08e50 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/claimant_partner_income_and_carers.yaml @@ -6,7 +6,7 @@ claimant: age: 75 is_parent: true - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 pension_contributions: 0 young_person: @@ -52,7 +52,7 @@ age: 75 is_parent: true state_pension: 8_000 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 pension_contributions: 0 young_person: @@ -82,7 +82,7 @@ age: 75 is_parent: true state_pension: 10_000 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 pension_contributions: 0 young_person: @@ -114,7 +114,7 @@ is_parent: true state_pension: 8_000 basic_income: 100 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 pension_contributions: 0 young_person: @@ -197,7 +197,7 @@ age: 75 is_parent: true state_pension: 10_000 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 pension_contributions: 0 young_person: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index bc02de5e3b..2ac8ffbe10 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -12,7 +12,8 @@ # 2007 ss.23 and 30). # The payments are worked from SI 2025/969 regs 2 to 4, NISR 2025/142 regs 2 # and 3, and SSI 2024/351 reg 10 as substituted by SSI 2025/282 (amounts from -# SSI 2025/100 and SSI 2026/170). Entitlement to a payment, and so who counts +# SSI 2025/100 and SSI 2026/170). One case, marked "model, not law", pins a +# known gap instead. Entitlement to a payment, and so who counts # as "a person entitled to a winter fuel payment" for the shared amounts, does # not depend on the charge. Benefit-unit awards are entered directly. @@ -534,14 +535,18 @@ household_benefits: 203.40 hbai_benefits: 203.40 -- name: 2025 a landlord's property allowance election is made before the charge +- name: 2025 a landlord's property allowance election is made before the charge (model, not law) # Private pension £32,000; property receipts £3,500, expenses £200 (profit # £3,300), finance costs £1,500. With expenses: (35,300 - 12,570) x 20% = # £4,546, less the finance-cost reduction 20% x £1,500 = £300, so £4,246. # With partial relief (£1,000 allowance for £200 of expenses, no # reduction): (34,500 - 12,570) x 20% = £4,386. Expenses are kept. The # model's total_income is before the property allowance, £35,300 on either - # route, so the £200 charge is added on either. + # route, so the £200 charge is added on either: £4,446. + # KNOWN GAP, pinned on purpose: under the law, partial relief puts total + # income at £34,500, under the threshold, so a landlord would elect it and + # pay £4,386 with no charge. The model does not deduct the property or + # trading allowance from total_income (see the PR's Limits). period: 2025 input: people: diff --git a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py index e6d1de97c7..e443985ad3 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py @@ -285,7 +285,10 @@ def situation( "esa_income", "universal_credit", ] -MAIN_DEDUCTIONS = ["income_tax", "national_insurance"] +# The means tests deduct income tax before the winter fuel payment charge +# (ITEPA 2003 s.681I), which recovers a payment rather than taxing the income +# they assess. A pensioner over £35,000 of total income is charged it. +MAIN_DEDUCTIONS = ["income_tax_before_winter_fuel_payment_charge", "national_insurance"] # The pensioner earnings disregards in 2026, written out from the law, not # read from the parameters: £25 a week for a lone parent, £10 with a partner, # £5 otherwise, plus £17.10 (SI 2012/2885 Sch 4 paras 2, 8 and 10; WSI @@ -511,6 +514,34 @@ def test_not_claiming_pension_credit_never_raises_a_recipients_ctr(units, guaran ), unit +def test_ctr_income_deducts_tax_before_the_winter_fuel_payment_charge(): + """A pensioner with £39,500 of total income is charged the winter fuel + payment (ITEPA 2003 s.681I) through income tax. CTR deducts the tax + before the charge, so the routes agree with the main definition.""" + family = dict( + shape="single_pension", + ages=[70], + country="ENGLAND", + council_tax=4_000.0, + savings=0.0, + other_property=0.0, + state_pension=25_000.0, + private_pension=5_000.0, + employment_income=0.0, + self_employment_income=0.0, + property_income=5_000.0, + carers_allowance=4_500.0, + child_ages=[], + would_claim_uc=False, + would_claim_pc=True, + non_dependant_income=None, + ) + data, _ = situation([family]) + sim = Simulation(situation=data) + assert sim.calculate("winter_fuel_payment_charge", YEAR).sum() > 0 + check_routes([family], calculate([family])) + + def test_savings_credit_only_award_counts_the_frozen_savings_credit(): # A savings credit rate rise raises the reform's savings credit from # 558.62 to 869.89, but the freeze keeps paying the baseline 558.62, and diff --git a/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py b/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py index 7ff84bd23a..0fcb74c3f0 100644 --- a/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py +++ b/policyengine_uk/tests/test_housing_benefit_savings_credit_only_properties.py @@ -32,7 +32,8 @@ depend on receiving Pension Credit (the free licence at 75, the Scottish payment's Pension Credit rates, and the charge, from which Pension Credit exempts a person with more than £35,000 of total income: ITEPA 2003 s. - 681I), and the Winter Fuel Payment, which depended on it before 2025-26. + 681I), and the Winter Fuel Payment, whose amount depends on it (one + payment for a couple on a relevant benefit, shared amounts otherwise). Housing Benefit switches from the Secretary of State's figure to its own rules there. 3. Differential: a savings-credit-only family's Housing Benefit income is the diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 6032a8c3b5..64d185e41d 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -97,6 +97,7 @@ "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", "income_tax", + "income_tax_before_winter_fuel_payment_charge", "national_insurance", ] @@ -259,9 +260,13 @@ 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"] + # Income tax in respect of earnings (UC Regs 2013 reg 55(5)): the + # liability before the winter fuel payment charge, which recovers a + # payment rather than taxing the earnings. A pensioner of a mixed-age + # couple earning over £35,000 off UC is charged it. np.testing.assert_allclose( full["uc_income_tax_on_earnings"][adult], - alone["income_tax"][adult], + alone["income_tax_before_winter_fuel_payment_charge"][adult], atol=0.01, err_msg=str(units), ) @@ -272,12 +277,50 @@ def test_deductions_equal_tax_and_ni_on_earnings_alone(units, year): 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_income_tax_on_earnings"] + <= full["income_tax_before_winter_fuel_payment_charge"] + 0.01 + ) assert np.all( full["uc_national_insurance_on_earnings"] <= full["national_insurance"] + 0.01 ) +def test_earnings_deduction_excludes_the_winter_fuel_payment_charge(): + """A pensioner in a mixed-age couple earning £40,000 off UC is charged + the winter fuel payment (ITEPA 2003 s.681I) through income tax. UC + deducts the tax on their earnings, which the charge is not part of.""" + units = [ + dict( + adults=[ + dict( + age=70, + employment_income=30_000.0, + self_employment_income=10_000.0, + uc_is_in_startup_period=False, + married_couples_allowance=0.0, + ), + dict( + age=30, + employment_income=0.0, + self_employment_income=0.0, + uc_is_in_startup_period=False, + married_couples_allowance=0.0, + ), + ], + children=[], + tenure="OWNED_OUTRIGHT", + rent=0.0, + region="LONDON", + ) + ] + sim = Simulation(situation=situation(units, 2026, earnings_only=True)) + assert sim.calculate("winter_fuel_payment_charge", 2026)[0] > 0 + test_deductions_equal_tax_and_ni_on_earnings_alone.hypothesis.inner_test( + units, 2026 + ) + + def _use_formula_before_1942(simulation): class uc_earned_income(Variable): value_type = float From 1024887303b35d2abfafeeeeb2d6a58f8c15603c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 21:35:43 -0400 Subject: [PATCH 07/28] Count statutory pay in the charge's total income and the charge in the property election Review r5 and CI on adcf8216a: - The charge tested total_income, which leaves out the statutory sick and maternity pay the model taxes as employment income. Total income is the income charged to tax (ITA 2007 s.23 Step 1), so they now count (test_uc_statutory_pay_properties invariant 2 failed on CI). - uses_property_allowance compared tax before the charge. A route can change Pension Credit and so the s.681I(4) exemption; it now compares income_tax, as main did (r5 P2, the m6 mutant was not equivalent). - test_trading_loss_properties: the means tests deduct tax before the charge, so the properties hold that; relief versus income cut compares it too, since relief comes after total income (Step 2). - test_variable_code_sharing reads a variable with a formula; income_tax now only adds. - YAML: statutory sick pay in total income, trade loss relief after total income, and the election that avoids the charge. Co-Authored-By: Claude Opus 5.5 --- .../charges/winter_fuel_payment_charge.yaml | 101 ++++++++++++++++++ .../tests/test_trading_loss_properties.py | 27 +++-- .../tests/test_variable_code_sharing.py | 13 ++- .../allowances/uses_property_allowance.py | 20 ++-- .../charges/winter_fuel_payment_charge.py | 13 ++- 5 files changed, 147 insertions(+), 27 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index 2ac8ffbe10..642c02a6c1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -566,3 +566,104 @@ income_tax_before_winter_fuel_payment_charge: [4_246] winter_fuel_payment_charge: [200] income_tax: [4_446] + +- name: 2025 statutory sick pay counts towards total income + # Statutory sick pay is income charged to tax, so it is part of total + # income (ITA 2007 s.23 Step 1): £30,000 + £6,000 = £36,000, over £35,000. + # Tax before the charge: (36,000 - 12,570) x 20% = £4,686; with the £200 + # charge, £4,886. + period: 2025 + input: + people: + pensioner: + age: 70 + private_pension_income: 30_000 + statutory_sick_pay: 6_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + winter_fuel_payment: [200] + winter_fuel_payment_charge: [200] + income_tax_before_winter_fuel_payment_charge: [4_686] + income_tax: [4_886] + +- name: 2025 trade loss relief does not lower total income + # Trade loss relief is deducted at Step 2 (ITA 2007 ss.23 and 64), after + # total income is found at Step 1. Total income is £30,000 + £10,000 = + # £40,000, so the £200 charge applies. Tax before the charge: net income + # 40,000 - 8,000 = £32,000, (32,000 - 12,570) x 20% = £3,886; with the + # charge, £4,086. + period: 2025 + input: + people: + pensioner: + age: 70 + employment_income: 30_000 + private_pension_income: 10_000 + trading_loss: 8_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + trade_loss_relief_against_general_income: [8_000] + winter_fuel_payment_charge: [200] + income_tax_before_winter_fuel_payment_charge: [3_886] + income_tax: [4_086] + +- name: 2025 a landlord's property allowance election counts the charge + # Private pension £32,520; property receipts £3,500, expenses £200 (profit + # £3,300), finance costs £1,500; five children. With expenses: (35,820 - + # 12,570) x 20% = £4,650, less the finance-cost reduction 20% x £1,500 = + # £300, so £4,350. With partial relief (£1,000 allowance, no reduction): + # (35,020 - 12,570) x 20% = £4,490. Pension Credit income is £29,970 with + # expenses and £29,830 with the allowance, against the model's minimum + # guarantee of £29,893.76 (with the amounts for the children), so only the + # allowance route gets Pension Credit, which exempts the pensioner from the + # charge (s.681I(4)). Total tax: £4,350 + £200 = £4,550 with expenses, + # £4,490 with the allowance, so the pensioner elects for partial relief. + period: 2025 + input: + people: + pensioner: + age: 70 + private_pension_income: 32_520 + property_rental_income: 3_500 + property_income: 3_300 + property_finance_costs: 1_500 + child_1: + age: 10 + child_2: + age: 10 + child_3: + age: 10 + child_4: + age: 10 + child_5: + age: 10 + benunits: + unit: + members: [pensioner, child_1, child_2, child_3, child_4, child_5] + would_claim_pc: true + would_claim_uc: false + households: + household: + members: [pensioner, child_1, child_2, child_3, child_4, child_5] + country: ENGLAND + output: + uses_property_allowance: [true, false, false, false, false, false] + pension_credit_income: 29_830 + winter_fuel_payment: [200, 0, 0, 0, 0, 0] + winter_fuel_payment_charge: [0, 0, 0, 0, 0, 0] + income_tax_before_winter_fuel_payment_charge: [4_490, 0, 0, 0, 0, 0] + income_tax: [4_490, 0, 0, 0, 0, 0] diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index f2360db709..05ada96925 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -67,6 +67,9 @@ pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEAR = 2026 +# Income tax before the winter fuel payment charge: what the means tests +# deduct. The charge depends on their awards, so they cannot deduct it. +PRE_CHARGE_TAX = "income_tax_before_winter_fuel_payment_charge" PROPERTY_SETTINGS = settings( max_examples=3, deadline=None, @@ -357,17 +360,21 @@ def reduced(person): ) def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): twins = [reduced(p)[0] for p in people] - variables = ["income_tax", "trade_loss_relief_against_general_income"] + # The winter fuel payment charge tests total income, before Step 2 + # reliefs (ITEPA 2003 s.681I(1)(b), ITA 2007 s.23), so a relief and an + # income cut can differ in it for people of pension age. + tax = PRE_CHARGE_TAX + variables = [tax, "trade_loss_relief_against_general_income"] a = calculate(single_people(people, regions), variables) - b = calculate(single_people(twins, regions), ["income_tax"]) + b = calculate(single_people(twins, regions), [tax]) for i, person in enumerate(people): _, relief = reduced(person) assert abs(a["trade_loss_relief_against_general_income"][i] - relief) <= ( tolerance(relief) ), person - assert abs(a["income_tax"][i] - b["income_tax"][i]) <= 0.5 + tolerance( - a["income_tax"][i], person["employment_income"] - ), (person, a["income_tax"][i], b["income_tax"][i]) + assert abs(a[tax][i] - b[tax][i]) <= 0.5 + tolerance( + a[tax][i], person["employment_income"] + ), (person, a[tax][i], b[tax][i]) @PROPERTY_SETTINGS @@ -584,12 +591,14 @@ def values(sim, variable): baseline = simulation(zero_profit, person_inputs) actual_loss = simulation(loss_making, person_inputs) - actual_tax = values(actual_loss, "income_tax") - baseline_tax = values(baseline, "income_tax") + # Before the winter fuel payment charge: a loss can end Pension Credit, + # and with it the exemption from the charge (s.681I(4)). + actual_tax = values(actual_loss, PRE_CHARGE_TAX) + baseline_tax = values(baseline, PRE_CHARGE_TAX) assert np.all(actual_tax <= baseline_tax + 0.5), (actual_tax, baseline_tax, units) deductions = [ - "income_tax", + PRE_CHARGE_TAX, "national_insurance", "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", @@ -624,7 +633,7 @@ def values(sim, variable): names = with_loss.populations["person"].ids benunits = with_loss.populations["benunit"].ids deductions = [ - "income_tax", + PRE_CHARGE_TAX, "national_insurance", "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", diff --git a/policyengine_uk/tests/test_variable_code_sharing.py b/policyengine_uk/tests/test_variable_code_sharing.py index 7445f4ab9b..f8850d31ee 100644 --- a/policyengine_uk/tests/test_variable_code_sharing.py +++ b/policyengine_uk/tests/test_variable_code_sharing.py @@ -19,6 +19,9 @@ from policyengine_uk import Simulation +# A variable with a formula. income_tax itself adds this and the winter fuel +# payment charge, so it has no formula of its own. +FORMULA_VARIABLE = "income_tax_before_winter_fuel_payment_charge" SITUATION = { "people": {"adult": {"age": {2026: 40}, "employment_income": {2026: 30_000}}}, "benunits": {"benunit": {"members": ["adult"]}}, @@ -42,8 +45,8 @@ def core_shares_code(request): with pytest.MonkeyPatch.context() as monkeypatch: monkeypatch.setattr(SourceFileLoader, "get_code", _conftest(request)._get_code) first, second = CountryTaxBenefitSystem(), CountryTaxBenefitSystem() - first = first.variables["income_tax"].get_formula().__code__ - second = second.variables["income_tax"].get_formula().__code__ + first = first.variables[FORMULA_VARIABLE].get_formula().__code__ + second = second.variables[FORMULA_VARIABLE].get_formula().__code__ return first is second @@ -56,7 +59,7 @@ def _skip_if_core_shares(core_shares_code): def _income_tax_formula(simulation): - variable = simulation.tax_benefit_system.variables["income_tax"] + variable = simulation.tax_benefit_system.variables[FORMULA_VARIABLE] return variable.get_formula() @@ -79,8 +82,8 @@ def _coverage_code_objects(): def test_systems_share_code_but_not_modules_classes_or_functions(): first = Simulation(situation=SITUATION) second = Simulation(situation=SITUATION) - first_class = type(first.tax_benefit_system.variables["income_tax"]) - second_class = type(second.tax_benefit_system.variables["income_tax"]) + first_class = type(first.tax_benefit_system.variables[FORMULA_VARIABLE]) + second_class = type(second.tax_benefit_system.variables[FORMULA_VARIABLE]) first_formula = _income_tax_formula(first) second_formula = _income_tax_formula(second) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py index f63250b5c8..cc7a7c2196 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py @@ -22,21 +22,19 @@ def income_tax_with_route(simulation, name, period, uses_allowance): """Each person's income tax with the given choices of property allowance. - The tax is before the winter fuel payment charge. The charge turns on - total_income, which is before the property allowance and so the same on - either route, and on receipt of means-tested benefits, which reading it - here would compute again in each route's branch. The branch shares the - simulation's cached arrays until it writes, and is dropped afterwards so - a later period starts from a fresh branch. + The tax includes the winter fuel payment charge. The model's total_income + is before the property allowance, so it is the same on either route, but + a route can change the person's Pension Credit and so whether the charge + applies (s.681I(4)). The branch shares the simulation's cached arrays + until it writes, and is dropped afterwards so a later period starts from + a fresh branch. """ while name in simulation.branches or name == simulation.branch_name: name += "_" branch = simulation.get_branch(name) try: branch.set_input("uses_property_allowance", period, uses_allowance) - return branch.populations["person"]( - "income_tax_before_winter_fuel_payment_charge", period - ) + return branch.populations["person"]("income_tax", period) finally: del simulation.branches[name] @@ -52,8 +50,8 @@ class uses_property_allowance(Variable): "two cannot be combined. Full relief applies to receipts within the " "allowance unless the person elects out of it, and partial relief " "only if they elect for it; each election is made only if it lowers " - "the person's own income tax (before the winter fuel payment charge), " - "with everyone else's route held fixed. " + "the person's own income tax, including the winter fuel payment " + "charge, with everyone else's route held fixed. " "Ties keep the default, so a person with receipts within the " "allowance keeps full relief even where electing out would carry " "finance costs forward. Without finance costs to relieve, the " diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py b/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py index c1dab9a309..e3c2b10300 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py @@ -17,7 +17,9 @@ class winter_fuel_payment_charge(Variable): "The charge to income tax that recovers a person's winter fuel " "payment: the whole payment, when the person's own total income for " "the tax year exceeds the threshold and they are not entitled to a " - "relevant benefit in the qualifying week. It is additional tax at " + "relevant benefit in the qualifying week. Total income includes the " + "statutory sick and maternity pay the model taxes as employment " + "income. It is additional tax at " "Step 7 of the income tax calculation, so tax reductions do not " "offset it. The payment itself, and who counts as another entitled " "person for the shared amounts, are unaffected by the charge." @@ -33,6 +35,13 @@ class winter_fuel_payment_charge(Variable): def formula(person, period, parameters): p = parameters(period).gov.hmrc.income_tax.charges.winter_fuel_payment payment = add(person, period, WINTER_FUEL_PAYMENTS) - over_threshold = person("total_income", period) > p.income_threshold + # Total income is the income charged to tax (ITA 2007 s.23 Step 1). + # The model taxes statutory sick and maternity pay as employment + # income (employment_benefits, in taxable_employment_income), but + # total_income leaves them out, so they are added here. + total_income = person("total_income", period) + person( + "employment_benefits", period + ) + over_threshold = total_income > p.income_threshold on_relevant_benefit = add(person, period, p.relevant_benefits) > 0 return p.in_effect * payment * over_threshold * ~on_relevant_benefit From bc1fe6370382425fc7daefbcf80c8b1ee7cd23aa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 15:08:00 -0400 Subject: [PATCH 08/28] Bring the changelog up to date with main Main now has the PAWHP household-income lines (#2047) and UC and benefit cap earnings tests that deduct each person's own tax on earnings, so the fragments no longer claim them. The property election compares income_tax, which includes the charge, since 102488730. Co-Authored-By: Claude Opus 5.5 --- changelog.d/winter-fuel-payment-charge.added.md | 2 +- changelog.d/winter-fuel-payment-charge.fixed.md | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/changelog.d/winter-fuel-payment-charge.added.md b/changelog.d/winter-fuel-payment-charge.added.md index c581723a1b..2098874cbc 100644 --- a/changelog.d/winter-fuel-payment-charge.added.md +++ b/changelog.d/winter-fuel-payment-charge.added.md @@ -1,2 +1,2 @@ - Add the winter fuel payment charge (`winter_fuel_payment_charge`, ITEPA 2003 s.681I, from 2025-26) with parameters under `gov.hmrc.income_tax.charges.winter_fuel_payment`. It is charged person by person: the whole Winter Fuel Payment (England, Wales and Northern Ireland) or Pension Age Winter Heating Payment, when the person's own total income exceeds £35,000 and they are not entitled to Income Support, income-based JSA, Pension Credit, income-related ESA or Universal Credit. It is added to `income_tax` after tax reductions (ITA 2007 s.23 Step 7). -- Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit, Income Support, Housing Benefit (applicable income and net earnings), Council Tax Reduction and benefit cap means tests now deduct it instead of `income_tax`, and the property allowance election compares it. The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. Universal Credit earned income already deducts only each person's own tax on earnings, which leaves out the charge. +- Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit, Income Support, Housing Benefit (applicable income and net earnings) and Council Tax Reduction means tests now deduct it instead of `income_tax`. The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. Universal Credit earned income and the benefit cap's earnings test already deduct only each person's own tax on earnings, which leaves out the charge. The property allowance election still compares `income_tax`, which now includes the charge: a route can change Pension Credit and so whether the charge applies. diff --git a/changelog.d/winter-fuel-payment-charge.fixed.md b/changelog.d/winter-fuel-payment-charge.fixed.md index 6d862676e3..2c5657914f 100644 --- a/changelog.d/winter-fuel-payment-charge.fixed.md +++ b/changelog.d/winter-fuel-payment-charge.fixed.md @@ -1,2 +1 @@ - From the 2025 qualifying week, pay the Winter Fuel Payment to everyone of pensionable age in England, Wales and Northern Ireland (SI 2025/969 reg. 2; NISR 2025/142 reg. 2) and recover it through the winter fuel payment charge, in place of a household income test. The test paid a whole household if any member of pensionable age had total income under £35,000. It applied only in England and Wales, so pensioners in Northern Ireland not on a relevant benefit were paid nothing, and nothing stood in for the charge on the Pension Age Winter Heating Payment. Its boundary was also wrong: total income of exactly £35,000 is not charged. -- Count the Pension Age Winter Heating Payment in `household_benefits`, `hbai_household_net_income` and `hbai_benefits`, as the Winter Fuel Payment is. From 005a97545b1a5df72025ca6db677abdbc6fe060c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 15:40:39 -0400 Subject: [PATCH 09/28] Name the property allowance case for the gap it pins The election has counted the charge since 102488730, so the case is about total income being taken before the property allowance, not about the election ignoring the charge. Expected values are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../income_tax/charges/winter_fuel_payment_charge.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index 642c02a6c1..73d688cb0d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -535,14 +535,14 @@ household_benefits: 203.40 hbai_benefits: 203.40 -- name: 2025 a landlord's property allowance election is made before the charge (model, not law) +- name: 2025 a landlord's total income is taken before the property allowance (model, not law) # Private pension £32,000; property receipts £3,500, expenses £200 (profit # £3,300), finance costs £1,500. With expenses: (35,300 - 12,570) x 20% = # £4,546, less the finance-cost reduction 20% x £1,500 = £300, so £4,246. # With partial relief (£1,000 allowance for £200 of expenses, no - # reduction): (34,500 - 12,570) x 20% = £4,386. Expenses are kept. The - # model's total_income is before the property allowance, £35,300 on either - # route, so the £200 charge is added on either: £4,446. + # reduction): (34,500 - 12,570) x 20% = £4,386. The model's total_income + # is before the property allowance, £35,300 on either route, so the £200 + # charge is added on either and expenses are kept: £4,446 against £4,586. # KNOWN GAP, pinned on purpose: under the law, partial relief puts total # income at £34,500, under the threshold, so a landlord would elect it and # pay £4,386 with no charge. The model does not deduct the property or From 8befc6dbe7db6a296445f4e2c1ed02ca1e5995d3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 16:39:07 -0400 Subject: [PATCH 10/28] Point main's rule comparison test at a formula and keep the charge in the trade loss property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_rule_comparison (new on main) replaces income_tax's formula, but income_tax now only adds the pre-charge liability and the charge, so it has none (CI: StopIteration). It now replaces the pre-charge formula. - The zero-profit trade loss property held only tax before the charge, since 102488730. It now also checks the charge: it moves only in a household where the loss changes who is on a relevant benefit, as when lower tax ends Pension Credit and with it the exemption (s.681I(4)). A pinned pensioner exercises that case: on Pension Credit (£217.48) and not charged without the loss; off it and charged £200 with it. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_rule_comparison.py | 4 +- .../tests/test_trading_loss_properties.py | 57 ++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_rule_comparison.py b/policyengine_uk/tests/test_rule_comparison.py index bf294ec46f..89bc0661a6 100644 --- a/policyengine_uk/tests/test_rule_comparison.py +++ b/policyengine_uk/tests/test_rule_comparison.py @@ -62,7 +62,9 @@ def test_a_neutralised_variable_differs(system): def test_a_replaced_formula_differs(system): copy = system.clone() - formulas = copy.variables["income_tax"].formulas + # A variable with a formula: income_tax itself only adds this and the + # winter fuel payment charge. + formulas = copy.variables["income_tax_before_winter_fuel_payment_charge"].formulas formulas[next(iter(formulas))] = lambda person, period, parameters: 0 assert not same_rules(system, copy) diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 43f2a85ee5..fa6621c42b 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -71,6 +71,14 @@ # Income tax before the winter fuel payment charge: what the means tests # deduct. The charge depends on their awards, so they cannot deduct it. PRE_CHARGE_TAX = "income_tax_before_winter_fuel_payment_charge" +# The benefits that exempt a person from the charge (s.681I(5)). +RELEVANT_BENEFITS = [ + "is_on_income_support", + "is_on_income_based_jsa", + "is_on_pension_credit", + "is_on_income_related_esa", + "is_on_universal_credit", +] PROPERTY_SETTINGS = settings( max_examples=3, deadline=None, @@ -533,19 +541,42 @@ def home(rent, region="LONDON"): ] +# A pensioner with five children on Pension Credit and so not charged. In +# 2026 the loss cuts tax by £1,000, which ends Pension Credit and with it the +# exemption from the winter fuel payment charge (ITEPA 2003 s.681I(4)). +PINNED_PENSIONER = [ + ( + [ + adult( + age=70, + employment_income=3_000.0, + private_pension_income=32_520.0, + trading_loss=5_000.0, + hours_worked=16.0, + ) + ] + + [{"age": 10, "trading_loss": 0.0} for _ in range(5)], + home(9_000.0, "NORTH_EAST"), + ), +] + + @pytest.mark.parametrize( ("programme", "year"), [("universal_credit", 2026), ("housing_benefit", 2024), ("tax_credits", 2024)], ) @PROPERTY_SETTINGS @example(units=PINNED_FAMILIES) +@example(units=PINNED_PENSIONER) @given(units=st.lists(families(), min_size=4, max_size=10)) def test_zero_profit_trade_loss_cannot_raise_tax_or_reduce_programme_award( units, programme, year ): """Compare a loss-making trade with an otherwise identical zero-profit trade. - Tax uses each person's actual liability. Benefit comparisons hold paid + Tax uses each person's actual liability before the winter fuel payment + charge, and the charge moves only where the loss changes receipt of a + relevant benefit in the household. Benefit comparisons hold paid deductions at the zero-loss values, including UC's earnings-only tax and NI. Housing Benefit is tested for continuing 2024 awards without tax credits: a changed credit receipt can legitimately change Housing Benefit. @@ -598,6 +629,30 @@ def values(sim, variable): baseline_tax = values(baseline, PRE_CHARGE_TAX) assert np.all(actual_tax <= baseline_tax + 0.5), (actual_tax, baseline_tax, units) + # The charge itself tests total income, which the loss leaves alone + # (relief comes at Step 2), and receipt of a relevant benefit, which also + # sets the payments. So it moves only in a household where the loss + # changes who is on a relevant benefit. + def on_relevant_benefit(sim): + return np.any([values(sim, v) > 0 for v in RELEVANT_BENEFITS], axis=0) + + names = baseline.populations["person"].ids + household = np.array([int(name.split("_")[0][1:]) for name in names]) + receipt_moved = np.bincount( + household, + weights=( + on_relevant_benefit(baseline) != on_relevant_benefit(actual_loss) + ).astype(float), + minlength=len(units), + )[household] + charge = "winter_fuel_payment_charge" + charge_moved = np.abs(values(actual_loss, charge) - values(baseline, charge)) > 0.5 + assert np.all(~charge_moved | (receipt_moved > 0)), ( + values(actual_loss, charge), + values(baseline, charge), + units, + ) + deductions = [ PRE_CHARGE_TAX, "national_insurance", From 96d2519d9e0d86ffa00e3e3225d104d93bfeb8ff Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 12:11:21 -0400 Subject: [PATCH 11/28] Test total income as the law defines it, and fix review r6's findings The winter fuel payment charge tests total income (ITEPA 2003 s.681I(1)(b)), which is ITA 2007 s.23 Step 1: the income charged to tax, after employment expenses, capital allowances, the trading and property allowances and exempt ISA interest, and before the Step 2 reliefs. The charge read total_income, which sums gross income. Add total_income_charged_to_tax, the Step 1 figure adjusted_total_income already worked out for the s.24A cap (the taxable components with losses brought forward added back), and have both read it. Tests that set the charge's income now set the new variable; six YAML cases pin each deduction, and the property allowance case that pinned the old gap now follows the law. r6 P1: test_income_tax_falls_with_the_loss_by_at_most_the_relief compared income_tax, which the charge can raise when the relief ends a Pension Credit award. Compare tax before the charge, check that the charge moves only with benefit receipt or total income, and pin r6's counterexample. Invariants 3 and 9 say so. Nits: the code-sharing helper's name, the deprecated limit's reference, the changed.md wording, and a pinned pensioner charged on both sides of the zero-profit test. Co-Authored-By: Claude Opus 5.5 --- .../winter-fuel-payment-charge.added.md | 5 +- .../winter-fuel-payment-charge.changed.md | 2 +- .../maximum_taxable_income.yaml | 6 +- .../relevant_benefit_receipt.yaml | 4 +- .../charges/winter_fuel_payment_charge.yaml | 218 ++++++++++++++---- .../taxable_income_test.yaml | 8 +- .../charge_parameters.yaml | 10 +- .../tests/test_trading_loss_properties.py | 85 ++++++- .../tests/test_variable_code_sharing.py | 6 +- .../test_winter_heating_payment_properties.py | 2 +- .../allowances/uses_property_allowance.py | 8 +- .../charges/winter_fuel_payment_charge.py | 17 +- .../reliefs/adjusted_total_income.py | 13 +- .../income_tax/total_income_charged_to_tax.py | 44 ++++ 14 files changed, 331 insertions(+), 97 deletions(-) create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py diff --git a/changelog.d/winter-fuel-payment-charge.added.md b/changelog.d/winter-fuel-payment-charge.added.md index 2098874cbc..aa7d0eed40 100644 --- a/changelog.d/winter-fuel-payment-charge.added.md +++ b/changelog.d/winter-fuel-payment-charge.added.md @@ -1,2 +1,3 @@ -- Add the winter fuel payment charge (`winter_fuel_payment_charge`, ITEPA 2003 s.681I, from 2025-26) with parameters under `gov.hmrc.income_tax.charges.winter_fuel_payment`. It is charged person by person: the whole Winter Fuel Payment (England, Wales and Northern Ireland) or Pension Age Winter Heating Payment, when the person's own total income exceeds £35,000 and they are not entitled to Income Support, income-based JSA, Pension Credit, income-related ESA or Universal Credit. It is added to `income_tax` after tax reductions (ITA 2007 s.23 Step 7). -- Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit, Income Support, Housing Benefit (applicable income and net earnings) and Council Tax Reduction means tests now deduct it instead of `income_tax`. The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. Universal Credit earned income and the benefit cap's earnings test already deduct only each person's own tax on earnings, which leaves out the charge. The property allowance election still compares `income_tax`, which now includes the charge: a route can change Pension Credit and so whether the charge applies. +- Add the winter fuel payment charge (`winter_fuel_payment_charge`, ITEPA 2003 s.681I, from 2025-26) with parameters under `gov.hmrc.income_tax.charges.winter_fuel_payment`. It is charged person by person: the whole Winter Fuel Payment (England, Wales and Northern Ireland) or Pension Age Winter Heating Payment, when the person's own total income (ITA 2007 s.23 Step 1) exceeds £35,000 and they are not entitled to Income Support, income-based JSA, Pension Credit, income-related ESA or Universal Credit. It is added to `income_tax` after tax reductions (ITA 2007 s.23 Step 7). +- Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit, Income Support, Housing Benefit (applicable income and net earnings) and Council Tax Reduction means tests now deduct it instead of `income_tax`. The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. Universal Credit earned income and the benefit cap's earnings test already deduct only each person's own tax on earnings, which leaves out the charge. The property allowance election still compares `income_tax`, which now includes the charge: a route can change total income and Pension Credit, and so whether the charge applies. +- Add `total_income_charged_to_tax`, total income as ITA 2007 s.23 Step 1 defines it: the model's taxable income components (after employment expenses, capital allowances, the trading and property allowances and exempt ISA interest), with losses brought forward added back. The winter fuel payment charge tests it, and `adjusted_total_income` now reads it for Step 1 instead of computing it itself. `total_income` still sums gross income. diff --git a/changelog.d/winter-fuel-payment-charge.changed.md b/changelog.d/winter-fuel-payment-charge.changed.md index d99a959352..e916202bfe 100644 --- a/changelog.d/winter-fuel-payment-charge.changed.md +++ b/changelog.d/winter-fuel-payment-charge.changed.md @@ -1 +1 @@ -- Deprecate `gov.dwp.winter_fuel_payment.eligibility.taxable_income_test`: it is off from 2025 and has effect only in a reform that requires a relevant benefit. Its limit now admits total income equal to it. Use `gov.hmrc.income_tax.charges.winter_fuel_payment` for the charge. +- Deprecate `gov.dwp.winter_fuel_payment.eligibility.taxable_income_test`: it is off in every year and has effect only in a reform that both switches it on and requires a relevant benefit. Its limit now admits total income equal to it. Use `gov.hmrc.income_tax.charges.winter_fuel_payment` for the charge. diff --git a/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/maximum_taxable_income.yaml b/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/maximum_taxable_income.yaml index 4b926ae091..8678b2f08d 100644 --- a/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/maximum_taxable_income.yaml +++ b/policyengine_uk/parameters/gov/dwp/winter_fuel_payment/eligibility/taxable_income_test/maximum_taxable_income.yaml @@ -1,7 +1,11 @@ description: Deprecated; use gov.hmrc.income_tax.charges.winter_fuel_payment.income_threshold for the winter fuel payment charge. The household income test (use_maximum_taxable_income) pays a person not on a relevant benefit if a member of their household who has reached pensionable age has total income at or under this amount. It has effect only when that test is switched on and a relevant benefit is required (require_benefits). values: 2000-01-01: 0 - 2025-01-01: 35_000 + 2025-01-01: + value: 35_000 + reference: + - title: Finance Act 2026, Sch. 10 para. 1 (ITEPA 2003 s.681I(1)(b), the charge's threshold, which this test stood in for) + href: https://www.legislation.gov.uk/ukpga/2026/11/schedule/10 metadata: unit: currency-GBP label: Winter Fuel Payment household income test limit (deprecated) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_payment/relevant_benefit_receipt.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_payment/relevant_benefit_receipt.yaml index 93ff8eb4c7..50cb2f0348 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_payment/relevant_benefit_receipt.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_payment/relevant_benefit_receipt.yaml @@ -300,7 +300,7 @@ people: pensioner: age: 70 - total_income: 50_000 + total_income_charged_to_tax: 50_000 son: age: 40 benunits: @@ -328,7 +328,7 @@ people: pensioner: age: 70 - total_income: 15_000 + total_income_charged_to_tax: 15_000 son: age: 40 benunits: diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index 73d688cb0d..367c02f523 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -12,10 +12,12 @@ # 2007 ss.23 and 30). # The payments are worked from SI 2025/969 regs 2 to 4, NISR 2025/142 regs 2 # and 3, and SSI 2024/351 reg 10 as substituted by SSI 2025/282 (amounts from -# SSI 2025/100 and SSI 2026/170). One case, marked "model, not law", pins a -# known gap instead. Entitlement to a payment, and so who counts -# as "a person entitled to a winter fuel payment" for the shared amounts, does -# not depend on the charge. Benefit-unit awards are entered directly. +# SSI 2025/100 and SSI 2026/170). Total income is ITA 2007 s.23 Step 1: the +# income charged to tax, after the deductions made in working out each kind of +# income and before the Step 2 reliefs. Entitlement to a payment, and so who +# counts as "a person entitled to a winter fuel payment" for the shared +# amounts, does not depend on the charge. Benefit-unit awards are entered +# directly. - name: 2025 total income of exactly £35,000 is not charged # s.681I(1)(b): total income must exceed £35,000. Reg. 2 and 3(1): £200. @@ -24,7 +26,7 @@ people: pensioner: age: 70 - total_income: 35_000 + total_income_charged_to_tax: 35_000 benunits: unit: members: [pensioner] @@ -45,7 +47,7 @@ people: pensioner: age: 70 - total_income: 35_001 + total_income_charged_to_tax: 35_001 benunits: unit: members: [pensioner] @@ -66,7 +68,7 @@ people: pensioner: age: 85 - total_income: 50_000 + total_income_charged_to_tax: 50_000 benunits: unit: members: [pensioner] @@ -88,10 +90,10 @@ people: partner_1: age: 70 - total_income: 50_000 + total_income_charged_to_tax: 50_000 partner_2: age: 72 - total_income: 10_000 + total_income_charged_to_tax: 10_000 benunits: couple: members: [partner_1, partner_2] @@ -113,10 +115,10 @@ people: partner_1: age: 82 - total_income: 40_000 + total_income_charged_to_tax: 40_000 partner_2: age: 85 - total_income: 40_000 + total_income_charged_to_tax: 40_000 benunits: couple: members: [partner_1, partner_2] @@ -141,10 +143,10 @@ people: high_income: age: 70 - total_income: 50_000 + total_income_charged_to_tax: 50_000 aged_85: age: 85 - total_income: 10_000 + total_income_charged_to_tax: 10_000 benunits: unit_1: members: [high_income] @@ -172,7 +174,7 @@ age: 70 lodger: age: 72 - total_income: 40_000 + total_income_charged_to_tax: 40_000 benunits: unit_1: members: [on_pension_credit] @@ -197,7 +199,7 @@ people: pensioner: age: 70 - total_income: 50_000 + total_income_charged_to_tax: 50_000 son: age: 40 benunits: @@ -227,7 +229,7 @@ age: 60 pensioner: age: 70 - total_income: 40_000 + total_income_charged_to_tax: 40_000 benunits: couple: members: [claimant, pensioner] @@ -253,7 +255,7 @@ pension_credit_reported: 1_000 partner: age: 72 - total_income: 50_000 + total_income_charged_to_tax: 50_000 benunits: couple: members: [claimant, partner] @@ -281,7 +283,7 @@ income_support_reported: 1_000 partner: age: 70 - total_income: 40_000 + total_income_charged_to_tax: 40_000 benunits: couple: members: [claimant, partner] @@ -306,7 +308,7 @@ people: pensioner: age: 70 - total_income: 20_000 + total_income_charged_to_tax: 20_000 benunits: unit: members: [pensioner] @@ -327,7 +329,7 @@ people: pensioner: age: 70 - total_income: 50_000 + total_income_charged_to_tax: 50_000 benunits: unit: members: [pensioner] @@ -349,7 +351,7 @@ people: pensioner: age: 70 - total_income: 50_000 + total_income_charged_to_tax: 50_000 benunits: unit: members: [pensioner] @@ -373,10 +375,10 @@ people: partner_1: age: 82 - total_income: 40_000 + total_income_charged_to_tax: 40_000 partner_2: age: 85 - total_income: 20_000 + total_income_charged_to_tax: 20_000 benunits: couple: members: [partner_1, partner_2] @@ -399,7 +401,7 @@ people: pensioner: age: 82 - total_income: 40_000 + total_income_charged_to_tax: 40_000 benunits: unit: members: [pensioner] @@ -421,7 +423,7 @@ people: pensioner: age: 70 - total_income: 50_000 + total_income_charged_to_tax: 50_000 benunits: unit: members: [pensioner] @@ -441,7 +443,7 @@ people: pensioner: age: 70 - total_income: 20_000 + total_income_charged_to_tax: 20_000 benunits: unit: members: [pensioner] @@ -471,7 +473,7 @@ members: [pensioner] country: ENGLAND output: - total_income: [50_000] + total_income_charged_to_tax: [50_000] income_tax_before_winter_fuel_payment_charge: [7_486] winter_fuel_payment_charge: [200] income_tax: [7_686] @@ -535,18 +537,16 @@ household_benefits: 203.40 hbai_benefits: 203.40 -- name: 2025 a landlord's total income is taken before the property allowance (model, not law) +- name: 2025 a landlord's property allowance election can keep total income under £35,000 # Private pension £32,000; property receipts £3,500, expenses £200 (profit - # £3,300), finance costs £1,500. With expenses: (35,300 - 12,570) x 20% = - # £4,546, less the finance-cost reduction 20% x £1,500 = £300, so £4,246. - # With partial relief (£1,000 allowance for £200 of expenses, no - # reduction): (34,500 - 12,570) x 20% = £4,386. The model's total_income - # is before the property allowance, £35,300 on either route, so the £200 - # charge is added on either and expenses are kept: £4,446 against £4,586. - # KNOWN GAP, pinned on purpose: under the law, partial relief puts total - # income at £34,500, under the threshold, so a landlord would elect it and - # pay £4,386 with no charge. The model does not deduct the property or - # trading allowance from total_income (see the PR's Limits). + # £3,300), finance costs £1,500. With expenses: total income £35,300, over + # £35,000, so the £200 charge applies; tax (35,300 - 12,570) x 20% = + # £4,546, less the finance-cost reduction 20% x £1,500 = £300, so £4,246, + # and £4,446 with the charge. With partial relief (the £1,000 allowance in + # place of £200 of expenses, ITTOIA 2005 s.783BH, and no finance-cost + # reduction): property profits £2,500, total income £34,500, no charge; + # tax (34,500 - 12,570) x 20% = £4,386. The pensioner elects partial + # relief. period: 2025 input: people: @@ -561,11 +561,140 @@ members: [pensioner] country: ENGLAND output: - uses_property_allowance: [false] - total_income: [35_300] - income_tax_before_winter_fuel_payment_charge: [4_246] + uses_property_allowance: [true] + total_income_charged_to_tax: [34_500] + income_tax_before_winter_fuel_payment_charge: [4_386] + winter_fuel_payment_charge: [0] + income_tax: [4_386] + +- name: 2025 employment expenses come off total income + # Employment income is net taxable earnings, after deductible expenses + # (ITEPA 2003 ss.11 and 336): 35,500 - 1,000 = £34,500, not over £35,000, + # so no charge. Tax: (34,500 - 12,570) x 20% = £4,386. + period: 2025 + input: + people: + pensioner: + age: 70 + employment_income: 35_500 + employment_expenses: 1_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + total_income_charged_to_tax: [34_500] + winter_fuel_payment: [200] + winter_fuel_payment_charge: [0] + income_tax: [4_386] + +- name: 2025 capital allowances come off total income + # Capital allowances are deducted as trading expenses (CAA 2001 s.247): + # trading profits 35,500 - 1,000 = £34,500, so no charge. Tax: + # (34,500 - 12,570) x 20% = £4,386. + period: 2025 + input: + people: + pensioner: + age: 70 + self_employment_income: 35_500 + capital_allowances: 1_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + total_income_charged_to_tax: [34_500] + winter_fuel_payment: [200] + winter_fuel_payment_charge: [0] + income_tax: [4_386] + +- name: 2025 trading income within the trading allowance is not total income + # A profit of £800 is within the £1,000 trading allowance, so it is not + # charged to tax (ITTOIA 2005 s.783AF): total income is the £34,500 + # pension, not £35,300, so no charge. Tax: (34,500 - 12,570) x 20% = + # £4,386. + period: 2025 + input: + people: + pensioner: + age: 70 + private_pension_income: 34_500 + self_employment_income: 800 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + total_income_charged_to_tax: [34_500] + winter_fuel_payment: [200] + winter_fuel_payment_charge: [0] + income_tax: [4_386] + +- name: 2025 ISA interest is not total income + # Interest of £2,000, £1,500 of it from an ISA, which is exempt (ITTOIA + # 2005 s.694): total income is 34,000 + 500 = £34,500, so no charge. Tax: + # (34,000 - 12,570) x 20% = £4,286 on the pension; the £500 of interest is + # within the £1,000 personal savings allowance. + period: 2025 + input: + people: + pensioner: + age: 70 + private_pension_income: 34_000 + savings_interest_income: 2_000 + tax_free_savings_income: 1_500 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + total_income_charged_to_tax: [34_500] + winter_fuel_payment: [200] + winter_fuel_payment_charge: [0] + income_tax: [4_286] + +- name: 2025 losses brought forward do not lower total income + # Relief for a trade loss carried forward (ITA 2007 s.83) is deducted at + # Step 2, after total income: total income is the £40,000 profit, so the + # £200 charge applies. Tax before the charge: (40,000 - 10,000 - 12,570) x + # 20% = £3,486; with the charge, £3,686. + period: 2025 + input: + people: + pensioner: + age: 70 + self_employment_income: 40_000 + loss_relief: 10_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + total_income_charged_to_tax: [40_000] winter_fuel_payment_charge: [200] - income_tax: [4_446] + income_tax_before_winter_fuel_payment_charge: [3_486] + income_tax: [3_686] - name: 2025 statutory sick pay counts towards total income # Statutory sick pay is income charged to tax, so it is part of total @@ -617,6 +746,7 @@ country: ENGLAND output: trade_loss_relief_against_general_income: [8_000] + total_income_charged_to_tax: [40_000] winter_fuel_payment_charge: [200] income_tax_before_winter_fuel_payment_charge: [3_886] income_tax: [4_086] @@ -626,7 +756,9 @@ # £3,300), finance costs £1,500; five children. With expenses: (35,820 - # 12,570) x 20% = £4,650, less the finance-cost reduction 20% x £1,500 = # £300, so £4,350. With partial relief (£1,000 allowance, no reduction): - # (35,020 - 12,570) x 20% = £4,490. Pension Credit income is £29,970 with + # (35,020 - 12,570) x 20% = £4,490. Total income is over £35,000 either + # way (£35,820 and £35,020), so Pension Credit decides the charge. Pension + # Credit income is £29,970 with # expenses and £29,830 with the allowance, against the model's minimum # guarantee of £29,893.76 (with the amounts for the children), so only the # allowance route gets Pension Credit, which exempts the pensioner from the diff --git a/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_allowance/taxable_income_test.yaml b/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_allowance/taxable_income_test.yaml index 8b30f9c78b..aecdfcf080 100644 --- a/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_allowance/taxable_income_test.yaml +++ b/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_allowance/taxable_income_test.yaml @@ -218,7 +218,7 @@ input: age: 67 country: "ENGLAND" - total_income: 50000 + private_pension_income: 50000 pension_credit: 0 income_support: 0 esa_income: 0 @@ -235,7 +235,7 @@ gov.dwp.winter_fuel_payment.eligibility.taxable_income_test.maximum_taxable_income: 20000 age: 67 country: "ENGLAND" - total_income: 50000 + private_pension_income: 50000 pension_credit: 0 income_support: 0 esa_income: 0 @@ -253,7 +253,7 @@ gov.dwp.winter_fuel_payment.eligibility.taxable_income_test.maximum_taxable_income: 20000 age: 67 country: "ENGLAND" - total_income: 50000 + private_pension_income: 50000 pension_credit: 0 income_support: 0 esa_income: 0 @@ -271,7 +271,7 @@ gov.dwp.winter_fuel_payment.eligibility.taxable_income_test.maximum_taxable_income: 20000 age: 67 country: "ENGLAND" - total_income: 15000 + private_pension_income: 15000 pension_credit: 0 income_support: 0 esa_income: 0 diff --git a/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml b/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml index 2a5c82c656..9bd9911777 100644 --- a/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml +++ b/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml @@ -7,7 +7,7 @@ gov.hmrc.income_tax.charges.winter_fuel_payment.income_threshold: 50_000 age: 70 country: ENGLAND - total_income: 45_000 + total_income_charged_to_tax: 45_000 pension_credit: 0 output: winter_fuel_payment: 200 @@ -19,7 +19,7 @@ gov.hmrc.income_tax.charges.winter_fuel_payment.income_threshold: 50_000 age: 70 country: ENGLAND - total_income: 50_001 + total_income_charged_to_tax: 50_001 pension_credit: 0 output: winter_fuel_payment: 200 @@ -31,7 +31,7 @@ gov.hmrc.income_tax.charges.winter_fuel_payment.in_effect: false age: 70 country: ENGLAND - total_income: 50_000 + total_income_charged_to_tax: 50_000 pension_credit: 0 output: winter_fuel_payment: 200 @@ -44,7 +44,7 @@ gov.hmrc.income_tax.charges.winter_fuel_payment.in_effect: true age: 70 country: ENGLAND - total_income: 50_000 + total_income_charged_to_tax: 50_000 pension_credit: 0 output: winter_fuel_payment: 200 @@ -60,7 +60,7 @@ gov.hmrc.income_tax.charges.winter_fuel_payment.in_effect: true age: 70 country: ENGLAND - total_income: 50_000 + total_income_charged_to_tax: 50_000 pension_credit: 0 tax_credits: 1_000 output: diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index fa6621c42b..dc337b6032 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -17,10 +17,15 @@ taken from non-savings income first, then savings, then dividends: the model's fixed allowance order (s.25(2) asks for the order giving the greatest reduction; #2106). -3. Income Tax never rises with the loss and falls by at most the relief (for - people without savings income: the personal savings allowance steps up by - £500 when someone stops being a higher-rate taxpayer, so with savings - income a small relief can save more than itself). +3. Income Tax before the winter fuel payment charge never rises with the + loss and falls by at most the relief (for people without savings income: + the personal savings allowance steps up by £500 when someone stops being + a higher-rate taxpayer, so with savings income a small relief can save + more than itself). The charge moves only where the loss changes the + person's receipt of a relevant benefit or their total income: relief + comes at Step 2, after total income, but lower tax can end a Pension + Credit award and with it the exemption (ITEPA 2003 s.681I(4)), and a loss + claim replaces the trading allowance. 4. Means tests never set the loss against employed earnings or other income: with Income Tax and NI held at the values the loss gives, every means-tested benefit and means-test income is what it is without the loss, @@ -45,7 +50,9 @@ pays none whatever their loss. 8. The s.24A cap starts in 2013-14. 9. A loss-making trade, compared with the same trade making zero profit and - no loss, never increases Income Tax. With paid tax and NI deductions and + no loss, never increases Income Tax before the winter fuel payment + charge, and the charge moves only where the loss changes who in the + household is on a relevant benefit. With paid tax and NI deductions and other benefit receipts held consistently, it never reduces UC, Housing Benefit or tax credits. This isolates each programme's loss rule: the model's annual tax deductions and changes to other benefits can otherwise @@ -386,8 +393,22 @@ def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): ), (person, a[tax][i], b[tax][i]) +# A pensioner with a tiny Pension Credit award: Pension Credit income +# deducts tax but leaves out dividends, so with total income over £35,000 the +# award survives until the loss cuts the tax. In 2026 the relief ends it, and +# with it the exemption from the winter fuel payment charge, so Income Tax +# with the charge rises with the loss although total income does not move. +PINNED_CHARGE_TAXPAYER = taxpayer( + age=80, + private_pension_income=19_200, + dividend_income=30_000, + trading_loss=1_100, +) + + @PROPERTY_SETTINGS @example(PINNED_TAXPAYERS, PINNED_REGIONS, 0.5) +@example([PINNED_CHARGE_TAXPAYER] * 8, ["LONDON"] * 16, 0.5) @given( st.lists(taxpayers(), min_size=8, max_size=16), st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), @@ -396,18 +417,46 @@ def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): def test_income_tax_falls_with_the_loss_by_at_most_the_relief(people, regions, share): smaller = [{**p, "trading_loss": p["trading_loss"] * share} for p in people] without = [{**p, "trading_loss": 0.0} for p in people] - variables = ["income_tax", "trade_loss_relief_against_general_income"] + charge = "winter_fuel_payment_charge" + total_income = "total_income_charged_to_tax" + variables = [ + PRE_CHARGE_TAX, + "trade_loss_relief_against_general_income", + charge, + total_income, + *RELEVANT_BENEFITS, + ] a = calculate(single_people(people, regions), variables) s = calculate(single_people(smaller, regions), variables) b = calculate(single_people(without, regions), variables) + + def on_relevant_benefit(result, i): + return any(result[v][i] > 0 for v in RELEVANT_BENEFITS) + for i, person in enumerate(people): - tol = tolerance(b["income_tax"][i], person["employment_income"]) - assert a["income_tax"][i] <= s["income_tax"][i] + tol, person - assert s["income_tax"][i] <= b["income_tax"][i] + tol, person + tol = tolerance(b[PRE_CHARGE_TAX][i], person["employment_income"]) + assert a[PRE_CHARGE_TAX][i] <= s[PRE_CHARGE_TAX][i] + tol, person + assert s[PRE_CHARGE_TAX][i] <= b[PRE_CHARGE_TAX][i] + tol, person if person["savings_interest_income"] == 0: - saved = b["income_tax"][i] - a["income_tax"][i] + saved = b[PRE_CHARGE_TAX][i] - a[PRE_CHARGE_TAX][i] relief = a["trade_loss_relief_against_general_income"][i] assert saved <= relief + tol, (person, saved, relief) + # Each person is alone in their household, so their payment, and + # with it the charge, follows only their own benefit receipt and + # total income. + for result in (a, s): + if abs(result[charge][i] - b[charge][i]) > 0.5: + receipt_moved = on_relevant_benefit(result, i) != ( + on_relevant_benefit(b, i) + ) + total_income_moved = abs( + result[total_income][i] - b[total_income][i] + ) > tolerance(b[total_income][i]) + assert receipt_moved or total_income_moved, ( + person, + result[charge][i], + b[charge][i], + ) @st.composite @@ -543,7 +592,10 @@ def home(rent, region="LONDON"): # A pensioner with five children on Pension Credit and so not charged. In # 2026 the loss cuts tax by £1,000, which ends Pension Credit and with it the -# exemption from the winter fuel payment charge (ITEPA 2003 s.681I(4)). +# exemption from the winter fuel payment charge (ITEPA 2003 s.681I(4)). The +# second pensioner is charged with or without the loss: total income of +# £40,000 is found before trade loss relief, and the loss leaves them off +# Pension Credit. PINNED_PENSIONER = [ ( [ @@ -558,6 +610,17 @@ def home(rent, region="LONDON"): + [{"age": 10, "trading_loss": 0.0} for _ in range(5)], home(9_000.0, "NORTH_EAST"), ), + ( + [ + adult( + age=70, + private_pension_income=40_000.0, + trading_loss=10_000.0, + hours_worked=0.0, + ) + ], + home(9_000.0), + ), ] diff --git a/policyengine_uk/tests/test_variable_code_sharing.py b/policyengine_uk/tests/test_variable_code_sharing.py index f8850d31ee..a775bd7b4a 100644 --- a/policyengine_uk/tests/test_variable_code_sharing.py +++ b/policyengine_uk/tests/test_variable_code_sharing.py @@ -58,7 +58,7 @@ def _skip_if_core_shares(core_shares_code): ) -def _income_tax_formula(simulation): +def _formula(simulation): variable = simulation.tax_benefit_system.variables[FORMULA_VARIABLE] return variable.get_formula() @@ -84,8 +84,8 @@ def test_systems_share_code_but_not_modules_classes_or_functions(): second = Simulation(situation=SITUATION) first_class = type(first.tax_benefit_system.variables[FORMULA_VARIABLE]) second_class = type(second.tax_benefit_system.variables[FORMULA_VARIABLE]) - first_formula = _income_tax_formula(first) - second_formula = _income_tax_formula(second) + first_formula = _formula(first) + second_formula = _formula(second) assert first_class is not second_class assert ( diff --git a/policyengine_uk/tests/test_winter_heating_payment_properties.py b/policyengine_uk/tests/test_winter_heating_payment_properties.py index 996333a346..6634aa58d2 100644 --- a/policyengine_uk/tests/test_winter_heating_payment_properties.py +++ b/policyengine_uk/tests/test_winter_heating_payment_properties.py @@ -202,7 +202,7 @@ def situation(drawn, year): for name, (inputs, _, cp) in zip(names, members): person = { "age": {year: inputs["age"]}, - "total_income": {year: inputs["total_income"]}, + "total_income_charged_to_tax": {year: inputs["total_income"]}, "is_claimant_or_partner": {year: cp}, "is_benunit_head": {year: bool(inputs.get("head", False))}, } diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py index cc7a7c2196..93f478df53 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py @@ -22,10 +22,10 @@ def income_tax_with_route(simulation, name, period, uses_allowance): """Each person's income tax with the given choices of property allowance. - The tax includes the winter fuel payment charge. The model's total_income - is before the property allowance, so it is the same on either route, but - a route can change the person's Pension Credit and so whether the charge - applies (s.681I(4)). The branch shares the simulation's cached arrays + The tax includes the winter fuel payment charge. A route can change the + person's total income, which is after the property allowance, and their + Pension Credit, and so whether the charge applies (s.681I(1)(b) and (4)). + The branch shares the simulation's cached arrays until it writes, and is dropped afterwards so a later period starts from a fresh branch. """ diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py b/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py index e3c2b10300..f71830a0a1 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py @@ -17,9 +17,9 @@ class winter_fuel_payment_charge(Variable): "The charge to income tax that recovers a person's winter fuel " "payment: the whole payment, when the person's own total income for " "the tax year exceeds the threshold and they are not entitled to a " - "relevant benefit in the qualifying week. Total income includes the " - "statutory sick and maternity pay the model taxes as employment " - "income. It is additional tax at " + "relevant benefit in the qualifying week. Total income is the income " + "charged to tax before the Step 2 reliefs (total_income_charged_to_tax). " + "It is additional tax at " "Step 7 of the income tax calculation, so tax reductions do not " "offset it. The payment itself, and who counts as another entitled " "person for the shared amounts, are unaffected by the charge." @@ -35,13 +35,10 @@ class winter_fuel_payment_charge(Variable): def formula(person, period, parameters): p = parameters(period).gov.hmrc.income_tax.charges.winter_fuel_payment payment = add(person, period, WINTER_FUEL_PAYMENTS) - # Total income is the income charged to tax (ITA 2007 s.23 Step 1). - # The model taxes statutory sick and maternity pay as employment - # income (employment_benefits, in taxable_employment_income), but - # total_income leaves them out, so they are added here. - total_income = person("total_income", period) + person( - "employment_benefits", period - ) + # s.681I(1)(b) tests total income (ITA 2007 s.23 Step 1), not gross + # income: it is after employment expenses, capital allowances and the + # trading and property allowances, and before trade loss relief. + total_income = person("total_income_charged_to_tax", period) over_threshold = total_income > p.income_threshold on_relevant_benefit = add(person, period, p.relevant_benefits) > 0 return p.in_effect * payment * over_threshold * ~on_relevant_benefit diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py index 959562f123..3ff06b68fa 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py @@ -7,9 +7,8 @@ class adjusted_total_income(Variable): label = "Adjusted total income" documentation = ( "The base of the cap on reliefs against general income (ITA 2007 " - "s.24A(8)). Step 1 is total income (s.23 Step 1): the taxable income " - "components, with losses brought forward (loss_relief, a Step 2 " - "relief the model deducts from trading profits) added back. Steps 3 " + "s.24A(8)). Step 1 is total income (s.23 Step 1, " + "total_income_charged_to_tax). Steps 3 " "and 4 deduct the pension contributions given relief (FA 2004 " "ss.192-194): the person's own contributions up to the greater of " "their relevant UK earnings (s.189(2): taxable employment income, " @@ -43,13 +42,7 @@ def formula(person, period, parameters): profits_before_carry_forward = max_( 0, profits - add(person, period, other_deductions) ) - losses_brought_forward_deducted = profits_before_carry_forward - person( - "taxable_self_employment_income", period - ) - total_income = ( - person("net_income_before_trade_loss_relief", period) - + losses_brought_forward_deducted - ) + total_income = person("total_income_charged_to_tax", period) # Relevant UK earnings (FA 2004 s.189(2)): employment income chargeable # to tax, including taxable benefits and statutory payments, and # trading profits chargeable to tax. diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py new file mode 100644 index 0000000000..4bf41d99dc --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py @@ -0,0 +1,44 @@ +from policyengine_uk.model_api import * + + +class total_income_charged_to_tax(Variable): + value_type = float + entity = Person + label = "Total income charged to Income Tax" + documentation = ( + "Total income as ITA 2007 s.23 Step 1 defines it: the sum of the " + "amounts of income on which the person is charged to Income Tax, " + "before the Step 2 reliefs. These are the model's taxable income " + "components, which already make the deductions the law makes in " + "working out each kind of income: employment expenses, capital " + "allowances, the trading and property allowances, and exempt ISA " + "interest. Trade losses brought forward (loss_relief) are a Step 2 " + "relief that the model deducts from trading profits, so they are " + "added back. Unlike total_income, which sums gross income, this is " + "the figure the law tests." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Income Tax Act 2007 s. 23", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.income_tax + profits = person("self_employment_income", period) + other_deductions = [ + deduction + for deduction in p.bases.taxable_self_employment_income_deductions + if deduction != "loss_relief" + ] + profits_before_carry_forward = max_( + 0, profits - add(person, period, other_deductions) + ) + losses_brought_forward_deducted = profits_before_carry_forward - person( + "taxable_self_employment_income", period + ) + return ( + person("net_income_before_trade_loss_relief", period) + + losses_brought_forward_deducted + ) From 26ff32421268cba66393a73e757f2c3552076429 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 16:31:56 -0400 Subject: [PATCH 12/28] Compare property routes with the charge for every landlord, one per household Review r7 found two ways the property allowance election could choose the route with the higher income tax once the winter fuel payment charge is in it: - Without finance costs at stake, the allowance was taken whenever it lowered taxable property income. Lower tax before the charge can raise the income Pension Credit counts, end the award and with it the s.681I(4) exemption, and the charge can exceed the saving. While the charge has effect, these landlords now compare both routes too; ties keep the allowance, so anyone the charge does not reach gets the answer the shortcut gave. - Landlords decided one per benefit unit, so landlords in separate benefit units of one household switched together. A couple's Pension Credit decides whether the partner is entitled to a payment, which moves another benefit unit's shared payment and charge. Landlords now decide one per household. Pins both counterexamples in YAML and adds a Hypothesis property: no landlord in a pensioner household lowers their own income tax, charge included, by switching route alone. The couples property now covers every landlord the allowance would help. Also qualifies the trading loss invariants (they hold tax before the charge) and total_income_charged_to_tax's description. Co-Authored-By: Claude Opus 5.5 --- .../winter-fuel-payment-charge.added.md | 2 +- .../charges/winter_fuel_payment_charge.yaml | 130 +++++++++++++ .../tests/test_property_income_properties.py | 174 +++++++++++++++++- .../tests/test_trading_loss_properties.py | 17 +- .../allowances/uses_property_allowance.py | 59 +++--- .../income_tax/total_income_charged_to_tax.py | 5 +- 6 files changed, 349 insertions(+), 38 deletions(-) diff --git a/changelog.d/winter-fuel-payment-charge.added.md b/changelog.d/winter-fuel-payment-charge.added.md index aa7d0eed40..bc00599747 100644 --- a/changelog.d/winter-fuel-payment-charge.added.md +++ b/changelog.d/winter-fuel-payment-charge.added.md @@ -1,3 +1,3 @@ - Add the winter fuel payment charge (`winter_fuel_payment_charge`, ITEPA 2003 s.681I, from 2025-26) with parameters under `gov.hmrc.income_tax.charges.winter_fuel_payment`. It is charged person by person: the whole Winter Fuel Payment (England, Wales and Northern Ireland) or Pension Age Winter Heating Payment, when the person's own total income (ITA 2007 s.23 Step 1) exceeds £35,000 and they are not entitled to Income Support, income-based JSA, Pension Credit, income-related ESA or Universal Credit. It is added to `income_tax` after tax reductions (ITA 2007 s.23 Step 7). -- Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit, Income Support, Housing Benefit (applicable income and net earnings) and Council Tax Reduction means tests now deduct it instead of `income_tax`. The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. Universal Credit earned income and the benefit cap's earnings test already deduct only each person's own tax on earnings, which leaves out the charge. The property allowance election still compares `income_tax`, which now includes the charge: a route can change total income and Pension Credit, and so whether the charge applies. +- Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit, Income Support, Housing Benefit (applicable income and net earnings) and Council Tax Reduction means tests now deduct it instead of `income_tax`. The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. Universal Credit earned income and the benefit cap's earnings test already deduct only each person's own tax on earnings, which leaves out the charge. The property allowance election still compares `income_tax`, which now includes the charge: a route can change total income and Pension Credit, and so whether the charge applies. While the charge has effect, the election compares tax on both routes even for landlords without finance costs, and landlords decide one per household rather than one per benefit unit, because a couple's Pension Credit can change another benefit unit's shared payment and so its charge. - Add `total_income_charged_to_tax`, total income as ITA 2007 s.23 Step 1 defines it: the model's taxable income components (after employment expenses, capital allowances, the trading and property allowances and exempt ISA interest), with losses brought forward added back. The winter fuel payment charge tests it, and `adjusted_total_income` now reads it for Step 1 instead of computing it itself. `total_income` still sums gross income. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index 367c02f523..83d287aae7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -799,3 +799,133 @@ winter_fuel_payment_charge: [0, 0, 0, 0, 0, 0] income_tax_before_winter_fuel_payment_charge: [4_490, 0, 0, 0, 0, 0] income_tax: [4_490, 0, 0, 0, 0, 0] + +- name: 2025 a landlord without finance costs keeps expenses when the allowance would end Pension Credit + # Private pension £35,207; property receipts £3,500, expenses £200 (profit + # £3,300), no finance costs; six children. With partial relief (the £1,000 + # allowance in place of £200 of expenses): total income £37,707, tax + # (37,707 - 12,570) x 20% = £5,027.40. With expenses: total income £38,507, + # tax (38,507 - 12,570) x 20% = £5,187.40. Total income is over £35,000 + # either way, so Pension Credit decides the charge. Pension Credit income + # (pension and property profits, less tax before the charge) is £33,479.60 + # with the allowance and £33,319.60 with expenses, against the model's + # minimum guarantee of £33,399.60 (with the amounts for the children), so + # only the expenses route gets Pension Credit (£80), which exempts the + # pensioner from the £200 charge (s.681I(4)). Total tax: £5,227.40 with the + # allowance, £5,187.40 with expenses, so the pensioner does not elect for + # partial relief. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 35_207 + property_rental_income: 3_500 + property_income: 3_300 + child_1: + age: 10 + child_2: + age: 10 + child_3: + age: 10 + child_4: + age: 10 + child_5: + age: 10 + child_6: + age: 10 + benunits: + unit: + members: [pensioner, child_1, child_2, child_3, child_4, child_5, child_6] + would_claim_pc: true + would_claim_uc: false + households: + household: + members: [pensioner, child_1, child_2, child_3, child_4, child_5, child_6] + country: ENGLAND + output: + uses_property_allowance: [false, false, false, false, false, false, false] + total_income_charged_to_tax: [38_507, 0, 0, 0, 0, 0, 0] + pension_credit_income: 33_319.60 + pension_credit: 80 + winter_fuel_payment: [200, 0, 0, 0, 0, 0, 0] + winter_fuel_payment_charge: [0, 0, 0, 0, 0, 0, 0] + income_tax: [5_187.40, 0, 0, 0, 0, 0, 0] + +- name: 2025 landlords in two benefit units of one household each elect with the other's route fixed + # One household. Unit A: a claimant aged 70 with private pension + # £42,772.55, a partner aged 85 with no income, and five children. Unit B: + # an unrelated person aged 85 with private pension £40,000. Each landlord + # has property receipts £3,500 and expenses £200 (profit £3,300); finance + # costs are £1,200 (A) and £675 (B). Unit A claims Pension Credit; B does + # not. + # A: with partial relief, total income £45,272.55 and tax before the charge + # (45,272.55 - 12,570) x 20% = £6,540.51, less the Marriage Allowance + # reduction 20% x £1,260 = £252, so £6,288.51. Unit A then gets Pension + # Credit (a savings credit of £16 in the model), which exempts A from the + # charge and makes A the person paid for the couple, so A's partner is not + # entitled (SI 2025/969 reg 4(2)(a)); A is paid the couple's full £300. + # With expenses, A's tax before the charge is £6,700.51 - £252 - 20% x + # £1,200 = £6,208.51, unit A gets no Pension Credit, and A's £100 shared + # payment is charged: £6,308.51. A elects for partial relief. + # B: tax before the charge is (42,500 - 12,570) x 20% = £5,986 with the + # allowance and (43,300 - 12,570) x 20% - 20% x £675 = £6,011 with + # expenses. B's total income is over £35,000 on both routes, so B is + # charged the payment: £200 while B lives with one other entitled person, + # A, who is under 80, and £150 when A's partner is entitled too (SI + # 2025/969 reg 3(5) and (6)). With A's election held fixed, B is charged + # £200 on both routes, so B elects for partial relief: £5,986 + £200 = + # £6,186, against £6,011 + £200 = £6,211 with expenses. Switching both + # landlords together would set £6,186 against £6,011 + £150 = £6,161 and + # wrongly keep B on expenses. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + is_benunit_head: true + private_pension_income: 42_772.55 + property_rental_income: 3_500 + property_income: 3_300 + property_finance_costs: 1_200 + partner: + age: 85 + is_benunit_head: false + lodger: + age: 85 + private_pension_income: 40_000 + property_rental_income: 3_500 + property_income: 3_300 + property_finance_costs: 675 + child_1: + age: 10 + child_2: + age: 10 + child_3: + age: 10 + child_4: + age: 10 + child_5: + age: 10 + benunits: + unit_a: + members: [claimant, partner, child_1, child_2, child_3, child_4, child_5] + would_claim_pc: true + would_claim_uc: false + unit_b: + members: [lodger] + would_claim_pc: false + would_claim_uc: false + households: + household: + members: [claimant, partner, lodger, child_1, child_2, child_3, child_4, child_5] + country: ENGLAND + output: + uses_property_allowance: [true, false, true, false, false, false, false, false] + income_tax_before_winter_fuel_payment_charge: [6_288.51, 0, 5_986, 0, 0, 0, 0, 0] + pension_credit: [16, 0] + winter_fuel_payment: [300, 0, 200, 0, 0, 0, 0, 0] + winter_fuel_payment_charge: [0, 0, 200, 0, 0, 0, 0, 0] + income_tax: [6_288.51, 0, 6_186, 0, 0, 0, 0, 0] diff --git a/policyengine_uk/tests/test_property_income_properties.py b/policyengine_uk/tests/test_property_income_properties.py index b4b1a59d61..09ebdf6f29 100644 --- a/policyengine_uk/tests/test_property_income_properties.py +++ b/policyengine_uk/tests/test_property_income_properties.py @@ -13,7 +13,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 @@ -346,12 +346,9 @@ def test_no_one_lowers_their_tax_by_switching_route_alone(couple_sim, drawn): set_couple_inputs(couple_sim, drawn) routes = couple_sim.calculate("uses_property_allowance", YEAR) income_tax = couple_sim.calculate("income_tax", YEAR).astype(float) - profit = couple_sim.calculate("property_income", YEAR).astype(float) - choosing = ( - (couple_sim.calculate("property_allowance_deduction_if_used", YEAR) > 0) - & (couple_sim.calculate("property_finance_costs_relievable", YEAR) > 0) - & (profit > 0) - ) + # While the winter fuel payment charge has effect (YEAR is after + # 2025-26), everyone the allowance would help chooses by comparing tax. + choosing = couple_sim.calculate("property_allowance_deduction_if_used", YEAR) > 0 position = np.arange(2 * COUPLES) % 2 for k in range(2): switching = choosing & (position == k) @@ -361,3 +358,166 @@ def test_no_one_lowers_their_tax_by_switching_route_alone(couple_sim, drawn): switched_tax = couple_sim.calculate("income_tax", YEAR).astype(float) margin = tolerance(income_tax, switched_tax) assert (switched_tax[switching] >= (income_tax - margin)[switching]).all() + + +# Pensioner households, while the winter fuel payment charge has effect: a +# route can move Pension Credit, and with it the exemption from the charge +# (ITEPA 2003 s.681I(4)), even without finance costs at stake; and a couple's +# Pension Credit decides whether the partner is entitled to a payment, which +# moves the shared payment, and so the charge, of anyone else in the +# household. Each landlord must still choose the route that gives them the +# lower income tax including the charge, with every other route held fixed. +PENSIONER_YEAR = 2025 +PENSIONER_HOUSEHOLDS = 6 +PENSIONER_REGIONS = ["LONDON", "LONDON", "SCOTLAND", "WALES", "LONDON", "SCOTLAND"] +CHILDREN = 5 +# A "partner" aged 10 is a sixth child, and a lodger aged 40 is not entitled +# to a payment, so the households also cover a lone pensioner. +pensioner_landlord = st.tuples( + st.one_of( + st.floats(min_value=0, max_value=60_000), + st.sampled_from([11_500.0, 32_000.0, 34_500.0, 35_207.0, 42_772.55]), + ), # private pension + st.sampled_from([0.0, 0.0, 30_000.0]), # dividends + st.one_of(amount, st.sampled_from([3_300.0])), # profit + st.floats(min_value=0, max_value=950), # expenses + st.sampled_from([0.0, 0.0, 675.0, 1_200.0, 1_500.0]), # finance costs +) +pensioner_household = st.tuples( + st.sampled_from([66, 70, 80, 85]), # claimant's age + st.sampled_from([10, 66, 70, 85]), # partner's age + st.sampled_from([40, 66, 70, 85]), # lodger's age + pensioner_landlord, # claimant + pensioner_landlord, # partner + pensioner_landlord, # lodger + st.booleans(), # the lodger claims Pension Credit +) +pensioner_households = st.lists( + pensioner_household, + min_size=PENSIONER_HOUSEHOLDS, + max_size=PENSIONER_HOUSEHOLDS, +) +NO_INCOME = (0.0, 0.0, 0.0, 0.0, 0.0) +# Review r7's cases (2026-10-16). A pensioner with six children and no +# finance costs: the allowance lowers tax before the charge by £160, which +# ends Pension Credit of £80 and so the exemption from the £200 charge. +LONE_PENSIONER = ( + 70, + 10, + 40, + (35_207.0, 0.0, 3_300.0, 200.0, 0.0), + NO_INCOME, + NO_INCOME, + False, +) +# A couple's Pension Credit, through the claimant's election, takes the +# partner out of entitlement and raises the lodger's shared payment and +# charge from £150 to £200; the lodger's own election must hold that fixed. +SHARED_PAYMENT = ( + 70, + 85, + 85, + (42_772.55, 0.0, 3_300.0, 200.0, 1_200.0), + NO_INCOME, + (40_000.0, 0.0, 3_300.0, 200.0, 675.0), + False, +) +PENSIONER_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +ROLES = ["claimant", "partner", "lodger"] + + +@pytest.fixture(scope="module") +def pensioner_sim(): + def person(age): + return { + "age": {PENSIONER_YEAR: age}, + "private_pension_income": {PENSIONER_YEAR: 0}, + "dividend_income": {PENSIONER_YEAR: 0}, + "property_income": {PENSIONER_YEAR: 0}, + "property_rental_income": {PENSIONER_YEAR: 0}, + "property_finance_costs": {PENSIONER_YEAR: 0}, + } + + people, benunits, households = {}, {}, {} + for h in range(PENSIONER_HOUSEHOLDS): + names = [f"{role}_{h}" for role in ROLES] + children = [f"child_{h}_{c}" for c in range(CHILDREN)] + for name in names: + people[name] = person(70) + for name in children: + people[name] = person(10) + people[names[0]]["is_benunit_head"] = {PENSIONER_YEAR: True} + people[names[1]]["is_benunit_head"] = {PENSIONER_YEAR: False} + benunits[f"couple_{h}"] = { + "members": names[:2] + children, + "would_claim_pc": {PENSIONER_YEAR: True}, + "would_claim_uc": {PENSIONER_YEAR: False}, + } + benunits[f"lodger_{h}"] = { + "members": names[2:], + "would_claim_pc": {PENSIONER_YEAR: True}, + "would_claim_uc": {PENSIONER_YEAR: False}, + } + households[f"household_{h}"] = { + "members": names + children, + "region": {PENSIONER_YEAR: PENSIONER_REGIONS[h]}, + } + return Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + + +def set_pensioner_inputs(sim, drawn, uses_allowance=None): + ages, incomes, claims_pc = [], [], [] + for claimant_age, partner_age, lodger_age, *landlords, claims in drawn: + household_ages = [claimant_age, partner_age, lodger_age] + ages += household_ages + [10] * CHILDREN + incomes += [ + landlord if age >= 18 else NO_INCOME + for age, landlord in zip(household_ages, landlords) + ] + [NO_INCOME] * CHILDREN + claims_pc += [True, claims] + pension, dividends, profit, expenses, costs = map(np.array, zip(*incomes)) + sim.set_input("age", PENSIONER_YEAR, np.array(ages, dtype=float)) + sim.set_input("private_pension_income", PENSIONER_YEAR, pension) + sim.set_input("dividend_income", PENSIONER_YEAR, dividends) + sim.set_input("property_income", PENSIONER_YEAR, profit) + sim.set_input("property_rental_income", PENSIONER_YEAR, profit + expenses) + sim.set_input("property_finance_costs", PENSIONER_YEAR, costs) + sim.set_input("would_claim_pc", PENSIONER_YEAR, np.array(claims_pc)) + sim.reset_calculations() + if uses_allowance is not None: + sim.set_input("uses_property_allowance", PENSIONER_YEAR, uses_allowance) + + +@PENSIONER_SETTINGS +@example([LONE_PENSIONER, SHARED_PAYMENT] * (PENSIONER_HOUSEHOLDS // 2)) +@given(pensioner_households) +def test_no_pensioner_lowers_their_tax_and_charge_by_switching_alone( + pensioner_sim, drawn +): + sim = pensioner_sim + set_pensioner_inputs(sim, drawn) + routes = sim.calculate("uses_property_allowance", PENSIONER_YEAR) + income_tax = sim.calculate("income_tax", PENSIONER_YEAR).astype(float) + choosing = sim.calculate("property_allowance_deduction_if_used", PENSIONER_YEAR) > 0 + position = np.arange(len(routes)) % (len(ROLES) + CHILDREN) + for k in range(len(ROLES)): + # One landlord per household switches; households do not interact. + switching = choosing & (position == k) + if not switching.any(): + continue + set_pensioner_inputs(sim, drawn, routes ^ switching) + switched_tax = sim.calculate("income_tax", PENSIONER_YEAR).astype(float) + margin = tolerance(income_tax, switched_tax) + assert (switched_tax[switching] >= (income_tax - margin)[switching]).all(), ( + ROLES[k], + np.flatnonzero(switching), + income_tax[switching], + switched_tax[switching], + ) diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index dc337b6032..9eea93f0d6 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -12,11 +12,13 @@ without the loss (a separate simulation); adjusted total income is that less the pension contributions given relief (own contributions up to the greater of £3,600 and pay plus profits, none from age 75). -2. Differential: a person's Income Tax with a loss equals the Income Tax of - the same person without the loss and with their income cut by the relief, - taken from non-savings income first, then savings, then dividends: the - model's fixed allowance order (s.25(2) asks for the order giving the - greatest reduction; #2106). +2. Differential: a person's Income Tax before the winter fuel payment + charge with a loss equals the same tax of the same person without the + loss and with their income cut by the relief, taken from non-savings + income first, then savings, then dividends: the model's fixed allowance + order (s.25(2) asks for the order giving the greatest reduction; #2106). + The charge itself can differ between the two: it tests total income, + which the relief, at Step 2, does not lower but the cut does. 3. Income Tax before the winter fuel payment charge never rises with the loss and falls by at most the relief (for people without savings income: the personal savings allowance steps up by £500 when someone stops being @@ -27,14 +29,15 @@ Credit award and with it the exemption (ITEPA 2003 s.681I(4)), and a loss claim replaces the trading allowance. 4. Means tests never set the loss against employed earnings or other - income: with Income Tax and NI held at the values the loss gives, every + income: with Income Tax before the winter fuel payment charge (what the + means tests deduct) and NI held at the values the loss gives, every means-tested benefit and means-test income is what it is without the loss, except that UC sets it against the person's other trades' profits (UC Regs 2013 reg 57(2)), so UC, its benefit cap earnings test and the capped award equal the same family with those profits cut by the loss. The model's means tests use annual tax deductions, so otherwise the loss reaches them through those deductions. UC's own deductions for tax and - NI on earnings are held alongside Income Tax and NI. + NI on earnings are held alongside them. 5. Tax credits (SI 2002/2006 reg 3(1) Step 4): the claimants' losses (not a child's) come off their applicable income, never below zero, and the award never falls. diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py index 93f478df53..c5860ee34d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py @@ -13,9 +13,10 @@ "pays_scottish_income_tax", "other_tax_credits", ] -# Rounds of decisions within a benefit unit before the routes are taken as -# settled. Partners' routes interact only through small amounts (such as the -# Marriage Allowance), so a second round rarely changes anything. +# Rounds of decisions within a household before the routes are taken as +# settled. Members' routes interact only through small amounts (such as the +# Marriage Allowance, or a shared winter fuel payment and so its charge), so +# a second round rarely changes anything. MAX_ROUNDS = 3 @@ -55,7 +56,11 @@ class uses_property_allowance(Variable): "Ties keep the default, so a person with receipts within the " "allowance keeps full relief even where electing out would carry " "finance costs forward. Without finance costs to relieve, the " - "allowance is used whenever it lowers taxable property income." + "default is the allowance whenever it lowers taxable property " + "income. Before the winter fuel payment charge that default is " + "always the lower tax, so it is taken without comparing. While the " + "charge has effect, lower tax before the charge can end an award " + "that exempts the person from it, so the routes are compared." ) definition_period = YEAR reference = [ @@ -87,29 +92,39 @@ def formula(person, period, parameters): reduction_at_stake = ( person("property_finance_costs_relievable", period) > 0 ) & (profit > 0) - # Without a reduction at stake, the allowance is used whenever it - # lowers taxable property income. Otherwise compare income tax on - # each route. - uses = lowers_income & ~reduction_at_stake - choosing = lowers_income & reduction_at_stake + # Without a reduction at stake, lower taxable property income means + # lower tax before the winter fuel payment charge. The charge can + # reverse that: lower tax can raise income that Pension Credit + # counts, end the award and with it the exemption (s.681I(4)), and + # the charge can exceed the saving. So while it has effect, compare + # income tax on each route for everyone the allowance would help. + charge_in_effect = parameters( + period + ).gov.hmrc.income_tax.charges.winter_fuel_payment.in_effect + compared = reduction_at_stake | charge_in_effect + uses = lowers_income & ~compared + choosing = lowers_income & compared if not choosing.any(): # Nobody has a choice to weigh: skip the branches. return uses - # Full relief applies unless electing out of it (s. 783BJ) lowers - # tax; partial relief applies only if electing for it (s. 783BK) - # lowers tax. Until a person decides, they keep that default. + # With a reduction at stake, full relief applies unless electing out + # of it (s. 783BJ) lowers tax, and partial relief applies only if + # electing for it (s. 783BK) lowers tax. Without one, the allowance + # is the default. Until a person decides, they keep their default. full_relief = person("property_receipts_within_allowance", period) - route = where(choosing, full_relief, uses) + default = full_relief | ~reduction_at_stake + route = where(choosing, default, uses) for variable in INDEPENDENT_OF_ROUTE: person(variable, period) simulation = person.simulation - # One person per benefit unit decides at a time, with everyone - # else's route held fixed: a partner's route can move this person's - # tax (through the Marriage Allowance, for example), and comparing - # both partners' switches at once would count that. Rounds repeat - # until nobody changes. + # One person per household decides at a time, with everyone else's + # route held fixed: another member's route can move this person's + # tax (a partner's through the Marriage Allowance; anyone's, through + # the shared winter fuel payment, when it changes who is entitled), + # and comparing their switches at once would count that. Rounds + # repeat until nobody changes. order = person.get_rank( - person.benunit, np.zeros(person.count), condition=choosing + person.household, np.zeros(person.count), condition=choosing ) for _ in range(MAX_ROUNDS): changed = False @@ -127,12 +142,12 @@ def formula(person, period, parameters): # Tax is computed to the penny; smaller differences are # rounding. saving = np.round(tax_not_using - tax_using, 2) - prefers_allowance = where(full_relief, saving >= 0, saving > 0) + prefers_allowance = where(default, saving >= 0, saving > 0) decided = where(deciding, prefers_allowance, route) changed = changed or bool((decided != route).any()) route = decided - # With one person deciding per benefit unit, everyone else's - # route was fixed throughout, so one round settles it. + # With one person deciding per household, everyone else's route + # was fixed throughout, so one round settles it. if order.max() == 0 or not changed: break return route diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py index 4bf41d99dc..3f74d9259a 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py @@ -15,7 +15,10 @@ class total_income_charged_to_tax(Variable): "interest. Trade losses brought forward (loss_relief) are a Step 2 " "relief that the model deducts from trading profits, so they are " "added back. Unlike total_income, which sums gross income, this is " - "the figure the law tests." + "the figure the law tests, as far as the model's components go: " + "where a component departs from the law, so does this total. For " + "example, the model deducts deficiency relief from dividend income, " + "and does not yet tax statutory paternity pay." ) definition_period = YEAR unit = GBP From 95ae26c0b0b81894b91f983b0f031a7bc7db646b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Oct 2026 11:07:53 -0400 Subject: [PATCH 13/28] State when the property allowance election compares tax, and its round limit Review r8: the opening claim that every election lowers tax holds only with finance costs to relieve; without them the allowance is the default and ties keep it. The household loop stops after MAX_ROUNDS even if routes are still changing. Co-Authored-By: Claude Opus 5.5 --- .../income_tax/allowances/uses_property_allowance.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py index c5860ee34d..d77c410a69 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py @@ -50,9 +50,10 @@ class uses_property_allowance(Variable): "than with actual expenses and the finance-cost tax reduction. The " "two cannot be combined. Full relief applies to receipts within the " "allowance unless the person elects out of it, and partial relief " - "only if they elect for it; each election is made only if it lowers " - "the person's own income tax, including the winter fuel payment " - "charge, with everyone else's route held fixed. " + "only if they elect for it; with finance costs to relieve, each " + "election is made only if it lowers the person's own income tax, " + "including the winter fuel payment charge, with everyone else's " + "route held fixed. " "Ties keep the default, so a person with receipts within the " "allowance keeps full relief even where electing out would carry " "finance costs forward. Without finance costs to relieve, the " @@ -122,7 +123,8 @@ def formula(person, period, parameters): # tax (a partner's through the Marriage Allowance; anyone's, through # the shared winter fuel payment, when it changes who is entitled), # and comparing their switches at once would count that. Rounds - # repeat until nobody changes. + # repeat until nobody changes, for at most MAX_ROUNDS: routes still + # changing in the last round are kept as they stand. order = person.get_rank( person.household, np.zeros(person.count), condition=choosing ) From 5c1dbebfa1a55fc2a0bd3c121cca5133dc270f1e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Oct 2026 11:25:12 -0400 Subject: [PATCH 14/28] Deduct net pay pension contributions from the charge's total income FA 2004 s.193(2) deducts contributions under a net pay arrangement from employment income, so total income (ITA 2007 s.23 Step 1), which the winter fuel payment charge tests, is after them. The model holds employment income gross of pension contributions, takes employee contributions as net pay and gives their relief through allowances, so total_income_charged_to_tax overstated a working pensioner's total income by their relieved employee contributions. New net_pay_pension_relief: the relieved employee contributions, up to taxable employment income. adjusted_total_income now deducts at Steps 3 and 4 only the relieved contributions not already out of total income (s.24A(8)), so its values are unchanged. Relief at source (s.192) still leaves total income alone. Tests: four YAML cases worked from the law, and a Hypothesis property (net pay relief, total income, adjusted total income, the charge, and relief at source leaving total income alone); two mutants each fail it. Co-Authored-By: Claude Opus 5.5 --- .../winter-fuel-payment-charge.added.md | 2 +- .../charges/winter_fuel_payment_charge.yaml | 101 +++++++++++++ .../test_total_income_net_pay_properties.py | 142 ++++++++++++++++++ .../reliefs/adjusted_total_income.py | 14 +- .../income_tax/total_income_charged_to_tax.py | 28 +++- .../hmrc/pensions/net_pay_pension_relief.py | 34 +++++ 6 files changed, 309 insertions(+), 12 deletions(-) create mode 100644 policyengine_uk/tests/test_total_income_net_pay_properties.py create mode 100644 policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py diff --git a/changelog.d/winter-fuel-payment-charge.added.md b/changelog.d/winter-fuel-payment-charge.added.md index bc00599747..971f495ba9 100644 --- a/changelog.d/winter-fuel-payment-charge.added.md +++ b/changelog.d/winter-fuel-payment-charge.added.md @@ -1,3 +1,3 @@ - Add the winter fuel payment charge (`winter_fuel_payment_charge`, ITEPA 2003 s.681I, from 2025-26) with parameters under `gov.hmrc.income_tax.charges.winter_fuel_payment`. It is charged person by person: the whole Winter Fuel Payment (England, Wales and Northern Ireland) or Pension Age Winter Heating Payment, when the person's own total income (ITA 2007 s.23 Step 1) exceeds £35,000 and they are not entitled to Income Support, income-based JSA, Pension Credit, income-related ESA or Universal Credit. It is added to `income_tax` after tax reductions (ITA 2007 s.23 Step 7). - Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit, Income Support, Housing Benefit (applicable income and net earnings) and Council Tax Reduction means tests now deduct it instead of `income_tax`. The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. Universal Credit earned income and the benefit cap's earnings test already deduct only each person's own tax on earnings, which leaves out the charge. The property allowance election still compares `income_tax`, which now includes the charge: a route can change total income and Pension Credit, and so whether the charge applies. While the charge has effect, the election compares tax on both routes even for landlords without finance costs, and landlords decide one per household rather than one per benefit unit, because a couple's Pension Credit can change another benefit unit's shared payment and so its charge. -- Add `total_income_charged_to_tax`, total income as ITA 2007 s.23 Step 1 defines it: the model's taxable income components (after employment expenses, capital allowances, the trading and property allowances and exempt ISA interest), with losses brought forward added back. The winter fuel payment charge tests it, and `adjusted_total_income` now reads it for Step 1 instead of computing it itself. `total_income` still sums gross income. +- Add `total_income_charged_to_tax`, total income as ITA 2007 s.23 Step 1 defines it: the model's taxable income components (after employment expenses, capital allowances, the trading and property allowances and exempt ISA interest), less employee pension contributions relieved under a net pay arrangement (`net_pay_pension_relief`, new: FA 2004 s.193(2) deducts them from employment income), with losses brought forward added back. The winter fuel payment charge tests it, and `adjusted_total_income` now reads it for Step 1 instead of computing it itself, deducting at Steps 3 and 4 only the relieved contributions not already deducted, so its values are unchanged. `total_income` still sums gross income. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index 83d287aae7..7ca7c2bd46 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -929,3 +929,104 @@ winter_fuel_payment: [300, 0, 200, 0, 0, 0, 0, 0] winter_fuel_payment_charge: [0, 0, 200, 0, 0, 0, 0, 0] income_tax: [6_288.51, 0, 6_186, 0, 0, 0, 0, 0] + +- name: 2025 net pay pension contributions come off total income + # The model takes employee contributions as paid under a net pay + # arrangement, which the employer deducts from employment income (FA 2004 + # s.193(2)), so total income is 36,000 - 2,000 = £34,000, not over £35,000: + # no charge. Tax: (34,000 - 12,570) x 20% = £4,286. + period: 2025 + input: + people: + pensioner: + age: 70 + employment_income: 36_000 + employee_pension_contributions: 2_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + net_pay_pension_relief: [2_000] + total_income_charged_to_tax: [34_000] + winter_fuel_payment: [200] + winter_fuel_payment_charge: [0] + income_tax: [4_286] + +- name: 2025 pension contributions relieved at source do not lower total income + # Personal contributions get relief at source (FA 2004 s.192), which leaves + # employment income, and so total income, at £36,000: charged the £200. + period: 2025 + input: + people: + pensioner: + age: 70 + employment_income: 36_000 + personal_pension_contributions: 2_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + net_pay_pension_relief: [0] + total_income_charged_to_tax: [36_000] + winter_fuel_payment_charge: [200] + +- name: 2025 net pay contributions come off total income only up to the relief + # Relief is limited to relevant UK earnings, the £1,000 of employment + # income (FA 2004 s.190), and the employer deducts it from that income + # (s.193(2)). Total income: 1,000 + 35,500 - 1,000 = £35,500, over + # £35,000: charged the £200. + period: 2025 + input: + people: + pensioner: + age: 70 + employment_income: 1_000 + private_pension_income: 35_500 + employee_pension_contributions: 2_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + net_pay_pension_relief: [1_000] + total_income_charged_to_tax: [35_500] + winter_fuel_payment_charge: [200] + +- name: 2025 contributions from age 75 do not lower total income + # Contributions paid from age 75 get no relief (FA 2004 s.188(3)(a)), so + # nothing comes off employment income: total income £36,000. Reg. 3(4): + # £300 at 80 or over, living alone, charged in full. + period: 2025 + input: + people: + pensioner: + age: 80 + employment_income: 36_000 + employee_pension_contributions: 2_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + net_pay_pension_relief: [0] + total_income_charged_to_tax: [36_000] + winter_fuel_payment: [300] + winter_fuel_payment_charge: [300] diff --git a/policyengine_uk/tests/test_total_income_net_pay_properties.py b/policyengine_uk/tests/test_total_income_net_pay_properties.py new file mode 100644 index 0000000000..9270249ee2 --- /dev/null +++ b/policyengine_uk/tests/test_total_income_net_pay_properties.py @@ -0,0 +1,142 @@ +"""Property-based tests for pension contributions in total income. + +The winter fuel payment charge tests total income (ITA 2007 s.23 Step 1). +Employee pension contributions, which the model takes as paid under a net pay +arrangement, are deducted from employment income (FA 2004 s.193(2)), so total +income is after them. Contributions relieved at source (s.192) are not. +Invariants, for any single pensioner in England with pay, employment +expenses, a private pension and both kinds of contribution, in 2025: + +1. Net pay relief is the employee contributions, up to taxable employment + income, and nothing from age 75 (s.188(3)(a)). +2. Total income is taxable employment income plus the pension, less that + relief. Contributions relieved at source never change it. +3. Adjusted total income (s.24A(8)), which reads total income for Step 1, + still deducts each relieved contribution once: it is the pay and pension + less the contributions relieved, up to the greater of £3,600 and the pay. +4. The charge is the whole payment when total income exceeds £35,000, and + nothing otherwise (no relevant benefit is entered). + +All populations are synthetic situations; these tests never load a dataset. +""" + +import numpy as np +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2025 +VARIABLES = ( + "taxable_employment_income", + "net_pay_pension_relief", + "total_income_charged_to_tax", + "adjusted_total_income", + "winter_fuel_payment", + "winter_fuel_payment_charge", +) + + +@pytest.fixture(scope="module") +def template(): + return Simulation(situation={"people": {"template": {"age": {YEAR: 70}}}}) + + +def simulate(template, people): + """Give each pensioner their own English household and benefit unit.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for index, person in enumerate(people): + name = f"pensioner_{index}" + situation["people"][name] = { + key: {YEAR: value} for key, value in person.items() + } + situation["people"][name]["employer_pension_contributions"] = {YEAR: 0} + situation["benunits"][f"unit_{index}"] = { + "members": [name], + "pension_credit": {YEAR: 0}, + } + situation["households"][f"household_{index}"] = { + "members": [name], + "country": {YEAR: "ENGLAND"}, + } + simulation = template.clone(clone_tax_benefit_system=False) + simulation.build_from_situation(situation) + simulation.input_variables = simulation.get_known_variables() + simulation.calculated_periods = [] + return { + name: np.asarray(simulation.calculate(name, YEAR), dtype=float) + for name in VARIABLES + } + + +def money(top): + return st.integers(0, top).map(float) + + +pensioner = st.fixed_dictionaries( + { + "age": st.integers(66, 90), + "employment_income": money(60_000), + "employment_expenses": money(3_000), + "private_pension_income": money(50_000), + "employee_pension_contributions": money(20_000), + "personal_pension_contributions": money(20_000), + } +) +# Pay of £36,000 with £2,000 of employee contributions: total income £34,000. +NET_PAY = { + "age": 70, + "employment_income": 36_000.0, + "employment_expenses": 0.0, + "private_pension_income": 0.0, + "employee_pension_contributions": 2_000.0, + "personal_pension_contributions": 0.0, +} + + +@settings(max_examples=12, deadline=None, derandomize=True, database=None) +@given(people=st.lists(pensioner, min_size=1, max_size=20)) +@example( + people=[ + NET_PAY, + {**NET_PAY, "age": 80}, + {**NET_PAY, "employment_expenses": 1_500.0}, + ] +) +def test_net_pay_contributions_and_only_they_come_off_total_income(template, people): + with_ras = simulate(template, people) + without_ras = simulate( + template, + [{**person, "personal_pension_contributions": 0.0} for person in people], + ) + for i, person in enumerate(people): + taxable_pay = max( + 0.0, person["employment_income"] - person["employment_expenses"] + ) + under_75 = person["age"] < 75 + net_pay = under_75 * min(person["employee_pension_contributions"], taxable_pay) + total_income = taxable_pay + person["private_pension_income"] - net_pay + contributions = ( + person["employee_pension_contributions"] + + person["personal_pension_contributions"] + ) + relieved = under_75 * min(contributions, max(3_600.0, taxable_pay)) + adjusted_total_income = max( + 0.0, taxable_pay + person["private_pension_income"] - relieved + ) + charge = with_ras["winter_fuel_payment"][i] * (total_income > 35_000) + expected = { + "net_pay_pension_relief": net_pay, + "total_income_charged_to_tax": total_income, + "adjusted_total_income": adjusted_total_income, + "winter_fuel_payment_charge": charge, + } + for name, value in expected.items(): + assert with_ras[name][i] == pytest.approx(value, abs=0.05), ( + name, + person, + ) + # Relief at source leaves total income, and so the charge, alone. + for name in ("total_income_charged_to_tax", "winter_fuel_payment_charge"): + assert with_ras[name][i] == without_ras[name][i], (name, person) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py index 3ff06b68fa..9d976ccae9 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py @@ -8,9 +8,11 @@ class adjusted_total_income(Variable): documentation = ( "The base of the cap on reliefs against general income (ITA 2007 " "s.24A(8)). Step 1 is total income (s.23 Step 1, " - "total_income_charged_to_tax). Steps 3 " - "and 4 deduct the pension contributions given relief (FA 2004 " - "ss.192-194): the person's own contributions up to the greater of " + "total_income_charged_to_tax), which is already after employee " + "contributions under a net pay arrangement (FA 2004 s.193(2)). " + "Steps 3 and 4 deduct the other pension contributions given relief " + "(ss.192-194). Together these are the person's own contributions " + "up to the greater of " "their relevant UK earnings (s.189(2): taxable employment income, " "including benefits and statutory pay, and trading profits) and the " "basic amount (s.190), none from " @@ -59,4 +61,8 @@ def formula(person, period, parameters): min_(person("pension_contributions", period), relief_limit) * under_age_limit ) - return max_(0, total_income - relieved_contributions) + # Net pay contributions already came off total income at Step 1. + not_yet_deducted = relieved_contributions - person( + "net_pay_pension_relief", period + ) + return max_(0, total_income - not_yet_deducted) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py index 3f74d9259a..4d18346395 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py @@ -12,9 +12,14 @@ class total_income_charged_to_tax(Variable): "components, which already make the deductions the law makes in " "working out each kind of income: employment expenses, capital " "allowances, the trading and property allowances, and exempt ISA " - "interest. Trade losses brought forward (loss_relief) are a Step 2 " - "relief that the model deducts from trading profits, so they are " - "added back. Unlike total_income, which sums gross income, this is " + "interest. Employee pension contributions under a net pay " + "arrangement come off employment income (FA 2004 s.193(2)); the " + "model gives that relief through allowances, so the relieved part " + "(net_pay_pension_relief) is deducted here. Contributions relieved " + "at source (s.192) do not lower total income. Trade losses brought " + "forward (loss_relief) are a Step 2 relief that the model deducts " + "from trading profits, so they are added back. Unlike total_income, " + "which sums gross income, this is " "the figure the law tests, as far as the model's components go: " "where a component departs from the law, so does this total. For " "example, the model deducts deficiency relief from dividend income, " @@ -22,10 +27,16 @@ class total_income_charged_to_tax(Variable): ) definition_period = YEAR unit = GBP - reference = dict( - title="Income Tax Act 2007 s. 23", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", - ) + reference = [ + dict( + title="Income Tax Act 2007 s. 23", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", + ), + dict( + title="Finance Act 2004 s. 193", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/193", + ), + ] def formula(person, period, parameters): p = parameters(period).gov.hmrc.income_tax @@ -41,7 +52,10 @@ def formula(person, period, parameters): losses_brought_forward_deducted = profits_before_carry_forward - person( "taxable_self_employment_income", period ) + # Employment income is held gross of pension contributions, and the + # relief for net pay contributions is given through allowances. return ( person("net_income_before_trade_loss_relief", period) + losses_brought_forward_deducted + - person("net_pay_pension_relief", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py b/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py new file mode 100644 index 0000000000..3567641103 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class net_pay_pension_relief(Variable): + value_type = float + entity = Person + label = "Pension contributions relieved under a net pay arrangement" + documentation = ( + "The person's employee pension contributions given relief, which the " + "model takes as paid under a net pay arrangement. The employer " + "deducts them from employment income (FA 2004 s.193(2)), so total " + "income (ITA 2007 s.23 Step 1) is after them, up to that income: any " + "excess is relieved on a claim at Step 2 (s.193(4) and (6)). Within " + "the annual limit for relief (s.190), employee contributions are " + "relieved before personal ones. The model gives the relief itself " + "through allowances (pension_contributions_relief); this is the part " + "of it that total income is already after." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Finance Act 2004 s. 193", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/193", + ) + + def formula(person, period, parameters): + relieved_employee_contributions = min_( + person("employee_pension_contributions", period), + person("pension_contributions_relief", period), + ) + return min_( + relieved_employee_contributions, + person("taxable_employment_income", period), + ) From d73e73b99a4d0ae7b204bc6dcdecf37f56c15a6d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 15:25:54 -0400 Subject: [PATCH 15/28] Count statutory pay in the net pay deduction, and keep it off relief at source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r9 found the net pay deduction capped at pension_contributions_relief, whose relevant UK earnings leave out statutory sick and maternity pay. A pensioner with £1,000 of wages, £3,000 of SSP, a £32,500 pension and £2,000 of employee contributions had £1,000 deducted and a £200 charge, where the law deducts the whole £2,000 (FA 2004 ss.189(2)(a), 190(1), 193(2); HMRC PTM044100) and charges nothing. Personal contributions also moved the deduction, through the basic-amount rise that s.191(7) keeps for relief at source. The deduction is now the employee contributions up to taxable employment income, none from age 75. Relevant UK earnings include all of that income, so the s.190 limit never binds below it. It no longer reads relief at source. Two YAML cases pin the SSP case with and without personal contributions; the net pay property test now draws statutory sick and maternity pay. ATI's comment and the changelog qualify "unchanged" for float32 rounding. Co-Authored-By: Claude Opus 5.5 --- .../winter-fuel-payment-charge.added.md | 2 +- .../charges/winter_fuel_payment_charge.yaml | 60 ++++++++++++++++++- .../test_total_income_net_pay_properties.py | 32 ++++++++-- .../reliefs/adjusted_total_income.py | 7 ++- .../hmrc/pensions/net_pay_pension_relief.py | 45 +++++++++----- 5 files changed, 123 insertions(+), 23 deletions(-) diff --git a/changelog.d/winter-fuel-payment-charge.added.md b/changelog.d/winter-fuel-payment-charge.added.md index 971f495ba9..aa40194d49 100644 --- a/changelog.d/winter-fuel-payment-charge.added.md +++ b/changelog.d/winter-fuel-payment-charge.added.md @@ -1,3 +1,3 @@ - Add the winter fuel payment charge (`winter_fuel_payment_charge`, ITEPA 2003 s.681I, from 2025-26) with parameters under `gov.hmrc.income_tax.charges.winter_fuel_payment`. It is charged person by person: the whole Winter Fuel Payment (England, Wales and Northern Ireland) or Pension Age Winter Heating Payment, when the person's own total income (ITA 2007 s.23 Step 1) exceeds £35,000 and they are not entitled to Income Support, income-based JSA, Pension Credit, income-related ESA or Universal Credit. It is added to `income_tax` after tax reductions (ITA 2007 s.23 Step 7). - Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit, Income Support, Housing Benefit (applicable income and net earnings) and Council Tax Reduction means tests now deduct it instead of `income_tax`. The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. Universal Credit earned income and the benefit cap's earnings test already deduct only each person's own tax on earnings, which leaves out the charge. The property allowance election still compares `income_tax`, which now includes the charge: a route can change total income and Pension Credit, and so whether the charge applies. While the charge has effect, the election compares tax on both routes even for landlords without finance costs, and landlords decide one per household rather than one per benefit unit, because a couple's Pension Credit can change another benefit unit's shared payment and so its charge. -- Add `total_income_charged_to_tax`, total income as ITA 2007 s.23 Step 1 defines it: the model's taxable income components (after employment expenses, capital allowances, the trading and property allowances and exempt ISA interest), less employee pension contributions relieved under a net pay arrangement (`net_pay_pension_relief`, new: FA 2004 s.193(2) deducts them from employment income), with losses brought forward added back. The winter fuel payment charge tests it, and `adjusted_total_income` now reads it for Step 1 instead of computing it itself, deducting at Steps 3 and 4 only the relieved contributions not already deducted, so its values are unchanged. `total_income` still sums gross income. +- Add `total_income_charged_to_tax`, total income as ITA 2007 s.23 Step 1 defines it: the model's taxable income components (after employment expenses, capital allowances, the trading and property allowances and exempt ISA interest), less employee pension contributions relieved under a net pay arrangement (`net_pay_pension_relief`, new: FA 2004 s.193(2) deducts them from employment income, so up to taxable employment income, statutory sick and maternity pay included, and none from age 75), with losses brought forward added back. The winter fuel payment charge tests it, and `adjusted_total_income` now reads it for Step 1 instead of computing it itself, deducting at Steps 3 and 4 only the relieved contributions not already deducted, so its values are unchanged up to float32 rounding. `total_income` still sums gross income. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index 7ca7c2bd46..308982ddd2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -982,7 +982,8 @@ - name: 2025 net pay contributions come off total income only up to the relief # Relief is limited to relevant UK earnings, the £1,000 of employment - # income (FA 2004 s.190), and the employer deducts it from that income + # income (FA 2004 s.190(1); the rise to the basic amount is only for relief + # at source, s.191(7)), and the employer deducts it from that income # (s.193(2)). Total income: 1,000 + 35,500 - 1,000 = £35,500, over # £35,000: charged the £200. period: 2025 @@ -1006,6 +1007,63 @@ total_income_charged_to_tax: [35_500] winter_fuel_payment_charge: [200] +- name: 2025 statutory sick pay is relevant UK earnings for the net pay deduction + # Relevant UK earnings are employment income chargeable to tax, including + # taxable statutory sick pay (FA 2004 s.189(2)(a); HMRC PTM044100): 1,000 + + # 3,000 = £4,000, enough to relieve the whole £2,000 (s.190(1)), which the + # employer deducts from that income (s.193(2)). Total income: 4,000 + + # 32,500 - 2,000 = £34,500, not over £35,000: no charge. + period: 2025 + input: + people: + pensioner: + age: 70 + employment_income: 1_000 + statutory_sick_pay: 3_000 + private_pension_income: 32_500 + employee_pension_contributions: 2_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + taxable_employment_income: [4_000] + net_pay_pension_relief: [2_000] + total_income_charged_to_tax: [34_500] + winter_fuel_payment: [200] + winter_fuel_payment_charge: [0] + +- name: 2025 contributions relieved at source leave the net pay deduction alone + # The case above with £1,000 of personal contributions as well. They are + # relieved at source (FA 2004 s.192), which leaves employment income, so + # total income stays £34,500 and there is still no charge. + period: 2025 + input: + people: + pensioner: + age: 70 + employment_income: 1_000 + statutory_sick_pay: 3_000 + private_pension_income: 32_500 + employee_pension_contributions: 2_000 + personal_pension_contributions: 1_000 + benunits: + unit: + members: [pensioner] + pension_credit: 0 + households: + household: + members: [pensioner] + country: ENGLAND + output: + net_pay_pension_relief: [2_000] + total_income_charged_to_tax: [34_500] + winter_fuel_payment_charge: [0] + - name: 2025 contributions from age 75 do not lower total income # Contributions paid from age 75 get no relief (FA 2004 s.188(3)(a)), so # nothing comes off employment income: total income £36,000. Reg. 3(4): diff --git a/policyengine_uk/tests/test_total_income_net_pay_properties.py b/policyengine_uk/tests/test_total_income_net_pay_properties.py index 9270249ee2..3fcf41cf75 100644 --- a/policyengine_uk/tests/test_total_income_net_pay_properties.py +++ b/policyengine_uk/tests/test_total_income_net_pay_properties.py @@ -4,16 +4,19 @@ Employee pension contributions, which the model takes as paid under a net pay arrangement, are deducted from employment income (FA 2004 s.193(2)), so total income is after them. Contributions relieved at source (s.192) are not. -Invariants, for any single pensioner in England with pay, employment -expenses, a private pension and both kinds of contribution, in 2025: +Invariants, for any single pensioner in England with pay, statutory sick and +maternity pay, employment expenses, a private pension and both kinds of +contribution, in 2025: 1. Net pay relief is the employee contributions, up to taxable employment - income, and nothing from age 75 (s.188(3)(a)). + income (statutory pay included: it is relevant UK earnings, s.189(2)(a)), + and nothing from age 75 (s.188(3)(a)). 2. Total income is taxable employment income plus the pension, less that relief. Contributions relieved at source never change it. 3. Adjusted total income (s.24A(8)), which reads total income for Step 1, - still deducts each relieved contribution once: it is the pay and pension - less the contributions relieved, up to the greater of £3,600 and the pay. + still deducts each relieved contribution once: it is the taxable pay and + pension less the contributions relieved, up to the greater of £3,600 and + the taxable pay. 4. The charge is the whole payment when total income exceeds £35,000, and nothing otherwise (no relevant benefit is entered). @@ -78,6 +81,8 @@ def money(top): { "age": st.integers(66, 90), "employment_income": money(60_000), + "statutory_sick_pay": money(4_000), + "statutory_maternity_pay": money(4_000), "employment_expenses": money(3_000), "private_pension_income": money(50_000), "employee_pension_contributions": money(20_000), @@ -88,6 +93,8 @@ def money(top): NET_PAY = { "age": 70, "employment_income": 36_000.0, + "statutory_sick_pay": 0.0, + "statutory_maternity_pay": 0.0, "employment_expenses": 0.0, "private_pension_income": 0.0, "employee_pension_contributions": 2_000.0, @@ -102,6 +109,15 @@ def money(top): NET_PAY, {**NET_PAY, "age": 80}, {**NET_PAY, "employment_expenses": 1_500.0}, + # Statutory sick pay is relevant UK earnings, so the whole £2,000 + # comes off: total income £34,500, with or without relief at source. + { + **NET_PAY, + "employment_income": 1_000.0, + "statutory_sick_pay": 3_000.0, + "private_pension_income": 32_500.0, + "personal_pension_contributions": 1_000.0, + }, ] ) def test_net_pay_contributions_and_only_they_come_off_total_income(template, people): @@ -112,7 +128,11 @@ def test_net_pay_contributions_and_only_they_come_off_total_income(template, peo ) for i, person in enumerate(people): taxable_pay = max( - 0.0, person["employment_income"] - person["employment_expenses"] + 0.0, + person["employment_income"] + + person["statutory_sick_pay"] + + person["statutory_maternity_pay"] + - person["employment_expenses"], ) under_75 = person["age"] < 75 net_pay = under_75 * min(person["employee_pension_contributions"], taxable_pay) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py index 9d976ccae9..703cb40a8e 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py @@ -61,7 +61,12 @@ def formula(person, period, parameters): min_(person("pension_contributions", period), relief_limit) * under_age_limit ) - # Net pay contributions already came off total income at Step 1. + # Net pay contributions already came off total income at Step 1, and + # they never exceed the contributions relieved here (employee + # contributions up to taxable employment income, which is part of + # relevant earnings). So this is total income before them less every + # relieved contribution, up to float32 rounding of a few pence on + # incomes near £1m. not_yet_deducted = relieved_contributions - person( "net_pay_pension_relief", period ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py b/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py index 3567641103..47e3c8c5d6 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py @@ -10,25 +10,42 @@ class net_pay_pension_relief(Variable): "model takes as paid under a net pay arrangement. The employer " "deducts them from employment income (FA 2004 s.193(2)), so total " "income (ITA 2007 s.23 Step 1) is after them, up to that income: any " - "excess is relieved on a claim at Step 2 (s.193(4) and (6)). Within " - "the annual limit for relief (s.190), employee contributions are " - "relieved before personal ones. The model gives the relief itself " - "through allowances (pension_contributions_relief); this is the part " - "of it that total income is already after." + "excess is relieved on a claim at Step 2 (s.193(4) and (6)). The " + "annual limit for relief (s.190(1)) is the person's relevant UK " + "earnings, which include all their employment income chargeable to " + "tax, statutory sick and maternity pay among it (s.189(2)(a)), so it " + "never holds the deduction below that income. Within the limit, " + "employee contributions are relieved before personal ones, so " + "contributions relieved at source never change it. Nothing is " + "relieved from age 75 (s.188(3)(a)). The model gives the relief " + "itself through allowances (pension_contributions_relief), which " + "counts only wages and trading profits as relevant UK earnings, so " + "where statutory pay makes up part of the earnings it can give less " + "relief than this deduction." ) definition_period = YEAR unit = GBP - reference = dict( - title="Finance Act 2004 s. 193", - href="https://www.legislation.gov.uk/ukpga/2004/12/section/193", - ) + reference = [ + dict( + title="Finance Act 2004 s. 189", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/189", + ), + dict( + title="Finance Act 2004 s. 190", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/190", + ), + dict( + title="Finance Act 2004 s. 193", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/193", + ), + ] def formula(person, period, parameters): - relieved_employee_contributions = min_( - person("employee_pension_contributions", period), - person("pension_contributions_relief", period), + p = parameters(period).gov.hmrc.pensions + under_age_limit = ( + person("age", period) < p.pension_contributions_relief_age_limit ) - return min_( - relieved_employee_contributions, + return under_age_limit * min_( + person("employee_pension_contributions", period), person("taxable_employment_income", period), ) From ed0b799ea039fb1797b6ad4438f8bab1cf0b99b0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 15:32:30 -0400 Subject: [PATCH 16/28] Cite issue #2371 for the gap between the net pay deduction and the model's relief Co-Authored-By: Claude Opus 5.5 --- .../variables/gov/hmrc/pensions/net_pay_pension_relief.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py b/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py index 47e3c8c5d6..4a8636b6fe 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py @@ -18,10 +18,10 @@ class net_pay_pension_relief(Variable): "employee contributions are relieved before personal ones, so " "contributions relieved at source never change it. Nothing is " "relieved from age 75 (s.188(3)(a)). The model gives the relief " - "itself through allowances (pension_contributions_relief), which " - "counts only wages and trading profits as relevant UK earnings, so " - "where statutory pay makes up part of the earnings it can give less " - "relief than this deduction." + "itself through allowances (pension_contributions_relief). That " + "counts only wages and trading profits as relevant UK earnings " + "(issue #2371), so where statutory pay makes up part of the earnings " + "it can give less relief than this deduction." ) definition_period = YEAR unit = GBP From d0920a9cdf6df552d1cf1a6a7b634433401e5cd9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 15:55:12 -0400 Subject: [PATCH 17/28] Qualify two comments and the changelog after review r10 The ATI comment's "never exceed" holds for contributions of zero or more; the add-back cancels either way. Relieving employee contributions first is the model's order, not one the Act states. The fixed fragment now says the payment reaches everyone of pensionable age who meets its other conditions. Co-Authored-By: Claude Opus 5.5 --- changelog.d/winter-fuel-payment-charge.fixed.md | 2 +- .../gov/hmrc/income_tax/reliefs/adjusted_total_income.py | 9 +++++---- .../gov/hmrc/pensions/net_pay_pension_relief.py | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/changelog.d/winter-fuel-payment-charge.fixed.md b/changelog.d/winter-fuel-payment-charge.fixed.md index 2c5657914f..43a95772a5 100644 --- a/changelog.d/winter-fuel-payment-charge.fixed.md +++ b/changelog.d/winter-fuel-payment-charge.fixed.md @@ -1 +1 @@ -- From the 2025 qualifying week, pay the Winter Fuel Payment to everyone of pensionable age in England, Wales and Northern Ireland (SI 2025/969 reg. 2; NISR 2025/142 reg. 2) and recover it through the winter fuel payment charge, in place of a household income test. The test paid a whole household if any member of pensionable age had total income under £35,000. It applied only in England and Wales, so pensioners in Northern Ireland not on a relevant benefit were paid nothing, and nothing stood in for the charge on the Pension Age Winter Heating Payment. Its boundary was also wrong: total income of exactly £35,000 is not charged. +- From the 2025 qualifying week, pay the Winter Fuel Payment to everyone of pensionable age in England, Wales and Northern Ireland who meets its other conditions (SI 2025/969 reg. 2; NISR 2025/142 reg. 2) and recover it through the winter fuel payment charge, in place of a household income test. The test paid a whole household if any member of pensionable age had total income under £35,000. It applied only in England and Wales, so pensioners in Northern Ireland not on a relevant benefit were paid nothing, and nothing stood in for the charge on the Pension Age Winter Heating Payment. Its boundary was also wrong: total income of exactly £35,000 is not charged. diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py index 703cb40a8e..f5c0c14222 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py @@ -61,10 +61,11 @@ def formula(person, period, parameters): min_(person("pension_contributions", period), relief_limit) * under_age_limit ) - # Net pay contributions already came off total income at Step 1, and - # they never exceed the contributions relieved here (employee - # contributions up to taxable employment income, which is part of - # relevant earnings). So this is total income before them less every + # Net pay contributions already came off total income at Step 1. With + # contributions of zero or more they never exceed the contributions + # relieved here (employee contributions up to taxable employment + # income, which is part of relevant earnings). Either way the add-back + # cancels the deduction: this is total income before them less every # relieved contribution, up to float32 rounding of a few pence on # incomes near £1m. not_yet_deducted = relieved_contributions - person( diff --git a/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py b/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py index 4a8636b6fe..499c6a3604 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py @@ -14,8 +14,8 @@ class net_pay_pension_relief(Variable): "annual limit for relief (s.190(1)) is the person's relevant UK " "earnings, which include all their employment income chargeable to " "tax, statutory sick and maternity pay among it (s.189(2)(a)), so it " - "never holds the deduction below that income. Within the limit, " - "employee contributions are relieved before personal ones, so " + "never holds the deduction below that income. Within the limit, the " + "model relieves employee contributions before personal ones, so " "contributions relieved at source never change it. Nothing is " "relieved from age 75 (s.188(3)(a)). The model gives the relief " "itself through allowances (pension_contributions_relief). That " From 75ca17cdd8db739c844e5c187f38c946f2dfd457 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 16:38:07 -0400 Subject: [PATCH 18/28] Option B for d1669: compare property routes only where finance costs are at stake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIP for Max's ruling on d1669, not for the PR until he rules. Since 26ff32421 every landlord the property allowance helps had both routes compared while the winter fuel payment charge has effect. Each comparison works out income tax with the charge, and so the means-tested benefits, for the whole population, at every Simulation init for 2025-2032: CI shards reached 15.5 GB (9-10 GB on main) and a synthetic probe took 6.0 s against 0.9 s. On the Enhanced FRS the comparison moved no record's tax, charge or Pension Credit. Landlords without finance costs take the allowance without comparing again, as on main. Review r7's lone pensioner becomes a pinned limit: the YAML case and a property test record that the model takes the allowance and charges her £200 (£5,227.40 against £5,187.40 with expenses). The pensioner property now asserts optimality for landlords with finance costs to relieve. Co-Authored-By: Claude Opus 5.5 --- .../winter-fuel-payment-charge.added.md | 2 +- .../charges/winter_fuel_payment_charge.yaml | 20 ++++---- .../tests/test_property_income_properties.py | 47 +++++++++++++++++-- .../allowances/uses_property_allowance.py | 43 ++++++++--------- 4 files changed, 76 insertions(+), 36 deletions(-) diff --git a/changelog.d/winter-fuel-payment-charge.added.md b/changelog.d/winter-fuel-payment-charge.added.md index aa40194d49..2ff4d2c80c 100644 --- a/changelog.d/winter-fuel-payment-charge.added.md +++ b/changelog.d/winter-fuel-payment-charge.added.md @@ -1,3 +1,3 @@ - Add the winter fuel payment charge (`winter_fuel_payment_charge`, ITEPA 2003 s.681I, from 2025-26) with parameters under `gov.hmrc.income_tax.charges.winter_fuel_payment`. It is charged person by person: the whole Winter Fuel Payment (England, Wales and Northern Ireland) or Pension Age Winter Heating Payment, when the person's own total income (ITA 2007 s.23 Step 1) exceeds £35,000 and they are not entitled to Income Support, income-based JSA, Pension Credit, income-related ESA or Universal Credit. It is added to `income_tax` after tax reductions (ITA 2007 s.23 Step 7). -- Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit, Income Support, Housing Benefit (applicable income and net earnings) and Council Tax Reduction means tests now deduct it instead of `income_tax`. The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. Universal Credit earned income and the benefit cap's earnings test already deduct only each person's own tax on earnings, which leaves out the charge. The property allowance election still compares `income_tax`, which now includes the charge: a route can change total income and Pension Credit, and so whether the charge applies. While the charge has effect, the election compares tax on both routes even for landlords without finance costs, and landlords decide one per household rather than one per benefit unit, because a couple's Pension Credit can change another benefit unit's shared payment and so its charge. +- Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit, Income Support, Housing Benefit (applicable income and net earnings) and Council Tax Reduction means tests now deduct it instead of `income_tax`. The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. Universal Credit earned income and the benefit cap's earnings test already deduct only each person's own tax on earnings, which leaves out the charge. The property allowance election still compares `income_tax`, which now includes the charge: a route can change total income and Pension Credit, and so whether the charge applies. Landlords decide one per household rather than one per benefit unit, because a couple's Pension Credit can change another benefit unit's shared payment and so its charge. Landlords without finance costs still take the allowance without comparing, as before; that can rarely cost more once the charge applies (when the lower tax ends Pension Credit and with it the exemption), but comparing for them made simulations several times slower. - Add `total_income_charged_to_tax`, total income as ITA 2007 s.23 Step 1 defines it: the model's taxable income components (after employment expenses, capital allowances, the trading and property allowances and exempt ISA interest), less employee pension contributions relieved under a net pay arrangement (`net_pay_pension_relief`, new: FA 2004 s.193(2) deducts them from employment income, so up to taxable employment income, statutory sick and maternity pay included, and none from age 75), with losses brought forward added back. The winter fuel payment charge tests it, and `adjusted_total_income` now reads it for Step 1 instead of computing it itself, deducting at Steps 3 and 4 only the relieved contributions not already deducted, so its values are unchanged up to float32 rounding. `total_income` still sums gross income. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml index 308982ddd2..32fc572f72 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -800,7 +800,7 @@ income_tax_before_winter_fuel_payment_charge: [4_490, 0, 0, 0, 0, 0] income_tax: [4_490, 0, 0, 0, 0, 0] -- name: 2025 a landlord without finance costs keeps expenses when the allowance would end Pension Credit +- name: 2025 a landlord without finance costs takes the allowance even where it ends Pension Credit (a model limit) # Private pension £35,207; property receipts £3,500, expenses £200 (profit # £3,300), no finance costs; six children. With partial relief (the £1,000 # allowance in place of £200 of expenses): total income £37,707, tax @@ -812,8 +812,10 @@ # minimum guarantee of £33,399.60 (with the amounts for the children), so # only the expenses route gets Pension Credit (£80), which exempts the # pensioner from the £200 charge (s.681I(4)). Total tax: £5,227.40 with the - # allowance, £5,187.40 with expenses, so the pensioner does not elect for - # partial relief. + # allowance, £5,187.40 with expenses, so a pensioner choosing the lower tax + # would not elect for partial relief. The model does not compare routes for + # landlords without finance costs (uses_property_allowance), so it takes the + # allowance: no Pension Credit, charged the £200. This case pins that limit. period: 2025 absolute_error_margin: 0.01 input: @@ -845,13 +847,13 @@ members: [pensioner, child_1, child_2, child_3, child_4, child_5, child_6] country: ENGLAND output: - uses_property_allowance: [false, false, false, false, false, false, false] - total_income_charged_to_tax: [38_507, 0, 0, 0, 0, 0, 0] - pension_credit_income: 33_319.60 - pension_credit: 80 + uses_property_allowance: [true, false, false, false, false, false, false] + total_income_charged_to_tax: [37_707, 0, 0, 0, 0, 0, 0] + pension_credit_income: 33_479.60 + pension_credit: 0 winter_fuel_payment: [200, 0, 0, 0, 0, 0, 0] - winter_fuel_payment_charge: [0, 0, 0, 0, 0, 0, 0] - income_tax: [5_187.40, 0, 0, 0, 0, 0, 0] + winter_fuel_payment_charge: [200, 0, 0, 0, 0, 0, 0] + income_tax: [5_227.40, 0, 0, 0, 0, 0, 0] - name: 2025 landlords in two benefit units of one household each elect with the other's route fixed # One household. Unit A: a claimant aged 70 with private pension diff --git a/policyengine_uk/tests/test_property_income_properties.py b/policyengine_uk/tests/test_property_income_properties.py index 09ebdf6f29..31601a9705 100644 --- a/policyengine_uk/tests/test_property_income_properties.py +++ b/policyengine_uk/tests/test_property_income_properties.py @@ -365,8 +365,12 @@ def test_no_one_lowers_their_tax_by_switching_route_alone(couple_sim, drawn): # (ITEPA 2003 s.681I(4)), even without finance costs at stake; and a couple's # Pension Credit decides whether the partner is entitled to a payment, which # moves the shared payment, and so the charge, of anyone else in the -# household. Each landlord must still choose the route that gives them the -# lower income tax including the charge, with every other route held fixed. +# household. Each landlord with finance costs to relieve must still choose +# the route that gives them the lower income tax including the charge, with +# every other route held fixed. Landlords without finance costs take the +# allowance without comparing (uses_property_allowance); review r7's lone +# pensioner below shows that can cost more, an intended limit pinned by its +# own test. PENSIONER_YEAR = 2025 PENSIONER_HOUSEHOLDS = 6 PENSIONER_REGIONS = ["LONDON", "LONDON", "SCOTLAND", "WALES", "LONDON", "SCOTLAND"] @@ -505,7 +509,14 @@ def test_no_pensioner_lowers_their_tax_and_charge_by_switching_alone( set_pensioner_inputs(sim, drawn) routes = sim.calculate("uses_property_allowance", PENSIONER_YEAR) income_tax = sim.calculate("income_tax", PENSIONER_YEAR).astype(float) - choosing = sim.calculate("property_allowance_deduction_if_used", PENSIONER_YEAR) > 0 + profit = sim.calculate("property_income", PENSIONER_YEAR) - sim.calculate( + "deductible_property_finance_costs", PENSIONER_YEAR + ) + choosing = ( + (sim.calculate("property_allowance_deduction_if_used", PENSIONER_YEAR) > 0) + & (sim.calculate("property_finance_costs_relievable", PENSIONER_YEAR) > 0) + & (profit > 0) + ) position = np.arange(len(routes)) % (len(ROLES) + CHILDREN) for k in range(len(ROLES)): # One landlord per household switches; households do not interact. @@ -521,3 +532,33 @@ def test_no_pensioner_lowers_their_tax_and_charge_by_switching_alone( income_tax[switching], switched_tax[switching], ) + + +def test_a_landlord_without_finance_costs_takes_the_allowance_even_where_it_costs_more( + pensioner_sim, +): + """Intended violation: review r7's lone pensioner (no finance costs). + + The allowance lowers tax before the charge by £160 but ends £80 of Pension + Credit and with it the exemption from the £200 charge, so expenses would + give the lower income tax (£5,187.40 against £5,227.40). The model does + not compare routes for landlords without finance costs, so it takes the + allowance. + """ + sim = pensioner_sim + drawn = [LONE_PENSIONER] * PENSIONER_HOUSEHOLDS + set_pensioner_inputs(sim, drawn) + members = len(ROLES) + CHILDREN + claimants = np.arange(sim.calculate("age", PENSIONER_YEAR).size) % members == 0 + # The figures are England's and Wales's rates; Scotland's differ. + english_or_welsh = np.repeat( + [region != "SCOTLAND" for region in PENSIONER_REGIONS], members + ) + pinned = claimants & english_or_welsh + routes = sim.calculate("uses_property_allowance", PENSIONER_YEAR) + income_tax = sim.calculate("income_tax", PENSIONER_YEAR).astype(float) + assert routes[claimants].all() + assert income_tax[pinned] == pytest.approx(5_227.40, abs=0.01) + set_pensioner_inputs(sim, drawn, routes & ~claimants) + with_expenses = sim.calculate("income_tax", PENSIONER_YEAR).astype(float) + assert with_expenses[pinned] == pytest.approx(5_187.40, abs=0.01) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py index d77c410a69..e5facece38 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py @@ -57,11 +57,15 @@ class uses_property_allowance(Variable): "Ties keep the default, so a person with receipts within the " "allowance keeps full relief even where electing out would carry " "finance costs forward. Without finance costs to relieve, the " - "default is the allowance whenever it lowers taxable property " - "income. Before the winter fuel payment charge that default is " - "always the lower tax, so it is taken without comparing. While the " - "charge has effect, lower tax before the charge can end an award " - "that exempts the person from it, so the routes are compared." + "allowance is used whenever it lowers taxable property income, " + "without comparing tax. That is always the lower tax before the " + "winter fuel payment charge. While the charge has effect it can " + "rarely cost more: the lower tax can raise the income Pension Credit " + "counts and end an award that exempts the person from the charge. " + "The model does not compare routes for these landlords, because " + "that works out the benefits on both routes for the whole " + "population and made simulations several times slower; on the " + "Enhanced FRS the comparison changed no one's tax." ) definition_period = YEAR reference = [ @@ -94,27 +98,20 @@ def formula(person, period, parameters): person("property_finance_costs_relievable", period) > 0 ) & (profit > 0) # Without a reduction at stake, lower taxable property income means - # lower tax before the winter fuel payment charge. The charge can - # reverse that: lower tax can raise income that Pension Credit - # counts, end the award and with it the exemption (s.681I(4)), and - # the charge can exceed the saving. So while it has effect, compare - # income tax on each route for everyone the allowance would help. - charge_in_effect = parameters( - period - ).gov.hmrc.income_tax.charges.winter_fuel_payment.in_effect - compared = reduction_at_stake | charge_in_effect - uses = lowers_income & ~compared - choosing = lowers_income & compared + # lower tax before the winter fuel payment charge, so the allowance + # is used without comparing. The charge can rarely reverse that + # (lower tax can end Pension Credit and with it the exemption, + # s.681I(4)); see the documentation for why that is not compared. + uses = lowers_income & ~reduction_at_stake + choosing = lowers_income & reduction_at_stake if not choosing.any(): # Nobody has a choice to weigh: skip the branches. return uses - # With a reduction at stake, full relief applies unless electing out - # of it (s. 783BJ) lowers tax, and partial relief applies only if - # electing for it (s. 783BK) lowers tax. Without one, the allowance - # is the default. Until a person decides, they keep their default. + # Full relief applies unless electing out of it (s. 783BJ) lowers + # tax; partial relief applies only if electing for it (s. 783BK) + # lowers tax. Until a person decides, they keep that default. full_relief = person("property_receipts_within_allowance", period) - default = full_relief | ~reduction_at_stake - route = where(choosing, default, uses) + route = where(choosing, full_relief, uses) for variable in INDEPENDENT_OF_ROUTE: person(variable, period) simulation = person.simulation @@ -144,7 +141,7 @@ def formula(person, period, parameters): # Tax is computed to the penny; smaller differences are # rounding. saving = np.round(tax_not_using - tax_using, 2) - prefers_allowance = where(default, saving >= 0, saving > 0) + prefers_allowance = where(full_relief, saving >= 0, saving > 0) decided = where(deciding, prefers_allowance, route) changed = changed or bool((decided != route).any()) route = decided From ab69a95a988184fc0eaba6e0943ddb63016a793f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 22:15:36 -0400 Subject: [PATCH 19/28] Say what the couples property checks under d1669 = B Since 75ca17cdd, landlords without finance costs take the property allowance without comparing tax. The couples property still checks every landlord the allowance would help, so for those without finance costs it now checks main's shortcut, which holds because the couples are 40 and so never charged. Its comment still said everyone compares tax while the charge has effect. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_property_income_properties.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_property_income_properties.py b/policyengine_uk/tests/test_property_income_properties.py index 31601a9705..b804dd3172 100644 --- a/policyengine_uk/tests/test_property_income_properties.py +++ b/policyengine_uk/tests/test_property_income_properties.py @@ -346,8 +346,10 @@ def test_no_one_lowers_their_tax_by_switching_route_alone(couple_sim, drawn): set_couple_inputs(couple_sim, drawn) routes = couple_sim.calculate("uses_property_allowance", YEAR) income_tax = couple_sim.calculate("income_tax", YEAR).astype(float) - # While the winter fuel payment charge has effect (YEAR is after - # 2025-26), everyone the allowance would help chooses by comparing tax. + # Everyone the allowance would help. Those with finance costs to relieve + # choose by comparing tax; the rest take the allowance without comparing, + # which lowers their own taxable property income and so their tax: these + # couples are 40, so no winter fuel payment charge can reverse that. choosing = couple_sim.calculate("property_allowance_deduction_if_used", YEAR) > 0 position = np.arange(2 * COUPLES) % 2 for k in range(2): From cc2961429d972d9c2797c7f61232b69167f7e4f5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 00:51:17 -0400 Subject: [PATCH 20/28] =?UTF-8?q?Charge=20main's=20=C2=A335,000=20and=20ag?= =?UTF-8?q?e-75=20test=20cases=20back=20through=20income=20tax?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two YAML files that reached this branch with the merges of main still expected main's rules and failed CI's YAML job at 56a7fe29f. winter_fuel_allowance.yaml (#2174's boundary cases): main stood in for the charge with the household income test, so a pensioner with £35,001 of total income lost the payment. Here everyone of pensionable age is paid from 2025 and the charge recovers it, so that pensioner is paid £200 and charged £200. The four boundary cases now check winter_fuel_payment_charge (nothing at £34,999 or exactly £35,000, the whole payment at £35,001), and the comment describes the charge, not the stand-in. statutory_pay_pension_relief.yaml (#2464), "Age75 exclusion retained": the 75-year-old's total income is £42,000, with nothing relieved from age 75 (FA 2004 s.188(3)(a)), so income tax includes the £200 charge: 6,086, of which 5,886 (main's figure) is tax before the charge. Co-Authored-By: Claude Opus 5.5 --- .../baseline/gov/dwp/winter_fuel_allowance.yaml | 16 +++++++++++----- .../pensions/statutory_pay_pension_relief.yaml | 9 ++++++++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_allowance.yaml index 4d6c77c229..1e91049b97 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_allowance.yaml @@ -108,9 +108,11 @@ winter_fuel_allowance: 200 # ITEPA 2003 s.681I(1)(b): the winter fuel payment charge applies only where -# total income "exceeds £35,000". The model stands in for the charge with the -# income test, so a pensioner with exactly £35,000 keeps the payment. Total -# income is built from pension and other investment income, not input. +# total income "exceeds £35,000". From 2025-26 the payment goes to everyone of +# pensionable age who meets its other conditions, and the charge recovers it +# through income tax, so a pensioner with exactly £35,000 is not charged and +# one with £35,001 is charged the whole payment. Total income is built from +# pension and other investment income, not input. - name: Pensioner with total income just under £35,000 keeps WFP period: 2026 absolute_error_margin: 1 @@ -123,6 +125,7 @@ output: total_income: 34_999 winter_fuel_allowance: 200 + winter_fuel_payment_charge: 0 - name: Pensioner with total income of exactly £35,000 keeps WFP period: 2026 @@ -136,8 +139,9 @@ output: total_income: 35_000 winter_fuel_allowance: 200 + winter_fuel_payment_charge: 0 -- name: Pensioner with total income just over £35,000 loses WFP +- name: Pensioner with total income just over £35,000 is charged the whole WFP back period: 2026 absolute_error_margin: 1 input: @@ -148,7 +152,8 @@ pension_credit: 0 output: total_income: 35_001 - winter_fuel_allowance: 0 + winter_fuel_allowance: 200 + winter_fuel_payment_charge: 200 - name: Pensioner with total income of exactly £35,000 keeps WFP in 2025-26 period: 2025 @@ -161,3 +166,4 @@ output: total_income: 35_000 winter_fuel_allowance: 200 + winter_fuel_payment_charge: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml index ebf7430268..7b3ad78ce5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml @@ -140,7 +140,14 @@ employee_pension_contributions: 22000 pension_contributions_relief: 0 personal_pension_contributions_tax: 0 - income_tax: 5886.0 + # Nothing is relieved from age 75 (FA 2004 s.188(3)(a)), so total income + # is the full £42,000, over £35,000: income tax includes the £200 winter + # fuel payment charge (ITEPA 2003 s.681I). + net_pay_pension_relief: 0 + total_income_charged_to_tax: 42000 + income_tax_before_winter_fuel_payment_charge: 5886.0 + winter_fuel_payment_charge: 200 + income_tax: 6086.0 - name: RAS basic amount retained period: 2026 From aeb1b924decaf24fc3ec8952b15645464c33a1d8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 01:39:14 -0400 Subject: [PATCH 21/28] Drop net_pay_pension_relief's note on #2371, which #2464 fixed #2464 (merged into main on 2026-10-19 and into this branch with the merges of main) counts statutory sick and maternity pay as relevant UK earnings in pension_contributions_relief (utils/pensions.py relevant_uk_earnings adds employment_benefits). The documentation still said that relief counted only wages and trading profits (issue #2371) and so could give less relief than this deduction. The PR's merge notes said to drop the sentence once #2464 merged. Documentation only. Co-Authored-By: Claude Opus 5.5 --- .../variables/gov/hmrc/pensions/net_pay_pension_relief.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py b/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py index 499c6a3604..07b711b569 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py @@ -18,10 +18,7 @@ class net_pay_pension_relief(Variable): "model relieves employee contributions before personal ones, so " "contributions relieved at source never change it. Nothing is " "relieved from age 75 (s.188(3)(a)). The model gives the relief " - "itself through allowances (pension_contributions_relief). That " - "counts only wages and trading profits as relevant UK earnings " - "(issue #2371), so where statutory pay makes up part of the earnings " - "it can give less relief than this deduction." + "itself through allowances (pension_contributions_relief)." ) definition_period = YEAR unit = GBP From 2d0c8f76c1841b81de2171d1c278df562cc5fd04 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 05:00:33 -0400 Subject: [PATCH 22/28] Fix fiscal eligibility reads after Winter Fuel Payment tax split --- .../winter-fuel-payment-charge-fiscal-reads.fixed.md | 1 + policyengine_uk/data/fiscal_partition.yaml | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 changelog.d/winter-fuel-payment-charge-fiscal-reads.fixed.md diff --git a/changelog.d/winter-fuel-payment-charge-fiscal-reads.fixed.md b/changelog.d/winter-fuel-payment-charge-fiscal-reads.fixed.md new file mode 100644 index 0000000000..978dd48a42 --- /dev/null +++ b/changelog.d/winter-fuel-payment-charge-fiscal-reads.fixed.md @@ -0,0 +1 @@ +- Correct fiscal-partition eligibility reads for income tax and the TV licence after separating the Winter Fuel Payment charge from tax deducted by Pension Credit. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 3ae9f9fef0..8786793862 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -161,6 +161,10 @@ tax_heads: payer: household totals: [gov_tax, household_tax, pre_budget_change_household_tax] sign: nonnegative + reads: + - head: nics_individual + route: income_tax -> winter_fuel_payment_charge -> is_on_pension_credit -> pension_credit -> pension_credit_income -> national_insurance + why: Pension Credit receipt exempts a person from the charge, and its means test deducts National Insurance - id: nics_individual label: National Insurance (employee and self-employed) variable: national_insurance @@ -349,12 +353,9 @@ tax_heads: totals: [gov_tax, household_tax, pre_budget_change_household_tax] sign: nonnegative reads: - - head: income_tax - route: tv_licence -> tv_licence_discount -> pension_credit -> pension_credit_income -> income_tax - why: the over-75 discount depends on Pension Credit receipt, a means test on net income - head: nics_individual route: tv_licence -> tv_licence_discount -> pension_credit -> pension_credit_income -> national_insurance - why: as above + why: the over-75 discount depends on Pension Credit receipt, a means test on net income # Property - id: council_tax From f23c3d6efe5c00df4944f6247f0596c4b2178f48 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 05:02:15 -0400 Subject: [PATCH 23/28] Hold pre-cap Universal Credit in trading-loss comparison --- .../tests/test_trading_loss_properties.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 359e311586..1f18a4bd87 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -876,6 +876,31 @@ def on_relevant_benefit(sim): ), ] ) +@example( + [ + # Removing the loss must preserve UC's Housing Benefit passport: + # holding only final UC changed HB income from £0 to £45,976.93. + ( + [ + adult( + age=63, + employment_income=28_997.520933585933, + self_employment_income=17_292.726071844652, + trading_loss=33_528.10659809126, + hours_worked=35.0, + ), + {"age": 7, "trading_loss": 0.0}, + {"age": 9, "trading_loss": 3_911.068241394866}, + ], + { + "rent": 0.0, + "council_tax": 0.0, + "tenure_type": "RENT_FROM_COUNCIL", + "region": "SCOTLAND", + }, + ), + ] +) @given(st.lists(families(), min_size=4, max_size=10)) def test_means_tests_see_the_loss_only_through_tax(units): with_loss = Simulation(situation=family_situation(units)) @@ -924,6 +949,7 @@ def values(sim, variable): profits = values(with_loss, "self_employment_income") mif = np.asarray(with_loss.calculate("uc_mif_applies", YEAR)) uc = values(with_loss, "universal_credit") + uc_pre_cap = values(with_loss, "universal_credit_pre_benefit_cap") hb = values(with_loss, "housing_benefit") def held(k, **extra): @@ -934,6 +960,8 @@ def held(k, **extra): } # Every means test but UC: the loss reaches them only through the tax. + # Housing Benefit's UC passport reads entitlement before the benefit + # cap, so preserve that amount alongside the final award. no_loss = Simulation( situation=family_situation( units, @@ -941,6 +969,7 @@ def held(k, **extra): { b: { "universal_credit": float(uc[k]), + "universal_credit_pre_benefit_cap": float(uc_pre_cap[k]), **{v: float(pc_tax[v][k]) for v in pc_tax}, } for k, b in enumerate(benunits) From 862f89312bc164630e64967e5d5875996e3bd7a2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 08:01:10 -0400 Subject: [PATCH 24/28] Test declared-reader containment independently of tax levels --- .../test_aggregate_impact_guard.py | 113 +++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py index 7d801764b1..403f79d955 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py @@ -11,7 +11,14 @@ from hypothesis import given, settings from hypothesis import strategies as st -from policyengine_uk.utils.aggregate_impact.guard import counted_once, snapshot_inputs +from policyengine_uk.utils.aggregate_impact.guard import ( + _slack, + breaks_on_slope, + counted_once, + judge_total, + snapshot_inputs, + still_breaks, +) from policyengine_uk.utils.aggregate_impact.partition import parse_partition YEAR = 2026 @@ -232,6 +239,110 @@ def test_a_declared_read_may_respond_but_not_one_for_one(): assert row["status"] == "fail" and ("tax_a", "contains") in kinds(row) +@pytest.mark.parametrize("tax_level", [100_000, 300_000]) +@pytest.mark.parametrize("declared", [False, True]) +@pytest.mark.parametrize("contains", [False, True]) +def test_a_large_tax_head_is_containment_only_when_it_responds_one_for_one( + tax_level, declared, contains +): + """A flat £300,000 reader is unchanged at both NI-style perturbations.""" + formulas = model(contains=False) + formulas["tax_a"] = lambda f: tax_level + contains * f("tax_b") + reads = {"tax_a": ["tax_b"]} if declared else {} + _, result = run(formulas, partition(["tax_a", "tax_b"], reads=reads)) + row = by_variable(result)["tax_b"] + assert row["status"] == ("fail" if contains else "pass") + if contains: + assert ("tax_a", "contains" if declared else "moves") in kinds(row) + else: + assert "tax_a" not in row["responses"]["gov_tax"] + assert result["reset_reproduces_baseline"] + + +def test_a_large_reader_with_a_mediated_slope_has_no_held_containment(): + """Both held measurements are flat after the other head stops moving.""" + formulas = model(contains=False) + formulas["tax_c"] = lambda f: 0.5 * f("tax_b") + formulas["tax_a"] = lambda f: 300_000 + 2 * f("tax_c") + formulas["gov_tax"] = lambda f: f("tax_a") + f("tax_b") + f("tax_c") + _, result = run( + formulas, + partition( + ["tax_a", "tax_b", "tax_c"], + reads={"tax_a": ["tax_b", "tax_c"], "tax_c": ["tax_b"]}, + ), + ) + row = by_variable(result)["tax_b"] + assert row["status"] == "pass" + assert [(m["head"], m["kind"], m["via"]) for m in row["mediated"]] == [ + ("tax_a", "contains", ["tax_c"]) + ] + assert result["reset_reproduces_baseline"] + + +def test_a_large_reader_contains_a_component_despite_an_extra_mediated_slope(): + """The unrestricted slope is 1.125; holding tax_c reveals containment.""" + formulas = model(contains=False) + formulas["tax_c"] = lambda f: 0.25 * f("tax_b") + formulas["tax_a"] = lambda f: 300_000 + f("tax_b") + 0.5 * f("tax_c") + formulas["gov_tax"] = lambda f: f("tax_a") + f("tax_b") + f("tax_c") + _, result = run( + formulas, + partition( + ["tax_a", "tax_b", "tax_c"], + reads={"tax_a": ["tax_b", "tax_c"], "tax_c": ["tax_b"]}, + ), + ) + row = by_variable(result)["tax_b"] + assert row["status"] == "fail" + assert ("tax_a", "contains") in kinds(row) + assert result["reset_reproduces_baseline"] + + +@pytest.mark.parametrize("contains", [False, True]) +@settings(max_examples=60, deadline=None) +@given( + tax_level=st.floats(0, 1e9), + perturbation=st.floats(1e-6, 1e6), +) +def test_containment_depends_on_the_response_and_perturbation_not_the_tax_level( + contains, tax_level, perturbation +): + """Zero never contains; a one-for-one response always does, at any size.""" + expected = np.array([perturbation]) + first = expected if contains else np.zeros(1) + second = 2 * first + scales = np.array([tax_level]) + where = np.array([True]) + for second_changes in (None, {"tax_a": second}): + verdict = judge_total( + total="gov_tax", + is_tax=True, + component="tax_b", + own_variable="tax_b", + declared_double_count=False, + zero_baseline=False, + weights=np.ones(1), + expected=expected, + head_changes={"tax_b": expected, "tax_a": first}, + head_scales={"tax_b": scales, "tax_a": scales}, + model_change=None, + model_scale=None, + allowed_readers=["tax_a"], + second_changes=second_changes, + ) + assert bool(verdict["problems"]) == contains + if contains: + assert verdict["problems"][0]["head"] == "tax_a" + assert verdict["problems"][0]["kind"] == "contains" + slack = _slack(scales) + for held_second in (None, second): + direct = still_breaks("contains", where, expected, slack, first, held_second) + assert bool(direct[0]) == contains + at_first = breaks_on_slope("contains", where, expected, slack, second - first) + assert bool(at_first[0]) == contains + + def test_supplied_inputs_are_restored_after_perturbation(): formulas = model(contains=False) formulas.pop("tax_b") From 76108fafe74d9ee8d73e8d7a871aa723f39bfb20 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 08:04:32 -0400 Subject: [PATCH 25/28] Scale reader containment tolerance by perturbation and response --- .../2066-aggregate-reader-precision.fixed.md | 1 + .../utils/aggregate_impact/guard.py | 38 ++++++++++++++----- 2 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 changelog.d/2066-aggregate-reader-precision.fixed.md diff --git a/changelog.d/2066-aggregate-reader-precision.fixed.md b/changelog.d/2066-aggregate-reader-precision.fixed.md new file mode 100644 index 0000000000..eeaf1821b7 --- /dev/null +++ b/changelog.d/2066-aggregate-reader-precision.fixed.md @@ -0,0 +1 @@ +- Fixed aggregate counted-once checks falsely identifying unchanged large tax heads as containing a component read through a declared dependency. diff --git a/policyengine_uk/utils/aggregate_impact/guard.py b/policyengine_uk/utils/aggregate_impact/guard.py index 67e05c854d..5133e42283 100644 --- a/policyengine_uk/utils/aggregate_impact/guard.py +++ b/policyengine_uk/utils/aggregate_impact/guard.py @@ -134,9 +134,9 @@ def _head_arrays(sim, year, variables) -> dict: } -# A household's change counts as "no change", or as "exactly what was added", -# within this many pounds plus this share of the head's own value: the model -# stores values as float32. +# Level comparisons allow this many pounds plus this share of the head's +# own value: the model stores values as float32. Containment compares the +# response with the perturbation instead (see ``_response_slack``). ABSOLUTE_SLACK_GBP = 0.02 RELATIVE_SLACK = 2e-6 @@ -145,6 +145,16 @@ def _slack(scale: np.ndarray) -> np.ndarray: return ABSOLUTE_SLACK_GBP + RELATIVE_SLACK * np.abs(scale) +def _response_slack(response: np.ndarray, expected: np.ndarray) -> np.ndarray: + """Scale the £1 response allowance by the amount added, not the head level. + + A flat reader must never be close to one for one, even for a small + perturbation or a large baseline. The relative term allows rounding in + the response itself; two-point differences use twice this allowance. + """ + return ABSOLUTE_SLACK_GBP * np.abs(expected) + RELATIVE_SLACK * np.abs(response) + + def _problem(total, head, kind, where, text, weights, perturbed): """One failing (total, head, kind), with the households it fails in. @@ -238,17 +248,22 @@ def containment(head): # the component, 0 for a step such as a passport, whatever # else moved the head at the first point. slope = second_changes[variable] - change - containing = perturbed & (np.abs(slope - expected) <= 2 * slack) + response_slack = _response_slack(slope, expected) + containing = perturbed & ( + np.abs(slope - expected) <= 2 * response_slack + ) # Another head's response can add to a containing head's # slope, so any other linear response is measured again with # the other heads held. - linear = perturbed & (np.abs(slope) > 2 * slack) & ~containing + linear = perturbed & (np.abs(slope) > 2 * response_slack) & ~containing if linear.any(): screens.append( problem(variable, "contains", linear, containment(variable)) ) else: - containing = perturbed & (np.abs(change - expected) <= slack) + containing = perturbed & ( + np.abs(change - expected) <= _response_slack(change, expected) + ) if containing.any(): problems.append( problem(variable, "contains", containing, containment(variable)) @@ -344,7 +359,9 @@ def breaks_on_slope(kind, where, expected, slack, slope): """Households of ``where`` whose head's slope, with the other heads held at their values after the first point, breaks ``kind``.""" if kind == "contains": - return where & (np.abs(slope - expected) <= 2 * slack) + return where & ( + np.abs(slope - expected) <= 2 * _response_slack(slope, expected) + ) if kind == "rises": return where & (slope > 2 * slack) if kind == "moves": @@ -361,8 +378,11 @@ def still_breaks(kind, where, expected, slack, first, second=None): """ if kind == "contains": if second is not None: - return where & (np.abs(second - first - expected) <= 2 * slack) - return where & (np.abs(first - expected) <= slack) + slope = second - first + return where & ( + np.abs(slope - expected) <= 2 * _response_slack(slope, expected) + ) + return where & (np.abs(first - expected) <= _response_slack(first, expected)) if kind == "rises": return where & (first > slack) if kind == "moves": From b1505cdf46694f6e902095260068eb19ce24a788 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 20:02:45 -0400 Subject: [PATCH 26/28] State the paternity pay tax bound before the winter fuel payment charge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2368 taxes statutory paternity pay, so it is in total income, and with this branch's charge a pound of it can take total income over £35,000 and raise income_tax by the whole winter fuel payment. The bound in test_statutory_paternity_pay_income_tax_properties.py now reads income_tax_before_winter_fuel_payment_charge, names the charge as an intended cliff next to Marriage Allowance, and a new case checks the cliff: £35,000 of pension and savings plus £1 raises income tax by £200.20, 20p before the charge. total_income_charged_to_tax no longer says the model does not tax statutory paternity pay, and the changelog and the Pension Credit statutory pay test's docstring now say that it does. Co-Authored-By: Claude Opus 5.5 --- .../winter-fuel-payment-charge.added.md | 2 +- ...pension_credit_statutory_pay_properties.py | 30 +++++---- ...ory_paternity_pay_income_tax_properties.py | 66 +++++++++++++++++-- .../income_tax/total_income_charged_to_tax.py | 3 +- 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/changelog.d/winter-fuel-payment-charge.added.md b/changelog.d/winter-fuel-payment-charge.added.md index b7a1841aa7..6d5b28ddd2 100644 --- a/changelog.d/winter-fuel-payment-charge.added.md +++ b/changelog.d/winter-fuel-payment-charge.added.md @@ -1,3 +1,3 @@ - Add the winter fuel payment charge (`winter_fuel_payment_charge`, ITEPA 2003 s.681I, from 2025-26) with parameters under `gov.hmrc.income_tax.charges.winter_fuel_payment`. It is charged person by person: the whole Winter Fuel Payment (England, Wales and Northern Ireland) or Pension Age Winter Heating Payment, when the person's own total income (ITA 2007 s.23 Step 1) exceeds £35,000 and they are not entitled to Income Support, income-based JSA, Pension Credit, income-related ESA or Universal Credit. It is added to `income_tax` after tax reductions (ITA 2007 s.23 Step 7). - Add `income_tax_before_winter_fuel_payment_charge`. The Pension Credit means test, including the income tax it attributes to employed and self-employed earnings (SPC Regs 2002 reg. 17(10) and (11)), and the Income Support, Housing Benefit (applicable income and net earnings) and Council Tax Reduction means tests now deduct it instead of `income_tax`. The charge recovers a payment rather than taxing the income those tests assess, and it depends on Pension Credit and Universal Credit receipt, so deducting it would make the model circular. Universal Credit earned income and the benefit cap's earnings test already deduct only each person's own tax on earnings, which leaves out the charge. The property allowance election still compares `income_tax`, which now includes the charge: a route can change total income and Pension Credit, and so whether the charge applies. Landlords decide one per household rather than one per benefit unit, because a couple's Pension Credit can change another benefit unit's shared payment and so its charge. Landlords without finance costs still take the allowance without comparing, as before; that can rarely cost more once the charge applies (when the lower tax ends Pension Credit and with it the exemption), but comparing for them made simulations several times slower. -- Add `total_income_charged_to_tax`, total income as ITA 2007 s.23 Step 1 defines it: the model's taxable income components (after employment expenses, capital allowances, the trading and property allowances and exempt ISA interest), less employee pension contributions relieved under a net pay arrangement (`net_pay_pension_relief`, new: FA 2004 s.193(2) deducts them from employment income, so up to taxable employment income, statutory sick and maternity pay included, and none from age 75), with losses brought forward added back. The winter fuel payment charge tests it, and `adjusted_total_income` now reads it for Step 1 instead of computing it itself, deducting at Steps 3 and 4 only the relieved contributions not already deducted, so its values are unchanged up to float32 rounding. `total_income` still sums gross income. +- Add `total_income_charged_to_tax`, total income as ITA 2007 s.23 Step 1 defines it: the model's taxable income components (after employment expenses, capital allowances, the trading and property allowances and exempt ISA interest), less employee pension contributions relieved under a net pay arrangement (`net_pay_pension_relief`, new: FA 2004 s.193(2) deducts them from employment income, so up to taxable employment income, statutory sick, maternity and paternity pay included, and none from age 75), with losses brought forward added back. The winter fuel payment charge tests it, and `adjusted_total_income` now reads it for Step 1 instead of computing it itself, deducting at Steps 3 and 4 only the relieved contributions not already deducted, so its values are unchanged up to float32 rounding. `total_income` still sums gross income. diff --git a/policyengine_uk/tests/test_pension_credit_statutory_pay_properties.py b/policyengine_uk/tests/test_pension_credit_statutory_pay_properties.py index 5aaa2d7853..6db9e0d790 100644 --- a/policyengine_uk/tests/test_pension_credit_statutory_pay_properties.py +++ b/policyengine_uk/tests/test_pension_credit_statutory_pay_properties.py @@ -6,15 +6,15 @@ social security benefits prescribed as income. Pension Credit counted none of them, while pension-age Housing Benefit counted statutory sick and maternity pay. Both Housing Benefit schemes count statutory paternity pay as earnings -too (HB Regs 2006 reg 35(1)(i); HB (SPC) Regs 2006 reg 35(1)(i)), which the -model's Housing Benefit does not yet do (PolicyEngine/policyengine-uk#2368 -adds it), so invariant 1 draws only statutory sick and maternity pay. +too (HB Regs 2006 reg 35(1)(i); HB (SPC) Regs 2006 reg 35(1)(i)), as the +model's Housing Benefit has since PolicyEngine/policyengine-uk#2368. +Invariant 1 was written before that and still draws only statutory sick and +maternity pay. What the model taxes (read from the code, not changed here): income tax sees -statutory sick and maternity pay through employment_benefits, which -taxable_employment_income adds to employment income; it does not see -statutory paternity pay. Class 1 National Insurance sees all three: -ni_class_1_income adds them to employment income. +all three through employment_benefits, which taxable_employment_income adds +to employment income (statutory paternity pay since #2368). Class 1 National +Insurance sees all three: ni_class_1_income adds them to employment income. Invariants, for single people and couples over State Pension age in 2026 (invariant 2 also draws younger partners), with no dependants, renting from @@ -48,12 +48,14 @@ own earnings rules, housing_benefit_net_earnings and the Sch 4 disregards, are outside this change.) Moving it to statutory paternity pay - leaves Pension Credit earnings and National Insurance unchanged and changes - Pension Credit income only through income tax, which does not see that - pay: Pension Credit income plus the claimant's and partner's pre-charge income - tax and National Insurance plus the earnings disregard is unchanged where Pension - Credit income is above its zero floor in both runs. (The disregard is added - back because it is capped at net earnings, which are net of that tax.) + leaves Pension Credit earnings and National Insurance unchanged, and + Pension Credit income plus the claimant's and partner's pre-charge income + tax and National Insurance plus the earnings disregard is unchanged where + Pension Credit income is above its zero floor in both runs. (The disregard + is added back because it is capped at net earnings, which are net of that + tax.) This was written when income tax did not see statutory paternity pay; + since #2368 it does, so the check still holds but is weaker than the one for + sick and maternity pay. 3. Monotonicity: Guarantee Credit never rises as any one statutory payment rises. """ @@ -269,7 +271,7 @@ def vary(i, pension): context, ) if pay == "statutory_paternity_pay": - # Income tax does not see statutory paternity pay. + # Checked net of tax, as written before #2368 taxed this pay. def before_tax(g): return ( g["pension_credit_income"][i] diff --git a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py index 034802a3ff..bb654f7bd8 100644 --- a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py +++ b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py @@ -18,10 +18,14 @@ tax as income rises (the High Income Child Benefit Charge stops when the benefit stops), for wages too, so the generated families do not opt out. 2. Bounds: for families that do not claim Marriage Allowance, a pound more - of statutory paternity pay raises the family's income tax by at most a - pound. Income Tax Act 2007 s. 55B(2)(b) withdraws the whole Marriage - Allowance reduction once the recipient is liable above the basic rate, so - one pound can cost more than a pound there; wages do the same. + of statutory paternity pay raises the family's income tax before the + winter fuel payment charge by at most a pound. Two cliffs are intended + exceptions, and wages cross both the same way. Income Tax Act 2007 + s. 55B(2)(b) withdraws the whole Marriage Allowance reduction once the + recipient is liable above the basic rate. ITEPA 2003 s. 681I charges back + the whole winter fuel payment once the recipient's total income exceeds + £35,000, so in income_tax one pound across that line costs the payment + plus the tax on the pound (the last test below). 3. Same as statutory sick pay: statutory paternity pay of s gives every member the same income tax, National Insurance, adjusted net income and student loan repayment, and the family the same Housing Benefit and @@ -263,7 +267,14 @@ def test_income_tax_never_falls_as_statutory_paternity_pay_rises(population): assert family_gain >= -TOLERANCE, family # Invariant 2. if not claims_marriage_allowance(family): - assert family_gain <= family["high"] - family["low"] + TOLERANCE, family + gain_before_charge = results.family_total( + "income_tax_before_winter_fuel_payment_charge", i, HIGH + ) - results.family_total( + "income_tax_before_winter_fuel_payment_charge", i, LOW + ) + assert gain_before_charge <= family["high"] - family["low"] + TOLERANCE, ( + family + ) @PROPERTY_SETTINGS @@ -376,3 +387,48 @@ def test_statutory_paternity_pay_on_the_basic_rate_bears_20_percent(): assert abs(tax[HIGH] - tax[LOW] - 77.728) < 0.01 assert abs(tax[AS_SICK_PAY] - tax[HIGH]) < 0.01 assert abs(tax[AS_WAGES] - tax[HIGH]) < 0.01 + + +def test_a_pound_of_statutory_paternity_pay_can_bring_the_winter_fuel_payment_charge(): + # A 70-year-old in England with £20,000 of private pension and £15,000 of + # savings interest has total income of exactly £35,000, so the £200 Winter + # Fuel Payment is not charged back. A pound of statutory paternity pay + # takes total income over £35,000 (ITEPA 2003 s. 681I(1)(b)): income tax + # rises by the whole payment plus 20% of the pound. Before the charge, the + # pound costs only its 20p. + family = dict( + adults=[ + dict( + age=70, + wages=0.0, + profit=0.0, + pension_income=20_000.0, + savings=15_000.0, + dividends=0.0, + pension_contributions=0.0, + student_loan_plan="NONE", + claims_marriage_allowance=False, + ) + ], + married=False, + child_ages=[], + region="NORTH_EAST", + tenure="RENT_PRIVATELY", + rent=0.0, + would_claim_uc=False, + recipient=0, + low=0.0, + high=1.0, + ) + results = Results([family]) + + def rise(variable): + return results.recipient(variable, 0, HIGH) - results.recipient( + variable, 0, LOW + ) + + assert results.recipient("winter_fuel_payment", 0, HIGH) == 200 + assert results.recipient("winter_fuel_payment_charge", 0, LOW) == 0 + assert results.recipient("winter_fuel_payment_charge", 0, HIGH) == 200 + assert abs(rise("income_tax_before_winter_fuel_payment_charge") - 0.2) < 0.01 + assert abs(rise("income_tax") - 200.2) < 0.01 diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py index 4d18346395..0ae522a8a3 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py @@ -22,8 +22,7 @@ class total_income_charged_to_tax(Variable): "which sums gross income, this is " "the figure the law tests, as far as the model's components go: " "where a component departs from the law, so does this total. For " - "example, the model deducts deficiency relief from dividend income, " - "and does not yet tax statutory paternity pay." + "example, the model deducts deficiency relief from dividend income." ) definition_period = YEAR unit = GBP From 3a3800e24ccbd2c5a66d20944dba3414207fa941 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 20:46:56 -0400 Subject: [PATCH 27/28] Name the savings allowance and Child Benefit charge cliffs in invariant 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delta review found two more cliffs that break invariant 2's pound-for-a-pound bound: a pound across the basic rate limit halves the Personal Savings Allowance (ITA 2007 s. 12B; £100.40 of tax on wages of £48,270 and £2,000 of savings interest), and a pound across a £200 step adds 1% of the year's Child Benefit (ITEPA 2003 s. 681C(3)(b)). Both are on main and wages cross them the same way. The bound now skips draws where the recipient's savings allowance changes and leaves the Child Benefit charge out, and a test crosses each cliff. Also corrects invariant 4's caveat (relevant UK earnings include statutory pay) and the Pension Credit test's comment on the pay it draws. Co-Authored-By: Claude Opus 5.5 --- ...pension_credit_statutory_pay_properties.py | 5 +- ...ory_paternity_pay_income_tax_properties.py | 137 ++++++++++++++---- 2 files changed, 112 insertions(+), 30 deletions(-) diff --git a/policyengine_uk/tests/test_pension_credit_statutory_pay_properties.py b/policyengine_uk/tests/test_pension_credit_statutory_pay_properties.py index 6db9e0d790..75b2343cee 100644 --- a/policyengine_uk/tests/test_pension_credit_statutory_pay_properties.py +++ b/policyengine_uk/tests/test_pension_credit_statutory_pay_properties.py @@ -200,8 +200,9 @@ def with_private_pension(fam, pension): ] -# The statutory payments the model's Housing Benefit counts (see the module -# docstring on statutory paternity pay). +# The statutory payments invariant 1 draws. The model's Housing Benefit also +# counts statutory paternity pay, which invariant 1 does not draw (see the +# module docstring). HOUSING_BENEFIT_PAY = ("statutory_sick_pay", "statutory_maternity_pay") diff --git a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py index bb654f7bd8..e57c68a792 100644 --- a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py +++ b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py @@ -17,15 +17,26 @@ family's total income tax. A Child Benefit opt-out can lower the family's tax as income rises (the High Income Child Benefit Charge stops when the benefit stops), for wages too, so the generated families do not opt out. -2. Bounds: for families that do not claim Marriage Allowance, a pound more - of statutory paternity pay raises the family's income tax before the - winter fuel payment charge by at most a pound. Two cliffs are intended - exceptions, and wages cross both the same way. Income Tax Act 2007 - s. 55B(2)(b) withdraws the whole Marriage Allowance reduction once the - recipient is liable above the basic rate. ITEPA 2003 s. 681I charges back - the whole winter fuel payment once the recipient's total income exceeds - £35,000, so in income_tax one pound across that line costs the payment - plus the tax on the pound (the last test below). +2. Bounds: a pound more of statutory paternity pay raises the family's + income tax, before the winter fuel payment charge and less the High + Income Child Benefit Charge, by at most a pound, for families that do not + claim Marriage Allowance and where the recipient's Personal Savings + Allowance does not change. Four cliffs are intended exceptions, and wages + cross each the same way: + - Income Tax Act 2007 s. 55B(2)(b) withdraws the whole Marriage + Allowance reduction once the recipient is liable above the basic rate. + - ITA 2007 s. 12B cuts the Personal Savings Allowance from £1,000 to £500 + once the recipient has higher-rate income, and to nil with + additional-rate income, so one pound across the basic rate limit can + cost £100 of tax on savings. + - ITEPA 2003 s. 681C(3)(b) rounds the charge's percentage down to a + whole number, so one pound across a £200 step adds 1% of the year's + Child Benefit at once. + - ITEPA 2003 s. 681I charges back the whole winter fuel payment once the + recipient's total income exceeds £35,000, so in income_tax one pound + across that line costs the payment plus the tax on the pound. + The last three tests below cross the savings allowance, Child Benefit + charge and winter fuel payment cliffs in turn. 3. Same as statutory sick pay: statutory paternity pay of s gives every member the same income tax, National Insurance, adjusted net income and student loan repayment, and the family the same Housing Benefit and @@ -35,9 +46,9 @@ 4. Same as wages: statutory paternity pay of s on top of wages of e gives the same income tax and National Insurance as wages of e + s, where the recipient's wages and trading profit cover their pension contributions. - pension_contributions_relief caps relief at employment and self-employment - income, which leaves statutory pay out, so a recipient whose contributions - exceed those gets less relief on statutory pay than on wages. + pension_contributions_relief caps relief at relevant UK earnings, which + include statutory pay as taxable employment income (utils/pensions.py), + so the restriction is conservative. 5. Accounting: employment_benefits is statutory sick, maternity and paternity pay, and taxable_employment_income is wages plus those less employment deductions, floored at nil. @@ -266,15 +277,21 @@ def test_income_tax_never_falls_as_statutory_paternity_pay_rises(population): ) - results.family_total("income_tax", i, LOW) assert family_gain >= -TOLERANCE, family # Invariant 2. - if not claims_marriage_allowance(family): - gain_before_charge = results.family_total( - "income_tax_before_winter_fuel_payment_charge", i, HIGH - ) - results.family_total( - "income_tax_before_winter_fuel_payment_charge", i, LOW - ) - assert gain_before_charge <= family["high"] - family["low"] + TOLERANCE, ( - family + same_savings_allowance = results.recipient( + "savings_allowance", i, HIGH + ) == results.recipient("savings_allowance", i, LOW) + if not claims_marriage_allowance(family) and same_savings_allowance: + gain = ( + results.family_total( + "income_tax_before_winter_fuel_payment_charge", i, HIGH + ) + - results.family_total("CB_HITC", i, HIGH) + - results.family_total( + "income_tax_before_winter_fuel_payment_charge", i, LOW + ) + + results.family_total("CB_HITC", i, LOW) ) + assert gain <= family["high"] - family["low"] + TOLERANCE, family @PROPERTY_SETTINGS @@ -389,6 +406,74 @@ def test_statutory_paternity_pay_on_the_basic_rate_bears_20_percent(): assert abs(tax[AS_WAGES] - tax[HIGH]) < 0.01 +def single_earner(wages, savings=0.0, child_ages=(), low=0.0, high=1.0): + adult = dict( + age=33, + wages=wages, + profit=0.0, + pension_income=0.0, + savings=savings, + dividends=0.0, + pension_contributions=0.0, + student_loan_plan="NONE", + claims_marriage_allowance=False, + ) + return dict( + adults=[adult], + married=False, + child_ages=list(child_ages), + region="NORTH_EAST", + tenure="RENT_PRIVATELY", + rent=0.0, + would_claim_uc=False, + recipient=0, + low=low, + high=high, + ) + + +def rise(results, variable): + return results.recipient(variable, 0, HIGH) - results.recipient(variable, 0, LOW) + + +def test_a_pound_of_statutory_paternity_pay_can_halve_the_savings_allowance(): + # Wages of £48,270 and £2,000 of savings interest give taxable income of + # 50,270 - 12,570 = £37,700, exactly the basic rate limit, so the + # Personal Savings Allowance is £1,000 (ITA 2007 s. 12B). A pound of + # statutory paternity pay gives higher-rate income and a £500 allowance. + # Non-savings income goes from £35,700 to £35,701 (20p more). Savings tax + # goes from 20% of 1,000 = £200 to 20% of 1,499 plus 40% of 1 = £300.20. + results = Results([single_earner(48_270.0, savings=2_000.0)]) + assert results.recipient("savings_allowance", 0, LOW) == 1_000 + assert results.recipient("savings_allowance", 0, HIGH) == 500 + assert ( + abs(rise(results, "income_tax_before_winter_fuel_payment_charge") - 100.4) + < 0.01 + ) + + +def test_a_pound_of_statutory_paternity_pay_can_add_a_child_benefit_charge_step(): + # Wages of £50,000 and £10,199 of statutory paternity pay give adjusted + # net income of £60,199: the charge's percentage is floor(199 / 200) = 0. + # A pound more gives £60,200 and 1% of the year's Child Benefit, rounded + # down to whole pounds (ITEPA 2003 s. 681C(3)), on top of 40p of + # higher-rate tax on the pound. Net of the charge, the pound costs 40p. + results = Results( + [single_earner(50_000.0, child_ages=[4], low=10_199.0, high=10_200.0)] + ) + child_benefit = results.families("child_benefit", HIGH)[0] + assert child_benefit > 0 + assert results.recipient("CB_HITC", 0, LOW) == 0 + charge = results.recipient("CB_HITC", 0, HIGH) + assert charge == np.floor(0.01 * np.floor(child_benefit)) + assert ( + abs( + rise(results, "income_tax_before_winter_fuel_payment_charge") - charge - 0.4 + ) + < 0.01 + ) + + def test_a_pound_of_statutory_paternity_pay_can_bring_the_winter_fuel_payment_charge(): # A 70-year-old in England with £20,000 of private pension and £15,000 of # savings interest has total income of exactly £35,000, so the £200 Winter @@ -421,14 +506,10 @@ def test_a_pound_of_statutory_paternity_pay_can_bring_the_winter_fuel_payment_ch high=1.0, ) results = Results([family]) - - def rise(variable): - return results.recipient(variable, 0, HIGH) - results.recipient( - variable, 0, LOW - ) - assert results.recipient("winter_fuel_payment", 0, HIGH) == 200 assert results.recipient("winter_fuel_payment_charge", 0, LOW) == 0 assert results.recipient("winter_fuel_payment_charge", 0, HIGH) == 200 - assert abs(rise("income_tax_before_winter_fuel_payment_charge") - 0.2) < 0.01 - assert abs(rise("income_tax") - 200.2) < 0.01 + assert ( + abs(rise(results, "income_tax_before_winter_fuel_payment_charge") - 0.2) < 0.01 + ) + assert abs(rise(results, "income_tax") - 200.2) < 0.01 From 16ecb14095e0076701b7e1c9248e85b31d26fed5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 24 Oct 2026 13:26:52 -0400 Subject: [PATCH 28/28] Enter Income Support's tax deduction as income tax before the charge Main's statutory pay cases for Income Support set income_tax as an input to isolate the deduction. Income Support now deducts income tax before the winter fuel payment charge, so the cases set that variable instead. Co-Authored-By: Claude Opus 5.5 --- .../benefit/family/income_support/statutory_pay.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/statutory_pay.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/statutory_pay.yaml index aa0e6efbfb..798d0de7e5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/statutory_pay.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/statutory_pay.yaml @@ -191,7 +191,7 @@ age: 38 is_claimant_or_partner: false statutory_maternity_pay: 1_500 - income_tax: 300 + income_tax_before_winter_fuel_payment_charge: 300 national_insurance: 100 pension_contributions: 200 social_security_income: 0 @@ -257,7 +257,9 @@ statutory_sick_pay: 1_000 # Enter actual deductions to isolate the IS calculation. This case # does not assert how the annual tax and NI models calculate them. - income_tax: 100 + # Income Support deducts income tax before the winter fuel payment + # charge. + income_tax_before_winter_fuel_payment_charge: 100 national_insurance: 50 pension_contributions: 200 social_security_income: 0