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/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/changelog.d/winter-fuel-payment-charge.added.md b/changelog.d/winter-fuel-payment-charge.added.md new file mode 100644 index 0000000000..6d5b28ddd2 --- /dev/null +++ b/changelog.d/winter-fuel-payment-charge.added.md @@ -0,0 +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, 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/changelog.d/winter-fuel-payment-charge.changed.md b/changelog.d/winter-fuel-payment-charge.changed.md new file mode 100644 index 0000000000..e916202bfe --- /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 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/changelog.d/winter-fuel-payment-charge.fixed.md b/changelog.d/winter-fuel-payment-charge.fixed.md new file mode 100644 index 0000000000..43a95772a5 --- /dev/null +++ b/changelog.d/winter-fuel-payment-charge.fixed.md @@ -0,0 +1 @@ +- 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/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index af116b23f7..881724dec3 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -167,6 +167,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 @@ -375,12 +379,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 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 78e8908217..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,11 +1,11 @@ -description: Households in England or Wales with a pensioner whose total income is at or below this amount 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) + - 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 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 bd6ee4612f..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 a pensioner whose total income is at or below 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..e2e1f93805 --- /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/2003/1/section/681J +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..5a5b21883e --- /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 an award is paid to them in the year. A couple's Income Support, income-based jobseeker's allowance, income-related employment and support allowance or Pension Credit is the claimant's, the member it is paid to, and not the partner's. Joint claimants of Universal Credit are both entitled. +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 d458562f5c..a9f1455b37 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 @@ -369,7 +369,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 @@ -609,4 +609,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/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") 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 343e0b9c48..4d525a8a4a 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 @@ -13,7 +13,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 @@ -38,7 +38,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 @@ -65,7 +65,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 @@ -92,7 +92,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: 2_000 @@ -118,7 +118,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 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_net_earnings.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_net_earnings.yaml index 095a30e5ac..3d6f69ce24 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_net_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_net_earnings.yaml @@ -112,7 +112,7 @@ self_employment_income: -25_000 # Tax and NI as inputs: the test is about attribution, not the # income tax calculation. - income_tax: 1_486 + income_tax_before_winter_fuel_payment_charge: 1_486 ni_class_1_employee: 594.40 benunits: benunit: @@ -171,7 +171,7 @@ employment_income: 10_000 statutory_maternity_pay: 20_000 private_pension_income: 10_000 - income_tax: 5_486 + income_tax_before_winter_fuel_payment_charge: 5_486 ni_class_1_employee: 1_394.40 benunits: benunit: @@ -194,7 +194,7 @@ employment_income: 10_000 private_pension_income: 10_000 property_income: -5_000 - income_tax: 1_486 + income_tax_before_winter_fuel_payment_charge: 1_486 benunits: benunit: members: [person] @@ -239,3 +239,30 @@ # SI 2006/214 reg 35(1)(i). Within the personal allowance, and no NI # above State Pension age. housing_benefit_net_earnings: 388.64 + +- name: The winter fuel payment charge does not come off earnings + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + employment_income: 10_000 + private_pension_income: 30_000 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + country: ENGLAND + output: + # Total income 40,000 exceeds £35,000, so the £200 Winter Fuel Payment is + # charged back (ITEPA 2003 s.681I). The charge recovers the payment; it + # is not tax deducted from the earnings (reg 36(3); pension age reg + # 36(2)). Income tax before it is (40,000 - 12,570) x 20% = 5,486, of + # which 10,000 / 40,000 is attributed to earnings: 1,371.50. No NI above + # State Pension age. 10,000 - 1,371.50 = 8,628.50. + winter_fuel_payment_charge: [200] + income_tax: [5_686] + housing_benefit_net_earnings: 8_628.50 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 439406a636..ed8cb09712 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 @@ -16,7 +16,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 @@ -38,14 +38,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 @@ -67,7 +67,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 @@ -75,7 +75,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 @@ -97,7 +97,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 @@ -106,7 +106,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 @@ -127,7 +127,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 @@ -136,7 +136,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 @@ -157,7 +157,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 @@ -166,7 +166,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 @@ -187,7 +187,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 @@ -196,7 +196,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 @@ -218,21 +218,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 @@ -254,7 +254,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 @@ -273,7 +273,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/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 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 a54bf6b739..a8394908c3 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: @@ -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: @@ -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: @@ -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_entitled_to_carer_benefit: 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_entitled_to_carer_benefit: true @@ -201,7 +201,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/dwp/pension_credit/pension_credit_statutory_pay_earnings.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_statutory_pay_earnings.yaml index 18bf3646d7..ad838e72bf 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_statutory_pay_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_statutory_pay_earnings.yaml @@ -383,4 +383,3 @@ # 17,147), 0.6 * (19,855 - 17,147)) = min(1,045.20, 1,624.80) = 1,045.20; # amount B = 0.4 * (19,855 - 18,889) = 386.40; savings credit 658.80. savings_credit: 658.80 - diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml index 7627060033..18b4edc2a4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml @@ -53,7 +53,7 @@ uc_is_in_startup_period: true uc_income_tax_on_earnings: 0 uc_national_insurance_on_earnings: 0 - income_tax: 0 + income_tax_before_winter_fuel_payment_charge: 0 national_insurance: 0 child_1: age: 1 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/dwp/winter_fuel_payment/relevant_benefit_receipt.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/winter_fuel_payment/relevant_benefit_receipt.yaml index bfd3411b3e..f88fc548f1 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 @@ -300,7 +300,7 @@ people: pensioner: age: 70 - total_income: 50_000 + total_income_charged_to_tax: 50_000 son: age: 40 benunits: @@ -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 @@ -327,7 +328,7 @@ people: pensioner: age: 70 - total_income: 15_000 + total_income_charged_to_tax: 15_000 son: age: 40 benunits: @@ -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..27ed75a6e2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.yaml @@ -0,0 +1,1166 @@ +# 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). 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. + period: 2025 + input: + people: + pensioner: + age: 70 + total_income_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: 50_000 + partner_2: + age: 72 + total_income_charged_to_tax: 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_charged_to_tax: 40_000 + partner_2: + age: 85 + total_income_charged_to_tax: 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_charged_to_tax: 50_000 + aged_85: + age: 85 + total_income_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: 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 the partner of an Income Support claimant is paid and charged + # The couple's Income Support is the claimant's, who reports it and is + # under pensionable age, so is not entitled to a payment. The partner has + # reached pensionable age and is entitled (reg. 2), at £200 (reg. 3(1)). The + # partner is not entitled to Income Support (only the claimant is), so + # s.681I(4) does not exempt them, and their total income exceeds £35,000. + period: 2025 + input: + people: + claimant: + age: 60 + income_support_reported: 1_000 + partner: + age: 70 + total_income_charged_to_tax: 40_000 + benunits: + 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] + country: ENGLAND + output: + is_on_income_support: [true, false] + winter_fuel_payment: [0, 200] + winter_fuel_payment_charge: [0, 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_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: 40_000 + partner_2: + age: 85 + total_income_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: [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 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. + 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 + +- 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: 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: + pensioner: + age: 70 + private_pension_income: 32_000 + property_rental_income: 3_500 + property_income: 3_300 + property_finance_costs: 1_500 + households: + household: + members: [pensioner] + country: ENGLAND + output: + 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_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 + # 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] + 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] + +- 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. 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 + # 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] + +- 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 + # (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 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: + 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: [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: [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 + # £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] + +- 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(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 + 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 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): + # £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] + +# Pension Credit's earnings rules (SPC Regs 2002 regs 17(10) and 17(11) and +# Sch VI, #2019) take the income tax on earnings, like every means test, +# before the charge: the charge recovers a payment, and it depends on Pension +# Credit, so counting it would make the model circular. + +- name: 2026 a lone pensioner's earnings disregard works alongside the charge + # Review r12's reproducer, which raised CycleError while Pension Credit's + # earnings tax read income tax after the charge. London, aged 70, £2,000 of + # employment income and no State Pension: income tax nil (under the £12,570 + # personal allowance) and no National Insurance (over State Pension age). + # Sch VI para 5(a): £5 a week for a single claimant, 5 x 52 = £260, under + # the net earnings of £2,000. Pension Credit income: 2,000 - 260 = £1,740. + # Total income £2,000 is under £35,000: the £200 payment is not charged. + period: 2026 + input: + people: + claimant: + age: 70 + employment_income: 2_000 + state_pension: 0 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + region: LONDON + output: + income_tax_before_winter_fuel_payment_charge: [0] + pension_credit_employment_income_tax: 0 + pension_credit_earnings_disregard: 260 + pension_credit_income: 1_740 + winter_fuel_payment: [200] + winter_fuel_payment_charge: [0] + income_tax: [0] + +- name: 2026 Pension Credit's tax on earnings leaves out the charge the earnings bring + # London, aged 70, £34,000 of private pension and £2,000 of employment + # income. Total income £36,000 exceeds £35,000 and there is no relevant + # benefit (Pension Credit income is far above the guarantee), so the £200 + # payment is charged. Tax before the charge: (36,000 - 12,570) x 20% = + # £4,686, within the £37,700 basic rate band; with the charge, £4,886. + # Without the earnings: (34,000 - 12,570) x 20% = £4,286, and total income + # £34,000 is not charged. Taking earnings as the top slice (#2019's option + # (a)), they bear 4,686 - 4,286 = £400 of tax; the £200 charge they also + # bring is not tax on them. Net earnings 2,000 - 400 = £1,600; disregard + # £260. Pension Credit income: 36,000 - 4,686 - 260 = £31,054. + period: 2026 + input: + people: + claimant: + age: 70 + private_pension_income: 34_000 + employment_income: 2_000 + state_pension: 0 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + region: LONDON + output: + total_income_charged_to_tax: [36_000] + income_tax_before_winter_fuel_payment_charge: [4_686] + pension_credit_income_tax_without_employment_income: [4_286] + pension_credit_employment_income_tax: 400 + pension_credit_earnings_disregard: 260 + pension_credit_income: 31_054 + pension_credit: 0 + winter_fuel_payment: [200] + winter_fuel_payment_charge: [200] + income_tax: [4_886] 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 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 87ec0723dc..caa1d8b675 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,10 +52,10 @@ output: winter_fuel_allowance: 0 -# The limit is inclusive: the winter fuel payment charge the income test stands -# in for applies only where total income "exceeds" the limit (ITEPA 2003 -# s.681I(1)(b)), so income exactly at the limit keeps the payment. -- name: Enable income test - pensioner exactly at threshold keeps 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: @@ -220,3 +227,72 @@ jsa_income: 0 output: winter_fuel_allowance: 0 + +- 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" + private_pension_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" + private_pension_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" + private_pension_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" + private_pension_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..9bd9911777 --- /dev/null +++ b/policyengine_uk/tests/policy/reforms/parametric/winter_fuel_payment_charge/charge_parameters.yaml @@ -0,0 +1,69 @@ +# 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_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: 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_charged_to_tax: 50_000 + pension_credit: 0 + 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_charged_to_tax: 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 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 e317bf68e5..1974206aac 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) @@ -236,7 +241,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"] ) @@ -336,8 +341,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] @@ -347,7 +352,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_council_tax_reduction_pension_credit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py index 8de7c8e4cd..8f1c6c793b 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 @@ -284,7 +284,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 @@ -515,6 +518,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_council_tax_reduction_prescribed_benefits_properties.py b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py index 3bfc7883ef..173d6e8fcb 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py @@ -44,8 +44,8 @@ the savings credit where the award is savings credit only (para 14), and Pension Credit income otherwise. 2. Pound for pound, every scheme: raising IIDB or IB by d, or awarding SDA, - raises Council Tax Reduction income plus the family's income tax and - National Insurance by exactly the added amount, wherever the general rules + raises Council Tax Reduction income plus the family's pre-charge income tax + and National Insurance by exactly the added amount, wherever the general rules apply before and after and the zero floor does not bind. The reduction never rises where the route (general rules, savings credit only, guarantee credit) is unchanged; in the national schemes, where both awards are @@ -59,8 +59,8 @@ general rules, in every scheme. These compare the model's own measures. Invariant 1 relies on both measures -deducting the family's whole income tax and National Insurance, an existing -approximation in each. +deducting the family's whole pre-charge income tax and National Insurance, an +existing approximation in each. """ import numpy as np @@ -111,7 +111,7 @@ "pension_credit_income", "pension_credit", ] -PERSON_TOTALS = ["income_tax", "national_insurance"] +PERSON_TOTALS = ["income_tax_before_winter_fuel_payment_charge", "national_insurance"] def money(high): 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..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 @@ -27,19 +27,25 @@ 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, 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 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 +182,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 +241,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 +266,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_pension_credit_carer_income_properties.py b/policyengine_uk/tests/test_pension_credit_carer_income_properties.py index b0d4fcf75c..56b14e5b7b 100644 --- a/policyengine_uk/tests/test_pension_credit_carer_income_properties.py +++ b/policyengine_uk/tests/test_pension_credit_carer_income_properties.py @@ -199,7 +199,7 @@ def by_benunit(variable): "carers_allowance", "carer_support_payment", "scottish_carer_supplement", - "income_tax", + "income_tax_before_winter_fuel_payment_charge", ]: g[variable] = by_benunit(variable) return g @@ -274,8 +274,13 @@ def test_scottish_carer_supplement_is_not_means_tested_income(families): without["carer_support_payment"][i], ), fam for variable in ["pension_credit_income", "housing_benefit_applicable_income"]: - with_supplement = g[variable][i] + g["income_tax"][i] - without_supplement = without[variable][i] + without["income_tax"][i] + with_supplement = ( + g[variable][i] + g["income_tax_before_winter_fuel_payment_charge"][i] + ) + without_supplement = ( + without[variable][i] + + without["income_tax_before_winter_fuel_payment_charge"][i] + ) if variable == "housing_benefit_applicable_income": # Reg 27(4)(a) adds a savings-credit-only award's savings # credit, which moves with the tax on the supplement. diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py index 8b8696e236..58b2decbed 100644 --- a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -200,13 +200,15 @@ def test_statute_bounds_differential_and_monotonicity(case): 0, employment + self_employment - class_1 - notional - contributions ) - income_tax = person_values(sim, "income_tax") + # Pension Credit's tax deductions exclude recovery of the winter fuel payment. + income_tax = person_values(sim, "income_tax_before_winter_fuel_payment_charge") no_employment = person_values( - simulate(without(case, ["employment_income"])), "income_tax" + simulate(without(case, ["employment_income"])), + "income_tax_before_winter_fuel_payment_charge", ) no_earnings = person_values( simulate(without(case, ["employment_income", "self_employment_income"])), - "income_tax", + "income_tax_before_winter_fuel_payment_charge", ) employment_tax = max(0.0, float((income_tax - no_employment)[adult].sum())) self_employment_tax = max(0.0, float((no_employment - no_earnings)[adult].sum())) diff --git a/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py index cbdfaa5d61..abc0f6fac4 100644 --- a/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py +++ b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py @@ -42,6 +42,10 @@ Insurance (the model taxes IB), less any Carer's Allowance or Carer Support Payment the benefit displaces: SDA, IB and MA overlap with them (Social Security (Overlapping Benefits) Regulations 1979; carers_allowance_overlapping_benefits). + The income tax is the liability before the winter fuel payment charge + (ITEPA 2003 s. 681I), which Pension Credit income deducts: the charge + recovers a winter payment rather than taxing the income assessed. Here and + in invariant 3, "income tax" means that liability. 3. Savings Credit income never exceeds Pension Credit income. Adding an amount of a reg 9 source (working tax credit, IB, SDA, MA, contribution-based JSA or contributory ESA) changes Savings Credit income only through income tax @@ -74,6 +78,9 @@ PENSIONS = np.arange(0, 25_001, 500) AMOUNTS = np.arange(0, 15_001, 1_250) PRESCRIBED_BENEFITS = ("iidb", "sda", "incapacity_benefit", "maternity_allowance") +# The income tax Pension Credit income deducts: the liability before the +# winter fuel payment charge. +INCOME_TAX = "income_tax_before_winter_fuel_payment_charge" # Reg 9 sources and the input each is set through: the tax base reads # reported contribution-based JSA and contributory ESA. REG_9_INPUTS = { @@ -247,7 +254,7 @@ def test_guarantee_credit_falls_pound_for_pound_with_each_prescribed_benefit( [ "guarantee_credit", "pension_credit_income", - "income_tax", + INCOME_TAX, "national_insurance", "carers_allowance", "carer_support_payment", @@ -255,7 +262,7 @@ def test_guarantee_credit_falls_pound_for_pound_with_each_prescribed_benefit( ) gc = g["guarantee_credit"] pc_income = g["pension_credit_income"] - tax = g["income_tax"] + g["national_insurance"] + tax = g[INCOME_TAX] + g["national_insurance"] carer_benefit = g["carers_allowance"] + g["carer_support_payment"] assert np.all(np.diff(gc, axis=-1) <= 0.01), (families, gc) # The extra Pension Credit income is the extra benefit less the extra tax @@ -304,7 +311,7 @@ def test_reg_9_sources_change_savings_credit_income_only_through_tax( "pension_credit_income", "savings_credit_income", "savings_credit", - "income_tax", + INCOME_TAX, "national_insurance", "carers_allowance", "carer_support_payment", @@ -318,7 +325,7 @@ def test_reg_9_sources_change_savings_credit_income_only_through_tax( amount, savings_credit, ) - tax = g["income_tax"] + g["national_insurance"] + tax = g[INCOME_TAX] + g["national_insurance"] extra_tax = tax[..., 1] - tax[..., 0] carer_benefit = g["carers_allowance"] + g["carer_support_payment"] carer_change = carer_benefit[..., 1] - carer_benefit[..., 0] 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 dcee71bf6d..75b2343cee 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 @@ -39,7 +39,7 @@ capped at earnings and is given whether or not there are earnings. Every other difference is excluded by the draws: no pension contributions, maintenance, maternity allowance or Sure Start maternity grant, and both - measures deduct the same income tax and National Insurance of the + measures deduct the same pre-charge income tax and National Insurance of the claimant and partner. 2. Metamorphic: moving an amount between employment income and statutory sick or maternity pay of the same person changes nothing: Pension Credit @@ -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 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. """ @@ -169,7 +171,11 @@ def situation(families, steps, vary): "housing_benefit_applicable_income", "housing_benefit_applicable_income_disregard", ] -SUMMED_PERSON_VARIABLES = ["income_tax", "national_insurance"] + STATUTORY_PAY +SUMMED_PERSON_VARIABLES = [ + "income_tax", + "income_tax_before_winter_fuel_payment_charge", + "national_insurance", +] + STATUTORY_PAY def grid(families, steps, vary): @@ -194,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") @@ -265,11 +272,11 @@ 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] - + g["income_tax"][i] + + g["income_tax_before_winter_fuel_payment_charge"][i] + g["national_insurance"][i] + g["pension_credit_earnings_disregard"][i] ) diff --git a/policyengine_uk/tests/test_property_income_properties.py b/policyengine_uk/tests/test_property_income_properties.py index b4b1a59d61..b804dd3172 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,11 @@ 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) - ) + # 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): switching = choosing & (position == k) @@ -361,3 +360,207 @@ 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 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"] +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) + 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. + 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], + ) + + +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/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_statutory_paternity_pay_income_tax_properties.py b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py index 034802a3ff..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,11 +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 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. +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 @@ -31,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. @@ -262,8 +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): - assert family_gain <= 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 @@ -376,3 +404,112 @@ 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 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 + # 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]) + 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(results, "income_tax_before_winter_fuel_payment_charge") - 0.2) < 0.01 + ) + assert abs(rise(results, "income_tax") - 200.2) < 0.01 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..cde3e9048c --- /dev/null +++ b/policyengine_uk/tests/test_total_income_net_pay_properties.py @@ -0,0 +1,167 @@ +"""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, 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 (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 taxable pay and + pension less the contributions relieved, up to the taxable pay, which only + contributions relieved at source can raise towards the £3,600 basic amount + (ss.190(3), 191(7)). +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), + "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), + "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, + "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, + "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}, + # 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): + 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["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) + total_income = taxable_pay + person["private_pension_income"] - net_pay + contributions = ( + person["employee_pension_contributions"] + + person["personal_pension_contributions"] + ) + basic_amount_increase = min( + max(0.0, 3_600.0 - taxable_pay), + person["personal_pension_contributions"], + ) + relieved = under_75 * min(contributions, taxable_pay + basic_amount_increase) + 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/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 2b0c05f5b3..1f18a4bd87 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -12,17 +12,25 @@ treatment without the loss (a separate simulation); adjusted total income is that less the pension contributions given relief once (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). -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). +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 + 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 + 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 @@ -30,10 +38,10 @@ 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 and Pension Credit's tax attribution to employed and - self-employed earnings are held alongside Income Tax and NI, including - the actual Class 2/4 components replaced by notional PC deductions. - The personal reliefs used for PC's notional tax are held too: the loss - can change entitlement to those reliefs without offsetting PC earnings. + self-employed earnings are held alongside them, including the actual + Class 2/4 components replaced by notional PC deductions. The personal + reliefs used for PC's notional tax are held too: the loss can change + entitlement to those reliefs without offsetting PC earnings. 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. @@ -49,7 +57,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 @@ -72,6 +82,17 @@ 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" +# 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, @@ -357,21 +378,39 @@ 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]) + + +# 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), @@ -380,18 +419,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 @@ -525,19 +592,56 @@ 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)). 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 = [ + ( + [ + 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"), + ), + ( + [ + adult( + age=70, + private_pension_income=40_000.0, + trading_loss=10_000.0, + hours_worked=0.0, + ) + ], + home(9_000.0), + ), +] + + @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. @@ -584,12 +688,38 @@ 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) + # 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 = [ - "income_tax", + PRE_CHARGE_TAX, "national_insurance", "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", @@ -746,6 +876,31 @@ def values(sim, variable): ), ] ) +@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)) @@ -756,7 +911,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", # PC replaces these actual components with CER notional deductions; # hold them consistently with the total national_insurance input. @@ -774,9 +929,10 @@ def values(sim, variable): ] tax = {v: values(with_loss, v) for v in deductions} # SPC Regs reg 17(11) substitutes CER notional deductions for the tax - # actually attributable to profits. Holding income_tax alone also holds - # it in the no-profit counterfactual, incorrectly setting that attribution - # to zero. Preserve both PC tax allocations from the with-loss side. + # actually attributable to profits. Holding the pre-charge tax alone also + # holds it in the no-profit counterfactual, incorrectly setting that + # attribution to zero. Preserve both PC tax allocations from the + # with-loss side. # In the child-loss example the adult has no loss: actual/notional tax # is (17,809.09433 - 12,570) x 20% = 1,047.818866, and notional Class 4 # is 5,239.09433 x 6% = 314.345660. Holding both tax allocations gives @@ -793,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): @@ -803,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, @@ -810,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) diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index bc961fe791..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 @@ -292,10 +335,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) diff --git a/policyengine_uk/tests/test_variable_code_sharing.py b/policyengine_uk/tests/test_variable_code_sharing.py index 7445f4ab9b..a775bd7b4a 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 @@ -55,8 +58,8 @@ def _skip_if_core_shares(core_shares_code): ) -def _income_tax_formula(simulation): - variable = simulation.tax_benefit_system.variables["income_tax"] +def _formula(simulation): + variable = simulation.tax_benefit_system.variables[FORMULA_VARIABLE] return variable.get_formula() @@ -79,10 +82,10 @@ 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_formula = _income_tax_formula(first) - second_formula = _income_tax_formula(second) + first_class = type(first.tax_benefit_system.variables[FORMULA_VARIABLE]) + second_class = type(second.tax_benefit_system.variables[FORMULA_VARIABLE]) + first_formula = _formula(first) + second_formula = _formula(second) assert first_class is not second_class assert ( diff --git a/policyengine_uk/tests/test_winter_fuel_payment_charge_means_tests_properties.py b/policyengine_uk/tests/test_winter_fuel_payment_charge_means_tests_properties.py new file mode 100644 index 0000000000..3eacfcc441 --- /dev/null +++ b/policyengine_uk/tests/test_winter_fuel_payment_charge_means_tests_properties.py @@ -0,0 +1,217 @@ +"""Means tests deduct income tax before the winter fuel payment charge. + +The charge (ITEPA 2003 s.681I) recovers a person's winter fuel payment +through income tax when their own total income exceeds £35,000 and they are +not entitled to a relevant benefit, Pension Credit and Universal Credit among +them. It depends on those benefits, so a means test that deducted income tax +after the charge would be circular: review r12 of #2066 found Pension +Credit's earnings rules (#2019) raising CycleError for a 70-year-old with +£2,000 of earnings. Every means test deducts the tax before the charge +(income_tax_before_winter_fuel_payment_charge) instead. + +Invariants, in 2025 and 2026, for single people and couples of any mix of +ages around State Pension age, who rent from the council in England, Wales or +Scotland under each country's national schemes, with earnings, profits, +pensions, savings interest and property income (with no finance costs, so no +one weighs the property allowance against expenses), comparing the model +with the charge in effect against the same households with it repealed: + +1. Every household computes: no means test is circular. +2. Differential: every means-tested benefit and the income each assesses + (Pension Credit with its earnings tax and disregards, Housing Benefit, + Council Tax Reduction, Universal Credit, the legacy income-related + benefits and tax credits) is the same with and without the charge. +3. The winter fuel payments and income tax before the charge are the same + with and without it, and income tax with the charge exceeds income tax + without it by exactly the charge. +4. Household net income with the charge is household net income without it + less the household's charges. + +The pinned example is a couple aged 70 on council rent in Maidstone. The +first partner's £36,000 private pension is over the threshold, and neither +partner is on a relevant benefit, so the first partner repays their £100 +share of the couple's £200 payment while the couple get Housing Benefit. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +REPEAL = { + "gov.hmrc.income_tax.charges.winter_fuel_payment.in_effect": { + "2025-01-01.2100-12-31": False + } +} +# Local authorities without a modelled local council tax reduction scheme, so +# each country's national scheme applies. +LOCAL_AUTHORITIES = { + "ENGLAND": "MAIDSTONE", + "WALES": "CARDIFF", + "SCOTLAND": "GLASGOW_CITY", +} +# Either side of State Pension age (66), of 75 (pension relief) and of 80 +# (the higher winter fuel payment); a couple with a member under State +# Pension age claims Universal Credit. +AGES = [60, 65, 66, 70, 75, 80, 85] +# Amounts either side of the £35,000 threshold, alone or combined, and of the +# allowances and disregards. +EMPLOYMENT = [0, 0, 2_000, 15_000, 36_000] +PROFITS = [0, 0, 3_000, 20_000] +PRIVATE_PENSIONS = [0, 10_000, 30_000, 34_000, 36_000] +STATE_PENSIONS = [0, 9_000, 12_000] +SAVINGS_INTEREST = [0, 500, 3_000] +PROPERTY_INCOME = [0, 800, 8_000] +PROPERTY_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +BENUNIT_VARIABLES = [ + "pension_credit_income", + "pension_credit_employment_income_tax", + "pension_credit_self_employment_income_tax", + "pension_credit_earnings_disregard", + "guarantee_credit", + "savings_credit", + "pension_credit", + "housing_benefit_applicable_income", + "housing_benefit", + "council_tax_reduction_applicable_income", + "council_tax_benefit", + "universal_credit", + "income_support", + "esa_income", + "jsa_income", + "tax_credits", +] +# Person-level values the charge must not move. +PERSON_VARIABLES = [ + "winter_fuel_payment", + "pension_age_winter_heating_payment", + "income_tax_before_winter_fuel_payment_charge", +] +CHARGE = "winter_fuel_payment_charge" + + +@st.composite +def adult(draw): + return dict( + age=draw(st.sampled_from(AGES)), + employment_income=draw(st.sampled_from(EMPLOYMENT)), + self_employment_income=draw(st.sampled_from(PROFITS)), + private_pension_income=draw(st.sampled_from(PRIVATE_PENSIONS)), + state_pension=draw(st.sampled_from(STATE_PENSIONS)), + savings_interest_income=draw(st.sampled_from(SAVINGS_INTEREST)), + property_income=draw(st.sampled_from(PROPERTY_INCOME)), + ) + + +@st.composite +def family(draw): + return dict( + adults=[draw(adult()) for _ in range(draw(st.integers(1, 2)))], + country=draw(st.sampled_from(sorted(LOCAL_AUTHORITIES))), + rent=draw(st.sampled_from([3_000, 8_000, 20_000])), + council_tax=draw(st.sampled_from([1_000, 2_500])), + savings=draw(st.sampled_from([0, 6_000])), + ) + + +PINNED_COUPLE = dict( + adults=[ + dict( + age=70, + employment_income=0, + self_employment_income=0, + private_pension_income=36_000, + state_pension=0, + savings_interest_income=0, + property_income=0, + ), + dict( + age=70, + employment_income=0, + self_employment_income=0, + private_pension_income=0, + state_pension=0, + savings_interest_income=0, + property_income=0, + ), + ], + country="ENGLAND", + rent=20_000, + council_tax=2_500, + savings=0, +) + + +def situation(families, year): + people, benunits, households = {}, {}, {} + for i, fam in enumerate(families): + names = [] + for j, a in enumerate(fam["adults"]): + name = f"p{i}_{j}" + people[name] = {variable: {year: value} for variable, value in a.items()} + names.append(name) + benunits[f"b{i}"] = {"members": names} + households[f"h{i}"] = { + "members": names, + "country": {year: fam["country"]}, + "local_authority": {year: LOCAL_AUTHORITIES[fam["country"]]}, + "tenure_type": {year: "RENT_FROM_COUNCIL"}, + "rent": {year: fam["rent"]}, + "council_tax": {year: fam["council_tax"]}, + "savings": {year: fam["savings"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def outcomes(families, year, reform=None): + simulation = Simulation(situation=situation(families, year), reform=reform) + + def values(variable, **kwargs): + return np.asarray(simulation.calculate(variable, year, **kwargs), dtype=float) + + result = { + variable: values(variable) + for variable in BENUNIT_VARIABLES + + PERSON_VARIABLES + + ["income_tax", CHARGE, "household_net_income"] + } + result["household_charge"] = values(CHARGE, map_to="household") + return result + + +@PROPERTY_SETTINGS +@example([PINNED_COUPLE], 2026) +@given(st.lists(family(), min_size=1, max_size=6), st.sampled_from([2025, 2026])) +def test_means_tests_never_see_the_charge(families, year): + charged = outcomes(families, year) + repealed = outcomes(families, year, REPEAL) + for variable in BENUNIT_VARIABLES + PERSON_VARIABLES: + assert np.allclose(charged[variable], repealed[variable], atol=0.01), ( + variable, + charged[variable], + repealed[variable], + families, + ) + charge = charged[CHARGE] + assert (repealed[CHARGE] == 0).all() + assert np.allclose( + charged["income_tax"] - repealed["income_tax"], charge, atol=0.01 + ), (charge, families) + assert np.allclose( + repealed["household_net_income"] - charged["household_net_income"], + charged["household_charge"], + atol=0.01, + ), (charged["household_charge"], families) + if families == [PINNED_COUPLE]: + # Total income £36,000 over £35,000, and no relevant benefit: the + # first partner repays their £100 shared amount (SI 2025/969). + assert charge.tolist() == [100, 0] + assert charged["pension_credit"][0] == 0 + assert charged["housing_benefit"][0] > 0 diff --git a/policyengine_uk/tests/test_winter_heating_payment_properties.py b/policyengine_uk/tests/test_winter_heating_payment_properties.py index 66b165de44..8339e60b74 100644 --- a/policyengine_uk/tests/test_winter_heating_payment_properties.py +++ b/policyengine_uk/tests/test_winter_heating_payment_properties.py @@ -1,9 +1,11 @@ """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). Whether a -person is on a relevant benefit depends on the instrument: +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). Whether a person is on a relevant benefit depends on the +instrument: - "paid to" the person (SI 2000/729, SI 2025/969): Income Support, income-based JSA, income-related ESA and Pension Credit are paid to one @@ -18,11 +20,21 @@ - through the couple (SI 2024/869 reg 2(5); SSI 2024/351 reg 7(2), 10(8) and 2A): a member of a couple is on one when the other member is. -Either way a couple on one receives a single payment. Properties: +Either way a couple on one receives a single payment. 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: the member a couple's award is paid to, +or both joint claimants of Universal Credit, but not the partner of an +Income Support, income-based JSA, income-related ESA or Pension Credit +claimant. 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 of a single person or a couple @@ -50,9 +62,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] -# 35,000 is the charge limit itself: ITEPA 2003 s.681I(1)(b) charges only -# total income that "exceeds £35,000". -INCOMES = [5_000, 20_000, 35_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 = { @@ -121,7 +132,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 @@ -189,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))}, } @@ -218,13 +231,14 @@ def situation(drawn, year): return {"people": people, "benunits": benunits, "households": hh} -def reference_payments(household, year): - """WFP and PAWHP for each person, worked from the regulations. +def relevant_benefit_paid(household, relevant): + """Whether a relevant benefit is paid to each person of the household. - Everyone listed in AGES from 67 has reached pensionable age in these - years and everyone under 67 has not. + An outside member is on their own award. A couple's Income Support, + income-based JSA, income-related ESA and Pension Credit are paid to one + member, the claimant; Universal Credit and tax credits are joint awards, + paid to both. """ - country = household["country"] people = household_people(household) units = household["units"] @@ -253,13 +267,22 @@ def is_payee(i): return i in chosen return i == max(couple, key=lambda j: (people[j][0]["age"], -j)) - def paid(relevant): - """Whether a relevant benefit is paid to each person.""" - return [ - award_of(p) in relevant - and (not p[2] or award_of(p) in JOINT_AWARDS or is_payee(i)) - for i, p in enumerate(people) - ] + return [ + award_of(p) in relevant + and (not p[2] or award_of(p) in JOINT_AWARDS or is_payee(i)) + for i, p in enumerate(people) + ] + + +def reference_payments(household, year): + """WFP and PAWHP for each person, worked from the regulations. + + Everyone listed in AGES from 67 has reached pensionable age in these + years and everyone under 67 has not. + """ + country = household["country"] + people = household_people(household) + units = household["units"] def through_couple(receipt): """Each claimant or partner is on one when either of them is.""" @@ -272,7 +295,7 @@ def through_couple(receipt): def scheme(resident, relevant, means_test, amounts, partner_counts): lower, higher, shared, shared_80_with_under_80, shared_80_with_80 = amounts - paid_to = paid(relevant) + paid_to = relevant_benefit_paid(household, relevant) couple = through_couple(paid_to) on_relevant = couple if partner_counts else paid_to qualifies = [resident and p[0]["age"] >= 67 for p in people] @@ -334,17 +357,11 @@ def scheme(resident, relevant, means_test, amounts, partner_counts): 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 # Only SI 2024/869 (reg 2(5)) counts the other member's benefit. wfp = scheme(wfp_resident, WFP_RELEVANT[year], wfp_means, WFP_AMOUNTS, year == 2024) if year >= 2024 and country == "SCOTLAND": @@ -356,6 +373,29 @@ def scheme(resident, relevant, means_test, amounts, partner_counts): 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. Of a couple on + Income Support, income-based JSA, income-related ESA or Pension Credit + only the claimant, the member it is paid to, is entitled to it; both + joint claimants are entitled to Universal Credit. + """ + if year < 2025: + return [0.0] * len(payments) + entitled = relevant_benefit_paid(household, CHARGE_RELEVANT) + charges = [] + for (inputs, _, _), payment, exempt in zip( + household_people(household), payments, entitled + ): + liable = inputs["total_income"] > CHARGE_THRESHOLD and not exempt + charges.append(payment if liable else 0.0) + return charges + + SETTINGS = settings( max_examples=12, deadline=None, @@ -370,14 +410,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, @@ -478,7 +538,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, @@ -516,3 +576,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/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": diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py index a2897924d8..0edbc583b2 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py @@ -5,7 +5,8 @@ def person_net_earnings(person, period, pension_contribution_deduction_rate): """A person's net earnings under the legacy means tests' earnings rules. Employment income plus self-employment profit (each floored at zero) plus - statutory sick, maternity and paternity pay, less the income tax attributed to them, + statutory sick, maternity and paternity pay, less the income tax attributed to them + (before the winter fuel payment charge), Class 1, 2 and 4 National Insurance and the scheme's share of pension contributions, floored at zero. Housing Benefit (housing_benefit_net_earnings) and the Council Tax Reduction pensioner @@ -36,7 +37,10 @@ def person_net_earnings(person, period, pension_contribution_deduction_rate): out=np.zeros_like(earnings), where=income_before_losses > 0, ) - income_tax = person("income_tax", period) * min_(earnings_share, 1) + # Tax before the winter fuel payment charge: the charge recovers a + # payment and is not deducted from earnings. + tax = person("income_tax_before_winter_fuel_payment_charge", period) + income_tax = tax * min_(earnings_share, 1) national_insurance = add( person, period, ["ni_class_1_employee", "ni_class_2", "ni_class_4"] ) 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 792b327eeb..7d393e0df8 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 @@ -101,7 +101,10 @@ def hb_income_before_deductions(benunit, period, parameters): add_for_members(benunit, period, ["pension_contributions"], members) * p.pension_contribution_deduction_rate ) - 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/housing_benefit/applicable_income/housing_benefit_net_earnings.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py index ff9ef65b05..239604a49c 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py @@ -14,7 +14,8 @@ class housing_benefit_net_earnings(Variable): "income plus self-employment profit (each floored at zero, as a loss " "in one employment is not set against another) plus statutory sick, " "maternity and paternity pay (regulation 35(1)(i); pension age " - "regulation 35(1)(h) and (i)), less income tax, " + "regulation 35(1)(h) and (i)), less income tax (before the winter fuel " + "payment charge), " "Class 1, 2 and 4 National Insurance, and Housing Benefit's share of " "pension contributions (pension_contribution_deduction_rate, one-half " "in law), floored at zero. For an employee the regulations " 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 76fe44257d..18896f4d0e 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py @@ -53,7 +53,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) # IS Regs 1987 Sch 9 para 16(h): £10 a week of widowed parent's diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py index 60bac98423..5d50272dee 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_earnings_income_tax.py @@ -8,10 +8,16 @@ "earnings as the top slice of the person's income. " ) +# Income tax here is before the winter fuel payment charge, as in every means +# test: the charge recovers a payment rather than taxing earnings, and it +# depends on Pension Credit, so deducting it would make the model circular. +PRE_CHARGE_TAX = "income_tax_before_winter_fuel_payment_charge" + def income_tax_with_inputs_zeroed(simulation, period, variables): - """Each person's income tax with every person's value of each of - ``variables`` set to nil, on a branch that is dropped afterwards.""" + """Each person's income tax before the winter fuel payment charge with + every person's value of each of ``variables`` set to nil, on a branch + that is dropped afterwards.""" name = "pension_credit_without_" + "_and_".join(variables) while name in simulation.branches or name == simulation.branch_name: name += "_" @@ -23,7 +29,7 @@ def income_tax_with_inputs_zeroed(simulation, period, variables): people = branch.populations["person"] for variable in variables: branch.set_input(variable, period, np.zeros(people.count)) - return people("income_tax", period) + return people(PRE_CHARGE_TAX, period) finally: del simulation.branches[name] @@ -33,7 +39,8 @@ class pension_credit_income_tax_without_employment_income(Variable): entity = Person label = "Income tax without employed earnings, for Pension Credit" documentation = ( - "The person's income tax if nobody in the simulation had employed " + "The person's income tax before the winter fuel payment charge if " + "nobody in the simulation had employed " "earnings: employment income and the statutory sick, maternity and " "paternity pay that SPC Regs 2002 reg. 17A(2) counts as earnings. " + DECISION ) @@ -41,7 +48,7 @@ class pension_credit_income_tax_without_employment_income(Variable): unit = GBP def formula(person, period, parameters): - income_tax = person("income_tax", period) + income_tax = person(PRE_CHARGE_TAX, period) sources = employed_earnings_sources(parameters, period) if not any((person(source, period) != 0).any() for source in sources): return income_tax @@ -53,7 +60,8 @@ class pension_credit_income_tax_without_earnings(Variable): entity = Person label = "Income tax without earnings, for Pension Credit" documentation = ( - "The person's income tax if nobody in the simulation had employed " + "The person's income tax before the winter fuel payment charge if " + "nobody in the simulation had employed " "earnings (employment income and statutory sick, maternity and " "paternity pay) or self-employment income. " + DECISION ) @@ -82,6 +90,7 @@ class pension_credit_employment_income_tax(Variable): "earnings (employment income and statutory sick, maternity and " "paternity pay), which SPC Regs 2002 reg. 17(10) disregards in their " "net amount: the fall in the claimant's and partner's income tax " + "before the winter fuel payment charge " "without anyone's employed earnings, never negative. It includes any tax the " "earnings add on savings and dividends (a lost starting-rate band or " "personal savings allowance) and any effect of one partner's earnings " @@ -99,7 +108,7 @@ class pension_credit_employment_income_tax(Variable): def formula(benunit, period, parameters): person = benunit.members - added = person("income_tax", period) - person( + added = person(PRE_CHARGE_TAX, period) - person( "pension_credit_income_tax_without_employment_income", period ) claimant_or_partner = person("is_claimant_or_partner", period) @@ -112,7 +121,8 @@ class pension_credit_self_employment_income_tax(Variable): label = "Income tax actually payable on self-employed profit, for Pension Credit" documentation = ( "The income tax the claimant's and partner's self-employment income " - "adds to their income tax, beneath any employed earnings: the fall in " + "adds to their income tax before the winter fuel payment charge, " + "beneath any employed earnings: the fall in " "their income tax without anyone's employed earnings when nobody has " "self-employment income either, never negative. Pension Credit income " "replaces it with the notional tax of the Computation of Earnings " 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 86e9504e9c..57efbf50e8 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 @@ -56,7 +56,7 @@ def formula(benunit, period, parameters): person, period, [ - "income_tax", + "income_tax_before_winter_fuel_payment_charge", "national_insurance", "pension_credit_self_employment_deductions", ], 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 a4c3a9c9fd..50d80fd109 100644 --- a/policyengine_uk/variables/gov/dwp/winter_fuel_payment_eligible.py +++ b/policyengine_uk/variables/gov/dwp/winter_fuel_payment_eligible.py @@ -14,16 +14,17 @@ class winter_fuel_payment_eligible(Variable): "qualifying age for State Pension Credit) 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 who " + "meets the age and residence conditions 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 = ( @@ -32,6 +33,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): @@ -54,8 +56,9 @@ def formula(person, period, parameters): "is_on_winter_fuel_payment_relevant_benefit", period ) income_test = p.taxable_income_test - # ITEPA 2003 s.681I(1)(b): the charge applies only where total income - # "exceeds £35,000", so a person with exactly £35,000 keeps the payment. + # The deprecated test admits total income equal to the limit, as the + # charge it stood in for reaches only total income that "exceeds + # £35,000" (ITEPA 2003 s.681I(1)(b)). meets_income_test = ( person.household.any( age_eligible 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 6e77ce8650..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 @@ -13,17 +13,22 @@ "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 def income_tax_with_route(simulation, name, period, uses_allowance): """Each person's income tax with the given choices of property allowance. - 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. 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. """ while name in simulation.branches or name == simulation.branch_name: name += "_" @@ -45,12 +50,22 @@ 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, 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 " - "allowance is used whenever it lowers taxable property income." + "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 = [ @@ -82,9 +97,11 @@ 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. + # Without a reduction at stake, lower taxable property income means + # 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(): @@ -98,13 +115,15 @@ def formula(person, period, parameters): 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, for at most MAX_ROUNDS: routes still + # changing in the last round are kept as they stand. 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 @@ -126,8 +145,8 @@ def formula(person, period, parameters): 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/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..f71830a0a1 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/charges/winter_fuel_payment_charge.py @@ -0,0 +1,44 @@ +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. 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." + ) + 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) + # 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/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/hmrc/income_tax/reliefs/adjusted_total_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py index 0d4f98dd91..071f89ef76 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 @@ -2,7 +2,6 @@ from policyengine_uk.utils.pensions import ( pension_contributions_relief_amount, relevant_uk_earnings, - trading_profits_before_losses, ) @@ -12,17 +11,18 @@ 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 " - "and 4 deduct the pension contributions given relief (FA 2004 " - "ss.192-194): the person's own contributions up to relevant UK " - "earnings (s.189(2): employment income, including statutory pay, " - "less allowable expenses but before pension deductions, and trading " - "profits before losses). The basic amount can " - "increase that limit only through relief at source (ss.190(3), " + "s.24A(8)). Step 1 is total income (s.23 Step 1, " + "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 relevant UK earnings (s.189(2): employment income, including " + "statutory pay, less allowable expenses but before pension " + "deductions, and trading profits before losses). The basic amount " + "can increase that limit only through relief at source (ss.190(3), " "191(7)), which the model represents with gross personal pension " - "contributions; employee contributions represent net pay. None is relieved from " + "contributions; employee contributions represent net pay. " + "None is relieved from " "age 75 (s.188(3)(a)). The annual allowance limits tax-relieved " "saving through a charge, not the relief itself, so it is not applied " "here. Step 2 adds back payroll giving, which the model does not have." @@ -46,13 +46,7 @@ class adjusted_total_income(Variable): def formula(person, period, parameters): hmrc = parameters(period).gov.hmrc - losses_brought_forward_deducted = trading_profits_before_losses( - person, period, hmrc - ) - 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) # The same relief as pension_contributions_relief, on the same # relevant UK earnings (FA 2004 ss.189-191). relieved_contributions = pension_contributions_relief_amount( @@ -62,4 +56,14 @@ def formula(person, period, parameters): person("age", period), hmrc, ) - return max_(0, total_income - relieved_contributions) + # 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( + "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 new file mode 100644 index 0000000000..0ae522a8a3 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/total_income_charged_to_tax.py @@ -0,0 +1,60 @@ +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. 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." + ) + 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", + ), + 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 + 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 + ) + # 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..07b711b569 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/net_pay_pension_relief.py @@ -0,0 +1,48 @@ +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)). 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, 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)." + ) + definition_period = YEAR + unit = GBP + 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): + p = parameters(period).gov.hmrc.pensions + under_age_limit = ( + person("age", period) < p.pension_contributions_relief_age_limit + ) + return under_age_limit * min_( + person("employee_pension_contributions", period), + person("taxable_employment_income", period), + ) 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 540845a560..e1494e88cb 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 @@ -286,7 +286,10 @@ def formula(benunit, period, parameters): benunit, period, ["pension_contributions"], members ) * benunit("council_tax_reduction_pension_contribution_deduction_rate", period) tax = add_for_members( - benunit, period, ["income_tax", "national_insurance"], members + benunit, + period, + ["income_tax_before_winter_fuel_payment_charge", "national_insurance"], + members, ) # SI 2012/2885 Sch 1 para 17(9) and Sch 4; WSI 2013/3029 Sch 1 para # 11(9) and Sch 3; SSI 2012/319 reg 31(8) and Sch 2: the pensioner 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