From d5e40764042b4bd31ad3616337cb3982f8d483ff Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:54:54 -0400 Subject: [PATCH 1/3] Relieve a miscellaneous loss only against miscellaneous income (ITA 2007 s. 152(4)) Fixes #2326. taxable_miscellaneous_income added miscellaneous_income as it stood, so a loss reduced total and adjusted net income and the tax on pay, pensions, savings and dividends. A net loss now counts as nil; carrying it forward is not modelled. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2326.fixed.md | 1 + .../bases/taxable_miscellaneous_income.yaml | 38 ++++++++++ .../test_miscellaneous_loss_properties.py | 69 +++++++++++++++++++ .../bases/taxable_miscellaneous_income.py | 24 +++++-- 4 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 changelog.d/2326.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.yaml create mode 100644 policyengine_uk/tests/test_miscellaneous_loss_properties.py diff --git a/changelog.d/2326.fixed.md b/changelog.d/2326.fixed.md new file mode 100644 index 0000000000..8692add888 --- /dev/null +++ b/changelog.d/2326.fixed.md @@ -0,0 +1 @@ +- Stop relieving a miscellaneous loss against other income: ITA 2007 s. 152(4) allows it only against miscellaneous income. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.yaml new file mode 100644 index 0000000000..42874616cd --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.yaml @@ -0,0 +1,38 @@ +# A miscellaneous loss is relieved only against miscellaneous income +# (ITA 2007 s. 152(4)). 2026-27: personal allowance £12,570, basic rate 20% +# to £50,270, higher rate 40%. + +- name: Case 1, miscellaneous income is taxed with other income. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 30_000 + miscellaneous_income: 5_000 + output: + taxable_miscellaneous_income: 5_000 + # (35,000 - 12,570) x 20%. + income_tax: 4_486 + +- name: Case 2, a miscellaneous loss does not reduce tax on pay. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 50_000 + miscellaneous_income: -10_000 + output: + taxable_miscellaneous_income: 0 + # (50,000 - 12,570) x 20%, as with no loss. + income_tax: 7_486 + +- name: Case 3, nor adjusted net income. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 110_000 + miscellaneous_income: -20_000 + output: + taxable_miscellaneous_income: 0 + adjusted_net_income: 110_000 diff --git a/policyengine_uk/tests/test_miscellaneous_loss_properties.py b/policyengine_uk/tests/test_miscellaneous_loss_properties.py new file mode 100644 index 0000000000..4238e66f14 --- /dev/null +++ b/policyengine_uk/tests/test_miscellaneous_loss_properties.py @@ -0,0 +1,69 @@ +"""A miscellaneous loss is relieved only against miscellaneous income +(ITA 2007 s. 152(4)). + +Invariants, for any pay, savings, dividends and pension income: + +1. A miscellaneous loss leaves Income Tax and adjusted net income exactly as + they are with no miscellaneous income. +2. taxable_miscellaneous_income is never negative and equals the income when + it is positive. +""" + +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + + +def _calc(misc, pay, savings, dividends, pension): + situation = { + "people": { + "p": { + "age": {"2026": 50}, + "employment_income": {"2026": pay}, + "savings_interest_income": {"2026": savings}, + "dividend_income": {"2026": dividends}, + "private_pension_income": {"2026": pension}, + "miscellaneous_income": {"2026": misc}, + } + }, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"], "country": {"2026": "ENGLAND"}}}, + } + sim = Simulation(situation=situation) + return { + name: float(sim.calculate(name, 2026)[0]) + for name in ( + "income_tax", + "adjusted_net_income", + "taxable_miscellaneous_income", + ) + } + + +amounts = st.floats(min_value=0, max_value=150_000, allow_nan=False) + + +@settings(max_examples=10, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@given( + loss=st.floats(min_value=1, max_value=500_000, allow_nan=False), + pay=amounts, + savings=st.floats(min_value=0, max_value=20_000), + dividends=st.floats(min_value=0, max_value=20_000), + pension=st.floats(min_value=0, max_value=40_000), +) +def test_a_miscellaneous_loss_changes_nothing_else( + loss, pay, savings, dividends, pension +): + with_loss = _calc(-loss, pay, savings, dividends, pension) + without = _calc(0, pay, savings, dividends, pension) + assert with_loss["taxable_miscellaneous_income"] == 0 + assert abs(with_loss["income_tax"] - without["income_tax"]) < 0.01 + assert abs(with_loss["adjusted_net_income"] - without["adjusted_net_income"]) < 0.01 + + +@settings(max_examples=6, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@given(income=st.floats(min_value=0, max_value=100_000, allow_nan=False), pay=amounts) +def test_positive_miscellaneous_income_is_taxable_in_full(income, pay): + result = _calc(income, pay, 0, 0, 0) + assert abs(result["taxable_miscellaneous_income"] - income) < 0.01 diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py index 30f407bf45..563c959055 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py @@ -5,11 +5,25 @@ class taxable_miscellaneous_income(Variable): value_type = float entity = Person label = "Amount of miscellaneous income that is taxable" - definition_period = YEAR - reference = dict( - title="Income Tax Act (Trading and Other Income) 2005, s. 574", - href="https://www.legislation.gov.uk/ukpga/2005/5/section/574", + documentation = ( + "Miscellaneous income charged to income tax. A miscellaneous loss can " + "be relieved only against miscellaneous income (ITA 2007 s. 152(4)), " + "never against other income, so a net loss counts as nil here. Carrying " + "the loss forward against later years' miscellaneous income is not " + "modelled." ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax (Trading and Other Income) Act 2005 s. 574", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/574", + ), + dict( + title="Income Tax Act 2007 s. 152", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/152", + ), + ] unit = GBP - adds = ["miscellaneous_income"] + def formula(person, period, parameters): + return max_(0, person("miscellaneous_income", period)) From 7145d5b6085e0de403e4fca3df28f6ab5abbdb6f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:53:58 -0400 Subject: [PATCH 2/3] Keep a miscellaneous loss out of total income and UC earnings too Review changes: total_income (ITA 2007 s. 23 Step 1) and Universal Credit earned income now read taxable_miscellaneous_income, so a loss neither reduces total income (the Winter Fuel test, non-dependant deductions) nor widens the UC earnings error in #1961. The docstring states s. 152(5)'s same-provision limit and the input's wider scope. Tests add the personal allowance taper, total income and UC earnings; the property test covers total income, with 25 examples and a taper-region example. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2326.fixed.md | 2 +- .../bases/taxable_miscellaneous_income.yaml | 25 +++++++++++++++++++ .../test_miscellaneous_loss_properties.py | 16 ++++++------ .../uc_individual_earned_income_before_mif.py | 5 +++- .../bases/taxable_miscellaneous_income.py | 16 ++++++++---- .../gov/hmrc/income_tax/total_income.py | 3 ++- 6 files changed, 52 insertions(+), 15 deletions(-) diff --git a/changelog.d/2326.fixed.md b/changelog.d/2326.fixed.md index 8692add888..d486a2a5a0 100644 --- a/changelog.d/2326.fixed.md +++ b/changelog.d/2326.fixed.md @@ -1 +1 @@ -- Stop relieving a miscellaneous loss against other income: ITA 2007 s. 152(4) allows it only against miscellaneous income. +- Stop relieving a miscellaneous loss against other income in Income Tax, adjusted net income, total income and Universal Credit earnings: ITA 2007 s. 152(4) allows it only against miscellaneous income. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.yaml index 42874616cd..08d5fcf444 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.yaml @@ -36,3 +36,28 @@ output: taxable_miscellaneous_income: 0 adjusted_net_income: 110_000 + # Personal allowance 12,570 - (110,000 - 100,000) / 2 = 7,570; taxable + # 102,430: 37,700 x 20% + 64,730 x 40% = 33,432. + income_tax: 33_432 + +- name: Case 4, total income ignores a miscellaneous loss. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + private_pension_income: 40_000 + miscellaneous_income: -10_000 + output: + total_income: 40_000 + +- name: Case 5, a miscellaneous loss does not reduce Universal Credit earnings. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 30 + employment_income: 30_000 + miscellaneous_income: -10_000 + output: + # 30,000 less income tax (30,000 - 12,570) x 20% = 3,486 and primary + # Class 1 (30,000 - 12,570) x 8% = 1,394.40. + uc_individual_earned_income_before_mif: 30_000 - 3_486 - 1_394.40 diff --git a/policyengine_uk/tests/test_miscellaneous_loss_properties.py b/policyengine_uk/tests/test_miscellaneous_loss_properties.py index 4238e66f14..79ff5589aa 100644 --- a/policyengine_uk/tests/test_miscellaneous_loss_properties.py +++ b/policyengine_uk/tests/test_miscellaneous_loss_properties.py @@ -1,15 +1,15 @@ """A miscellaneous loss is relieved only against miscellaneous income (ITA 2007 s. 152(4)). -Invariants, for any pay, savings, dividends and pension income: +Invariants: -1. A miscellaneous loss leaves Income Tax and adjusted net income exactly as - they are with no miscellaneous income. -2. taxable_miscellaneous_income is never negative and equals the income when - it is positive. +1. For any pay, savings, dividends and pension income, a miscellaneous loss + leaves Income Tax, adjusted net income and total income exactly as they are + with no miscellaneous income. +2. For any pay, positive miscellaneous income is taxable in full. """ -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -44,7 +44,8 @@ def _calc(misc, pay, savings, dividends, pension): amounts = st.floats(min_value=0, max_value=150_000, allow_nan=False) -@settings(max_examples=10, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@settings(max_examples=25, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@example(loss=20_000, pay=110_000, savings=0, dividends=0, pension=0) @given( loss=st.floats(min_value=1, max_value=500_000, allow_nan=False), pay=amounts, @@ -60,6 +61,7 @@ def test_a_miscellaneous_loss_changes_nothing_else( assert with_loss["taxable_miscellaneous_income"] == 0 assert abs(with_loss["income_tax"] - without["income_tax"]) < 0.01 assert abs(with_loss["adjusted_net_income"] - without["adjusted_net_income"]) < 0.01 + assert abs(with_loss["total_income"] - without["total_income"]) < 0.01 @settings(max_examples=6, deadline=None, suppress_health_check=[HealthCheck.too_slow]) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py index 73a93702a1..9bf59a96a5 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -35,7 +35,10 @@ def formula(person, period, parameters): # The deductions are the person's own and come off only their own # earnings, so one partner's tax, NI or pension contributions never # reduce the other partner's earned income. - earnings_components = ["employment_income", "miscellaneous_income"] + # Miscellaneous income as income tax counts it, so a loss does not + # reduce pay (and matches uc_income_tax_on_earnings). Whether it is + # earnings at all is issue #1961. + earnings_components = ["employment_income", "taxable_miscellaneous_income"] bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.include_in_means_tests: earnings_components.append("basic_income") diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py index 563c959055..ce8be05b12 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bases/taxable_miscellaneous_income.py @@ -6,11 +6,17 @@ class taxable_miscellaneous_income(Variable): entity = Person label = "Amount of miscellaneous income that is taxable" documentation = ( - "Miscellaneous income charged to income tax. A miscellaneous loss can " - "be relieved only against miscellaneous income (ITA 2007 s. 152(4)), " - "never against other income, so a net loss counts as nil here. Carrying " - "the loss forward against later years' miscellaneous income is not " - "modelled." + "Miscellaneous income charged to income tax. On a claim, a loss on a " + "miscellaneous transaction is relieved only against relevant " + "miscellaneous income: income under the same section 1016 provision as " + "the loss (ITA 2007 s. 152(4)-(5)). It is never set against other " + "income, so a net loss counts as nil here. The model holds one net " + "figure per person, so it assumes the items netted inside " + "miscellaneous_income can be relieved against each other; that can " + "undertax a person with a gain under one provision and a loss under " + "another. The input also covers income from sources outside section " + "1016, some of which carry no loss relief at all. Carrying a loss " + "forward against later years' miscellaneous income is not modelled." ) definition_period = YEAR reference = [ diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/total_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/total_income.py index f13a71fb41..0503c8447c 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/total_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/total_income.py @@ -20,5 +20,6 @@ class total_income(Variable): "property_income", "savings_interest_income", "dividend_income", - "miscellaneous_income", + # A miscellaneous loss is never part of total income (ITA 2007 s. 152(4)). + "taxable_miscellaneous_income", ] From 9e2192d193b68d61d88519044e4126be9ba5de29 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:56:33 -0400 Subject: [PATCH 3/3] Return total income from the miscellaneous-loss property test helper Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/tests/test_miscellaneous_loss_properties.py | 1 + 1 file changed, 1 insertion(+) diff --git a/policyengine_uk/tests/test_miscellaneous_loss_properties.py b/policyengine_uk/tests/test_miscellaneous_loss_properties.py index 79ff5589aa..e3f08a5734 100644 --- a/policyengine_uk/tests/test_miscellaneous_loss_properties.py +++ b/policyengine_uk/tests/test_miscellaneous_loss_properties.py @@ -36,6 +36,7 @@ def _calc(misc, pay, savings, dividends, pension): for name in ( "income_tax", "adjusted_net_income", + "total_income", "taxable_miscellaneous_income", ) }