From 291d29300d9ba14a61160baf142b0b1cb7690661 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 13:06:06 -0400 Subject: [PATCH 01/11] Relieve trading losses against general income by each programme's rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trading_loss (a loss as a positive amount, separate from the profit in self_employment_income) now reaches each programme by its own rule: - Income Tax: trade loss relief against general income (ITA 2007 s.64) reduces net income, and so adjusted net income, for the loss-making year. It is limited to net income (s.25(4)) and to the s.24A cap, the greater of £50,000 and 25% of adjusted total income (s.24A(5), (8)). It comes off non-savings income first, then savings, then dividends. - Class 4 and Class 2 NICs: the relief reduces trading profits only (SSCBA 1992 Sch 2 para 3(4); s.11(3) relevant profits), with losses brought forward (loss_relief, s.83). - Tax credits: the claimants' losses come off their income (SI 2002/2006 reg 3(1) Step 4). - Market income and HBAI household income count the loss as negative income. - Means tests read neither the loss nor anything net of it, so it never offsets other earnings (UC reg 57, HB reg 38(10), IS reg 38(11) etc.). loss_relief now holds only losses brought forward (s.83). It no longer adds the previous year's trading_loss, which relief against general income may already have used. trading_loss is uprated like self-employment income. Tests: YAML cases for the relief, cap, ordering, Class 4, tax credits and household income; Hypothesis properties for the relief definition, a differential against income cut in s.25 order, monotonicity and bounds, means tests seeing the loss only through tax, tax credit income, and the channels HBAI income can move through. Co-Authored-By: Claude Opus 5.5 --- changelog.d/trading-loss-relief.added.md | 1 + changelog.d/trading-loss-relief.changed.md | 1 + policyengine_uk/data/uprating_indices.yaml | 1 + ...ble_self_employment_income_deductions.yaml | 4 +- .../gov/hmrc/income_tax/reliefs/cap/README.md | 1 + .../hmrc/income_tax/reliefs/cap/amount.yaml | 13 + .../gov/hmrc/income_tax/reliefs/cap/rate.yaml | 14 + ...redits_applicable_income_trading_loss.yaml | 58 +++ .../hmrc/income_tax/reliefs/loss_relief.yaml | 93 +--- ...de_loss_relief_against_general_income.yaml | 113 +++++ .../class_4/ni_class_4_trading_loss.yaml | 46 ++ .../income/trading_loss_household_income.yaml | 42 ++ .../tests/test_trading_loss_properties.py | 446 ++++++++++++++++++ .../gov/dwp/tax_credits_applicable_income.py | 5 + .../hmrc/income_tax/adjusted_net_income.py | 8 +- .../received_allowances_dividend_income.py | 3 + .../received_allowances_savings_income.py | 8 +- .../gov/hmrc/income_tax/bases/trading_loss.py | 13 - .../reliefs/adjusted_total_income.py | 28 ++ .../reliefs/income_tax_relief_cap.py | 25 + .../hmrc/income_tax/reliefs/loss_relief.py | 23 +- ...rade_loss_relief_against_general_income.py | 40 ++ ...ss_relief_against_savings_and_dividends.py | 35 ++ .../national_insurance/class_2/ni_class_2.py | 4 +- .../national_insurance/class_4/ni_class_4.py | 5 +- .../class_4/ni_class_4_main.py | 2 +- .../class_4/ni_class_4_maximum.py | 2 +- .../class_4/ni_class_4_profits.py | 37 ++ .../income/hbai_household_net_income.py | 2 + .../income/household_market_income.py | 5 +- .../household/income/market_income.py | 6 +- .../variables/input/self_employment_income.py | 2 +- .../variables/input/trading_loss.py | 27 ++ 33 files changed, 1010 insertions(+), 103 deletions(-) create mode 100644 changelog.d/trading-loss-relief.added.md create mode 100644 changelog.d/trading-loss-relief.changed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/README.md create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/amount.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/rate.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/tax_credits_applicable_income_trading_loss.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml create mode 100644 policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml create mode 100644 policyengine_uk/tests/test_trading_loss_properties.py delete mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/reliefs/income_tax_relief_cap.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py create mode 100644 policyengine_uk/variables/input/trading_loss.py diff --git a/changelog.d/trading-loss-relief.added.md b/changelog.d/trading-loss-relief.added.md new file mode 100644 index 0000000000..fdd287f220 --- /dev/null +++ b/changelog.d/trading-loss-relief.added.md @@ -0,0 +1 @@ +Trade loss relief against general income (ITA 2007 s.64): a trading loss (`trading_loss`) now reduces net income for the loss-making year, within the s.24A cap of the greater of £50,000 and 25% of adjusted total income, taking non-savings income first, then savings, then dividends. New variables `trade_loss_relief_against_general_income`, `trade_loss_relief_against_savings_and_dividends`, `income_tax_relief_cap`, `adjusted_total_income` and `ni_class_4_profits`. diff --git a/changelog.d/trading-loss-relief.changed.md b/changelog.d/trading-loss-relief.changed.md new file mode 100644 index 0000000000..8b519a9743 --- /dev/null +++ b/changelog.d/trading-loss-relief.changed.md @@ -0,0 +1 @@ +`trading_loss` is now an uprated input read by each programme's own rule: Class 4 and Class 2 NICs deduct the relief from trading profits only (SSCBA 1992 Sch 2 para 3, s.11(3)), tax credit income deducts it (SI 2002/2006 reg 3(1) Step 4), market income and HBAI household income count it as negative income, and means tests never offset it against other earnings. `loss_relief` now holds only losses brought forward (s.83); it no longer adds the previous year's `trading_loss`, which relief against general income may already have used. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index fe3072c0c7..1af9f17e96 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -108,6 +108,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - sublet_income gov.economic_assumptions.yoy_growth.obr.per_capita.mixed_income: - self_employment_income +- trading_loss gov.economic_assumptions.yoy_growth.obr.private_pension_index: - private_pension_income gov.economic_assumptions.yoy_growth.ons.population: diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/bases/taxable_self_employment_income_deductions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/bases/taxable_self_employment_income_deductions.yaml index 9b7c33a1d3..3a919715bf 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/bases/taxable_self_employment_income_deductions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/bases/taxable_self_employment_income_deductions.yaml @@ -9,8 +9,8 @@ metadata: label: Deductions from self-employment income period: year reference: - - title: "Income Tax Act 2007, s. 64" - href: https://www.legislation.gov.uk/ukpga/2007/3/section/64 + - title: "Income Tax Act 2007, s. 83" + href: https://www.legislation.gov.uk/ukpga/2007/3/section/83 - title: "Capital Allowances Act 2001, s. 1" href: https://www.legislation.gov.uk/ukpga/2001/2/section/1 - title: "Income Tax (Trading and Other Income) Act 2005, s. 783A" diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/README.md b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/README.md new file mode 100644 index 0000000000..587ce7e3dc --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/README.md @@ -0,0 +1 @@ +# Cap on certain reliefs diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/amount.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/amount.yaml new file mode 100644 index 0000000000..362b663ca3 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/amount.yaml @@ -0,0 +1,13 @@ +description: Reliefs against general income listed in ITA 2007 s.24A, such as trade loss relief, are limited to the greater of this amount and a share of adjusted total income. +values: + 0000-01-01: .inf + 2013-04-06: 50_000 +metadata: + label: Cap on certain income tax reliefs, fixed amount + period: year + unit: currency-GBP + reference: + - title: Income Tax Act 2007 s. 24A(3) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/24A + - title: Finance Act 2013 Sch. 3 (cap introduced from 2013-14) + href: https://www.legislation.gov.uk/ukpga/2013/29/schedule/3 diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/rate.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/rate.yaml new file mode 100644 index 0000000000..96d7f3ad79 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/cap/rate.yaml @@ -0,0 +1,14 @@ +description: Reliefs against general income listed in ITA 2007 s.24A, such as trade loss relief, are limited to the greater of a fixed amount and this share of adjusted total income. +values: + # No cap before 2013-14: the fixed amount is infinite then. + 0000-01-01: 0 + 2013-04-06: 0.25 +metadata: + label: Cap on certain income tax reliefs, share of adjusted total income + period: year + unit: /1 + reference: + - title: Income Tax Act 2007 s. 24A(3) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/24A + - title: Finance Act 2013 Sch. 3 (cap introduced from 2013-14) + href: https://www.legislation.gov.uk/ukpga/2013/29/schedule/3 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/tax_credits_applicable_income_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/tax_credits_applicable_income_trading_loss.yaml new file mode 100644 index 0000000000..e968e3d542 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/tax_credits_applicable_income_trading_loss.yaml @@ -0,0 +1,58 @@ +# SI 2002/2006 reg 3(1) Step 4: a trading loss in the year is subtracted from +# the income of Steps 1 to 3, so it reduces the claimants' joint income. + +- name: A trading loss reduces the claimant's other income + period: 2024 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + employment_income: 20_000 + trading_loss: 5_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + tax_credits_applicable_income: 15_000 + +- name: One partner's loss reduces the couple's joint income + period: 2024 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + employment_income: 20_000 + partner: + age: 38 + trading_loss: 8_000 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + tax_credits_applicable_income: 12_000 + +- name: Income does not go below zero + period: 2024 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + employment_income: 3_000 + trading_loss: 9_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + tax_credits_applicable_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/loss_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/loss_relief.yaml index 8d976948fe..31a1e52134 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/loss_relief.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/loss_relief.yaml @@ -1,80 +1,39 @@ -- name: No trading losses - period: 2024 - input: - trading_loss: - 2023: 0 - 2024: 0 - output: - loss_relief: 0 - -- name: Current year trading loss only - period: 2024 - input: - trading_loss: - 2023: 0 - 2024: 5000 - output: - loss_relief: 5000 - -- name: Previous year trading loss only - period: 2024 - input: - trading_loss: - 2023: 3000 - 2024: 0 - output: - loss_relief: 3000 - -- name: Trading losses in both years - period: 2024 - input: - trading_loss: - 2023: 2500 - 2024: 3500 - output: - loss_relief: 6000 - -- name: High trading losses - period: 2024 - input: - trading_loss: - 2023: 50000 - 2024: 75000 - output: - loss_relief: 125000 +# loss_relief holds trade losses brought forward from earlier years (ITA 2007 +# s.83). A loss made in the year itself is trading_loss, relieved against +# general income instead (see trade_loss_relief_against_general_income.yaml). -- name: Very low positive trading losses +- name: Losses brought forward come off the trade's profits period: 2024 + absolute_error_margin: 0 input: - trading_loss: - 2023: 1 - 2024: 1 + self_employment_income: 10_000 + loss_relief: 4_000 + trading_allowance_deduction: 0 output: - loss_relief: 2 + taxable_self_employment_income: 6_000 -- name: Uneven trading losses +- name: Losses brought forward never reduce other income period: 2024 + absolute_error_margin: 0 input: - trading_loss: - 2023: 1234.56 - 2024: 5678.90 + self_employment_income: 1_000 + employment_income: 20_000 + loss_relief: 5_000 + trading_allowance_deduction: 0 output: - loss_relief: 6913.46 + taxable_self_employment_income: 0 + taxable_employment_income: 20_000 + adjusted_net_income: 20_000 -- name: Large previous year loss, small current year loss +- name: Last year's trading loss is not brought forward automatically period: 2024 + absolute_error_margin: 0 input: + self_employment_income: 10_000 + trading_allowance_deduction: 0 trading_loss: - 2023: 100000 - 2024: 1000 - output: - loss_relief: 101000 - -- name: Small previous year loss, large current year loss - period: 2024 - input: - trading_loss: - 2023: 1000 - 2024: 100000 + 2023: 5_000 + 2024: 0 output: - loss_relief: 101000 \ No newline at end of file + loss_relief: 0 + taxable_self_employment_income: 10_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml new file mode 100644 index 0000000000..fa6c2ecb71 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml @@ -0,0 +1,113 @@ +# Trade loss relief against general income: ITA 2007 s.64, limited to net +# income and, from 2013-14, to the greater of £50,000 and 25% of adjusted total +# income (s.24A). 2026-27 rates: Personal Allowance £12,570, basic rate 20% to +# £50,270, dividend ordinary rate 10.75%, dividend allowance £500. + +- name: No trading loss, no relief + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 40_000 + output: + trade_loss_relief_against_general_income: 0 + adjusted_net_income: 40_000 + +- name: A loss within the person's income is relieved in full against their pay + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 40_000 + trading_loss: 10_000 + output: + trade_loss_relief_against_general_income: 10_000 + adjusted_net_income: 30_000 + # (30,000 - 12,570) x 20%: the tax on £30,000 of pay. + income_tax: 3_486 + +- name: Relief is limited to net income and the Personal Allowance is wasted + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 8_000 + trading_loss: 20_000 + output: + trade_loss_relief_against_general_income: 8_000 + adjusted_net_income: 0 + income_tax: 0 + +- name: The cap is 25% of adjusted total income above £200,000 + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 300_000 + trading_loss: 200_000 + output: + adjusted_total_income: 300_000 + income_tax_relief_cap: 75_000 + trade_loss_relief_against_general_income: 75_000 + adjusted_net_income: 225_000 + +- name: The cap is never below £50,000 + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 150_000 + trading_loss: 80_000 + output: + income_tax_relief_cap: 50_000 + trade_loss_relief_against_general_income: 50_000 + +- name: Pension contributions come out of adjusted total income + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 300_000 + personal_pension_contributions: 40_000 + trading_loss: 200_000 + output: + adjusted_total_income: 260_000 + income_tax_relief_cap: 65_000 + trade_loss_relief_against_general_income: 65_000 + +- name: Relief lowers adjusted net income below £100,000 and restores the Personal Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 110_000 + trading_loss: 20_000 + output: + adjusted_net_income: 90_000 + personal_allowance: 12_570 + # 37,700 x 20% + (77,430 - 37,700) x 40%: the tax on £90,000 of pay. + income_tax: 23_432 + +- name: With no non-savings income the relief comes off dividends + period: 2026 + absolute_error_margin: 0.01 + input: + dividend_income: 60_000 + trading_loss: 20_000 + output: + trade_loss_relief_against_general_income: 20_000 + trade_loss_relief_against_savings_and_dividends: 20_000 + # (40,000 - 12,570 - 500) x 10.75%: the tax on £40,000 of dividends. + income_tax: 2_894.98 + +- name: Relief takes non-savings income first, then savings, then dividends + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 5_000 + savings_interest_income: 10_000 + dividend_income: 30_000 + trading_loss: 20_000 + output: + trade_loss_relief_against_general_income: 20_000 + trade_loss_relief_against_savings_and_dividends: 15_000 + # The £15,000 of relief left after the pay, with the Personal + # Allowance, takes all £10,000 of savings and £17,570 of dividends. + received_allowances_earned_income: 0 + received_allowances_savings_income: 10_000 + received_allowances_dividend_income: 17_570 + # £25,000 of dividends is left: (25,000 - 12,570 - 500) x 10.75%. + income_tax: 1_282.48 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml new file mode 100644 index 0000000000..50e49f65b2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml @@ -0,0 +1,46 @@ +# SSCBA 1992 Sch 2 para 3: the trade loss reliefs of ITA 2007 ss.64 and 83 +# reduce Class 4 profits, and para 3(4)(a) applies a loss relieved against +# general income to the profits of any of the person's trades. 2026-27: main +# rate 6% between £12,570 and £50,270. + +- name: A loss in one trade reduces another trade's Class 4 profits + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + trading_loss: 10_000 + output: + ni_class_4_profits: 20_000 + ni_class_4: 445.8 + +- name: Relief set against pay for Income Tax still reduces Class 4 profits + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 50_000 + self_employment_income: 20_000 + trading_loss: 15_000 + output: + trade_loss_relief_against_general_income: 15_000 + ni_class_4_profits: 5_000 + ni_class_4: 0 + +- name: Losses brought forward reduce Class 4 profits + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 20_000 + loss_relief: 5_000 + output: + ni_class_4_profits: 15_000 + ni_class_4: 145.8 + +- name: A loss with no profits pays no Class 4 and never goes below zero + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 40_000 + trading_loss: 10_000 + output: + ni_class_4_profits: 0 + ni_class_4: 0 diff --git a/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml b/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml new file mode 100644 index 0000000000..23595bcb87 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/income/trading_loss_household_income.yaml @@ -0,0 +1,42 @@ +# HBAI counts self-employment losses as negative income; household market +# income does the same. 2026-27: £40,000 of pay with a £10,000 trading loss +# pays the Income Tax due on £30,000 (£3,486, against £5,486 without the +# loss), while NI on the pay is unchanged. + +- name: A trading loss is negative market income + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 40_000 + trading_loss: 10_000 + output: + market_income: 30_000 + household_market_income: 30_000 + +- name: HBAI income falls by the loss less the Income Tax it saves + period: 2026 + absolute_error_margin: 1 + input: + people: + with_loss: + age: 40 + employment_income: 40_000 + trading_loss: 10_000 + without_loss: + age: 40 + employment_income: 40_000 + benunits: + benunit_1: + members: [with_loss] + benunit_2: + members: [without_loss] + households: + household_1: + members: [with_loss] + household_2: + members: [without_loss] + output: + income_tax: [3_486, 5_486] + # -10,000 of loss + 2,000 of Income Tax saved. + hbai_household_net_income: [24_320, 32_320] + household_net_income: [24_140, 32_140] diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py new file mode 100644 index 0000000000..5513a6804d --- /dev/null +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -0,0 +1,446 @@ +"""Property-based tests for the trading loss input (trading_loss). + +A trading loss is entered as a positive amount, separate from the profits in +self_employment_income, and each programme reads it by its own rule. +Invariants, for any people and incomes: + +1. Relief definition (ITA 2007 s.64, s.24A): trade loss relief against + general income equals min(loss, max(£50,000, 25% of adjusted total + income), net income), where net income is the adjusted net income of the + same person without the loss (a separate simulation). +2. Differential (s.25 order): 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. +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). +4. Means tests never offset the loss against other income: with Income Tax + and NI held at the values the loss gives, every means-tested benefit and + means-test income is what it is without the loss. (The model's means tests + deduct the year's Income Tax liability, so the loss reaches them only + through that tax.) +5. Tax credits (SI 2002/2006 reg 3(1) Step 4): the claimants' losses come off + their applicable income, never below zero, and the award never falls. +6. Household income: market income falls by exactly the household's losses, + and HBAI net income moves only through the loss itself, taxes and + means-tested or passported benefits; every other component is unchanged. +7. Class 4 (SSCBA 1992 Sch 2 para 3): Class 4 profits are self-employment + profits less losses brought forward and the year's relief, never below + zero, so Class 4 never rises with the loss and someone with no profits + pays none whatever their loss. +8. The s.24A cap starts in 2013-14. + +Comparisons allow float32 rounding: the model stores values as float32. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.variables.household.income.hbai_household_net_income import ( + HBAI_HOUSEHOLD_NET_INCOME_ADDS, + HBAI_HOUSEHOLD_NET_INCOME_SUBTRACTS, +) + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +REGIONS = ["LONDON", "NORTH_EAST", "SCOTLAND", "WALES"] +MEANS_TESTED = [ + "universal_credit", + "housing_benefit", + "council_tax_reduction", + "pension_credit", + "income_support", + "jsa_income", + "esa_income", + "uc_earned_income", + "housing_benefit_applicable_income", + "income_support_applicable_income", + "council_tax_reduction_applicable_income", + "pension_credit_income", + "benefit_cap_reduction", +] +# HBAI components the loss may move: the loss itself, taxes, and benefits +# that are means-tested or passported from a means-tested benefit. +HBAI_CHANNELS = { + "trading_loss", + "income_tax", + "national_insurance", + "student_loan_repayments", + "universal_credit", + "housing_benefit", + "council_tax_benefit", + "pension_credit", + "income_support", + "jsa_income", + "esa_income", + "working_tax_credit", + "child_tax_credit", + "tax_free_childcare", + "free_school_meals", + "free_school_fruit_veg", + "free_school_milk", + "healthy_start_vouchers", + "scottish_child_payment", + "cost_of_living_support_payment", +} + + +def money(high): + return st.one_of( + st.just(0.0), + st.floats(1, high, allow_nan=False, allow_infinity=False), + ) + + +def tolerance(*amounts): + # float32 has a 24-bit significand: a few ulps of the largest amount + # involved, plus a penny. + return 0.01 + 4e-7 * max([abs(float(a)) for a in amounts] + [1]) + + +@st.composite +def taxpayers(draw): + """One working-age person with income of every taxed kind and a loss.""" + return { + "age": draw(st.integers(18, 64)), + "employment_income": draw(money(250_000)), + "private_pension_income": draw(money(60_000)), + "savings_interest_income": draw(money(40_000)), + "dividend_income": draw(money(150_000)), + "property_income": draw(money(40_000)), + "self_employment_income": draw(money(80_000)), + "personal_pension_contributions": draw(money(30_000)), + "trading_loss": draw(money(300_000)), + "state_pension": 0.0, + } + + +def single_people(people, regions): + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (inputs, region) in enumerate(zip(people, regions)): + name = f"p{i}" + situation["people"][name] = {k: {YEAR: v} for k, v in inputs.items()} + situation["benunits"][f"b{i}"] = {"members": [name]} + situation["households"][f"h{i}"] = { + "members": [name], + "region": {YEAR: region}, + } + return situation + + +def calculate(situation, variables, year=YEAR): + sim = Simulation(situation=situation) + return {v: np.asarray(sim.calculate(v, year), dtype=float) for v in variables} + + +@PROPERTY_SETTINGS +@given( + st.lists(taxpayers(), min_size=1, max_size=8), + st.lists(st.sampled_from(REGIONS), min_size=8, max_size=8), +) +def test_relief_is_the_loss_within_net_income_and_the_cap(people, regions): + without = [{**p, "trading_loss": 0.0} for p in people] + a = calculate( + single_people(people, regions), + [ + "trade_loss_relief_against_general_income", + "adjusted_net_income", + "total_income", + "pension_contributions", + ], + ) + b = calculate(single_people(without, regions), ["adjusted_net_income"]) + for i, person in enumerate(people): + ati = max(0.0, a["total_income"][i] - a["pension_contributions"][i]) + cap = max(50_000.0, 0.25 * ati) + expected = min(person["trading_loss"], cap, b["adjusted_net_income"][i]) + relief = a["trade_loss_relief_against_general_income"][i] + assert abs(relief - expected) <= tolerance(expected, ati), (person, relief) + # Net income falls by exactly the relief. + assert abs( + b["adjusted_net_income"][i] - relief - a["adjusted_net_income"][i] + ) <= tolerance(b["adjusted_net_income"][i]), person + + +@st.composite +def portfolio_holders(draw): + """Pay, pension, savings and dividends only, so the reduced-income twin + differs from the person only in those four amounts.""" + return { + "age": draw(st.integers(18, 64)), + "employment_income": draw(money(250_000)), + "private_pension_income": draw(money(60_000)), + "savings_interest_income": draw(money(40_000)), + "dividend_income": draw(money(150_000)), + "trading_loss": draw(money(300_000)), + "state_pension": 0.0, + } + + +def reduced(person): + """The person's income cut by the relief, in the s.25 order the model + uses, computed outside the model.""" + pay = person["employment_income"] + pension = person["private_pension_income"] + savings = person["savings_interest_income"] + dividends = person["dividend_income"] + total = pay + pension + savings + dividends + relief = min(person["trading_loss"], max(50_000.0, 0.25 * total), total) + left = relief + out = {} + for name, amount in [ + ("employment_income", pay), + ("private_pension_income", pension), + ("savings_interest_income", savings), + ("dividend_income", dividends), + ]: + cut = min(left, amount) + out[name] = amount - cut + left -= cut + return {**person, **out, "trading_loss": 0.0}, relief + + +@PROPERTY_SETTINGS +@given( + st.lists(portfolio_holders(), min_size=1, max_size=8), + st.lists(st.sampled_from(REGIONS), min_size=8, max_size=8), +) +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"] + a = calculate(single_people(people, regions), variables) + b = calculate(single_people(twins, regions), ["income_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]) + + +@PROPERTY_SETTINGS +@given( + st.lists(taxpayers(), min_size=1, max_size=8), + st.lists(st.sampled_from(REGIONS), min_size=8, max_size=8), + st.floats(0, 1), +) +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"] + a = calculate(single_people(people, regions), variables) + s = calculate(single_people(smaller, regions), variables) + b = calculate(single_people(without, regions), variables) + 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 + if person["savings_interest_income"] == 0: + saved = b["income_tax"][i] - a["income_tax"][i] + relief = a["trade_loss_relief_against_general_income"][i] + assert saved <= relief + tol, (person, saved, relief) + + +@st.composite +def families(draw): + """A benefit unit with a head, maybe a partner and children, rent and + council tax, where the adults may have a trading loss.""" + head_age = draw(st.integers(18, 80)) + adults = [head_age] + if draw(st.booleans()): + adults.append(draw(st.integers(18, 80))) + people = [ + { + "age": age, + "employment_income": draw(money(60_000)), + "self_employment_income": draw(money(30_000)), + "private_pension_income": draw(money(20_000)), + "savings_interest_income": draw(money(5_000)), + "trading_loss": draw(money(40_000)), + "hours_worked": draw(st.sampled_from([0.0, 16.0, 35.0])), + } + for age in adults + ] + for _ in range(draw(st.integers(0, 3))): + people.append({"age": draw(st.integers(0, 15))}) + household = { + "rent": draw(money(15_000)), + "council_tax": draw(money(3_000)), + "tenure_type": draw(st.sampled_from(["RENT_PRIVATELY", "RENT_FROM_COUNCIL"])), + "region": draw(st.sampled_from(REGIONS)), + } + return people, household + + +def family_situation(units, overrides=None): + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (people, household) in enumerate(units): + names = [] + for j, inputs in enumerate(people): + name = f"p{i}_{j}" + values = dict(inputs) + if overrides is not None: + values.update(overrides.get(name, {})) + situation["people"][name] = {k: {YEAR: v} for k, v in values.items()} + names.append(name) + situation["benunits"][f"b{i}"] = {"members": names} + situation["households"][f"h{i}"] = { + "members": names, + **{k: {YEAR: v} for k, v in household.items()}, + } + return situation + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=5)) +def test_means_tests_see_the_loss_only_through_tax(units): + with_loss = Simulation(situation=family_situation(units)) + names = with_loss.populations["person"].ids + tax = { + v: np.asarray(with_loss.calculate(v, YEAR), dtype=float) + for v in ["income_tax", "national_insurance"] + } + overrides = { + name: { + "trading_loss": 0.0, + "income_tax": float(tax["income_tax"][k]), + "national_insurance": float(tax["national_insurance"][k]), + } + for k, name in enumerate(names) + } + held = Simulation(situation=family_situation(units, overrides)) + for variable in MEANS_TESTED: + a = np.asarray(with_loss.calculate(variable, YEAR), dtype=float) + b = np.asarray(held.calculate(variable, YEAR), dtype=float) + assert np.allclose(a, b, atol=0.5), (variable, a, b, units) + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=5)) +def test_tax_credit_income_falls_by_the_claimants_losses(units): + year = 2024 + without = [ + ([{**p, "trading_loss": 0.0} if "trading_loss" in p else p for p in people], h) + for people, h in units + ] + + def run(us): + situation = family_situation(us) + for person in situation["people"].values(): + for key, value in list(person.items()): + person[key] = {year: value[YEAR]} + for household in situation["households"].values(): + for key, value in list(household.items()): + if key != "members": + household[key] = {year: value[YEAR]} + sim = Simulation(situation=situation) + return { + v: np.asarray(sim.calculate(v, year), dtype=float) + for v in ["tax_credits_applicable_income", "tax_credits"] + }, sim + + a, sim = run(units) + b, _ = run(without) + members = sim.calculate("is_claimant_or_partner", year) + loss = np.asarray(sim.calculate("trading_loss", year), dtype=float) + benunit_loss = sim.map_result(loss * members, "person", "benunit") + expected = np.maximum(0, b["tax_credits_applicable_income"] - benunit_loss) + # On income-related IS, ESA or JSA the applicable income is nil anyway. + on_exempt = b["tax_credits_applicable_income"] == 0 + assert np.allclose( + a["tax_credits_applicable_income"], np.where(on_exempt, 0, expected), atol=0.5 + ), (a, expected, units) + assert np.all(a["tax_credits"] >= b["tax_credits"] - 0.5), units + + +@PROPERTY_SETTINGS +@given(st.lists(families(), min_size=1, max_size=5)) +def test_household_income_moves_only_through_its_channels(units): + without = [ + ([{**p, "trading_loss": 0.0} if "trading_loss" in p else p for p in people], h) + for people, h in units + ] + components = sorted( + set(HBAI_HOUSEHOLD_NET_INCOME_ADDS + HBAI_HOUSEHOLD_NET_INCOME_SUBTRACTS) + ) + variables = components + ["household_market_income", "hbai_household_net_income"] + a_sim = Simulation(situation=family_situation(units)) + b_sim = Simulation(situation=family_situation(without)) + + def household_values(sim, variable): + values = np.asarray(sim.calculate(variable, YEAR), dtype=float) + entity = sim.tax_benefit_system.get_variable(variable).entity.key + if entity == "household": + return values + return sim.map_result(values, entity, "household") + + a = {v: household_values(a_sim, v) for v in variables} + b = {v: household_values(b_sim, v) for v in variables} + losses = a["trading_loss"] + assert np.allclose( + a["household_market_income"], b["household_market_income"] - losses, atol=0.5 + ) + for variable in components: + if variable not in HBAI_CHANNELS: + assert np.allclose(a[variable], b[variable], atol=0.5), variable + channels = sum( + (a[v] - b[v]) * (1 if v in HBAI_HOUSEHOLD_NET_INCOME_ADDS else -1) + for v in components + if v in HBAI_CHANNELS + ) + assert np.allclose( + a["hbai_household_net_income"] - b["hbai_household_net_income"], + channels, + atol=1, + ) + + +@PROPERTY_SETTINGS +@given( + st.lists(taxpayers(), min_size=1, max_size=8), + st.lists(st.sampled_from(REGIONS), min_size=8, max_size=8), + st.lists(money(20_000), min_size=8, max_size=8), +) +def test_class_4_profits_net_of_the_relief(people, regions, brought_forward): + people = [{**p, "loss_relief": b} for p, b in zip(people, brought_forward)] + without = [{**p, "trading_loss": 0.0} for p in people] + variables = [ + "ni_class_4_profits", + "ni_class_4", + "trade_loss_relief_against_general_income", + ] + a = calculate(single_people(people, regions), variables) + b = calculate(single_people(without, regions), variables) + for i, person in enumerate(people): + expected = max( + 0.0, + person["self_employment_income"] + - person["loss_relief"] + - a["trade_loss_relief_against_general_income"][i], + ) + assert abs(a["ni_class_4_profits"][i] - expected) <= tolerance( + person["self_employment_income"] + ), person + assert a["ni_class_4"][i] <= b["ni_class_4"][i] + tolerance( + b["ni_class_4"][i] + ), person + if person["self_employment_income"] == 0: + assert a["ni_class_4"][i] == 0, person + + +def test_cap_starts_in_2013_14(): + cap = CountryTaxBenefitSystem().parameters.gov.hmrc.income_tax.reliefs.cap + assert cap.amount("2013-04-05") == np.inf + assert cap.amount("2013-04-06") == 50_000 + assert cap.rate("2013-04-06") == 0.25 diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index 1c698c1921..e76a16aeed 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -39,6 +39,11 @@ def formula(benunit, period, parameters): if bi.interactions.include_in_means_tests: STEP_2_COMPONENTS.append("basic_income") income += add_for_members(benunit, period, STEP_2_COMPONENTS, members) + # Step 4: a trading loss in the year is subtracted from the total of + # the other steps, so one partner's loss reduces the joint income. + income = max_( + 0, income - add_for_members(benunit, period, ["trading_loss"], members) + ) EXEMPT_BENEFITS = ["income_support", "esa_income", "jsa_income"] on_exempt_benefits = ( add_for_members(benunit, period, EXEMPT_BENEFITS, members) > 0 diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py index 3723b6a60a..481f5ed35c 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py @@ -17,8 +17,12 @@ def formula(person, period, parameters): period ).gov.hmrc.income_tax.adjusted_net_income_components - # Find adjusted net income - ani = add(person, period, adjusted_net_income_components) + # Find adjusted net income: net income, after trade loss relief + # against general income (ITA 2007 s.24(1), s.64), which reduces it + # before the Personal Allowance taper and every other ANI test. + ani = add(person, period, adjusted_net_income_components) - person( + "trade_loss_relief_against_general_income", period + ) # For basic income contributions, add basic income # Modifying param list directly is mutative, hence two-step process diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py index 3ec0cd9fd3..8503f249b8 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py @@ -17,8 +17,11 @@ def formula(person, period, parameters): received_allowances_savings_income = person( "received_allowances_savings_income", period ) + # Includes any trade loss relief beyond non-savings and savings + # income (ITA 2007 s.25(2)). remaining_allowance = ( all_allowances + + person("trade_loss_relief_against_savings_and_dividends", period) - received_allowances_earned_income - received_allowances_savings_income ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py index ba3469a3eb..f970da5855 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py @@ -14,7 +14,13 @@ def formula(person, period, parameters): received_allowances_earned_income = person( "received_allowances_earned_income", period ) - remaining_allowance = all_allowances - received_allowances_earned_income + # Trade loss relief beyond non-savings income comes off savings income + # next, then dividends (ITA 2007 s.25(2)). + remaining_allowance = ( + all_allowances + + person("trade_loss_relief_against_savings_and_dividends", period) + - received_allowances_earned_income + ) savings_income = person("taxable_savings_interest_income", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py deleted file mode 100644 index 0277b37f86..0000000000 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py +++ /dev/null @@ -1,13 +0,0 @@ -from policyengine_uk.model_api import * - - -class trading_loss(Variable): - value_type = float - entity = Person - label = "Loss from trading in the current year." - reference = dict( - title="Income Tax Act 2007 s. 64", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/64", - ) - definition_period = YEAR - unit = GBP 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 new file mode 100644 index 0000000000..3aab95d882 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class adjusted_total_income(Variable): + value_type = float + entity = Person + label = "Adjusted total income" + documentation = ( + "The base of the cap on reliefs against general income (ITA 2007 " + "s.24A(8)): total income, plus payroll giving, less pension " + "contributions given relief at source or under net pay or claim " + "(FA 2004 ss.192, 193(4), 194(1)). The model deducts the person's own " + "pension contributions as made, because its relief variable depends " + "on adjusted net income through the tapered annual allowance, and " + "does not model payroll giving." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Income Tax Act 2007 s. 24A", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/24A", + ) + + def formula(person, period, parameters): + return max_( + 0, + person("total_income", period) - person("pension_contributions", period), + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/income_tax_relief_cap.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/income_tax_relief_cap.py new file mode 100644 index 0000000000..482ef814fd --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/income_tax_relief_cap.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class income_tax_relief_cap(Variable): + value_type = float + entity = Person + label = "Cap on certain Income Tax reliefs" + documentation = ( + "The most that the reliefs listed in ITA 2007 s.24A(6), of which the " + "model has trade loss relief against general income, can deduct at " + "Step 2 in the year: '£50,000, or ... if more, 25% of the taxpayer's " + "adjusted total income' (s.24A(5)). Deductions from profits of the " + "loss-making trade itself fall outside the cap (s.24A(7)(b)); in the " + "loss-making year that trade has no profits." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Income Tax Act 2007 s. 24A", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/24A", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.income_tax.reliefs.cap + return max_(p.amount, p.rate * person("adjusted_total_income", period)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/loss_relief.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/loss_relief.py index ced48ecb1e..0b27ae0104 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/loss_relief.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/loss_relief.py @@ -8,16 +8,19 @@ class loss_relief(Variable): value_type = float entity = Person - label = "Tax relief from trading losses" - definition_period = YEAR + label = "Trade losses brought forward" reference = dict( - title="Income Tax (Trading and Other Income) Act 2005 s. 59", - href="https://www.legislation.gov.uk/ukpga/2005/5/section/59", + title="Income Tax Act 2007 s. 83", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/83", + ) + documentation = ( + "Unrelieved trade losses of earlier years deducted from this year's " + "profits of the same trade (ITA 2007 s.83). Deducted from " + "self-employment income only, and never below zero. A loss made in " + "this year goes in trading_loss, which is relieved against general " + "income instead (s.64); whatever part of it this year's income cannot " + "absorb is carried forward by entering it here in later years." ) - documentation = "Can be set against general income." + definition_period = YEAR unit = GBP - - def formula(person, period, parameters): - current_loss = person("trading_loss", period) - previous_loss = person("trading_loss", period.last_year) - return current_loss + previous_loss + quantity_type = FLOW diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py new file mode 100644 index 0000000000..c181a4cd08 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py @@ -0,0 +1,40 @@ +from policyengine_uk.model_api import * + + +class trade_loss_relief_against_general_income(Variable): + value_type = float + entity = Person + label = "Trade loss relief against general income" + documentation = ( + "The part of the year's trading loss deducted in calculating net income " + "for the loss-making year (ITA 2007 s.64(2)(a)): the whole loss, " + "'limited in accordance with sections 24A and 25(4) and (5)' (s.65(1)), " + "so to the s.24A cap and to the net income there is to deduct it from. " + "Nothing lets a claim be restricted to keep the Personal Allowance in " + "use. The model assumes the trade is commercial (s.66), that the person " + "claims, and that the claim is for the loss-making year rather than " + "the year before (s.64(2)(b)); the part it cannot use is carried " + "forward only by entering it in loss_relief in later years (s.83)." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Income Tax Act 2007 s. 64", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/64", + ), + dict( + title="Income Tax Act 2007 s. 24A", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/24A", + ), + ] + + def formula(person, period, parameters): + components = parameters( + period + ).gov.hmrc.income_tax.adjusted_net_income_components + net_income = max_(0, add(person, period, components)) + return min_( + person("trading_loss", period), + min_(person("income_tax_relief_cap", period), net_income), + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py new file mode 100644 index 0000000000..63e7301177 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py @@ -0,0 +1,35 @@ +from policyengine_uk.model_api import * + + +class trade_loss_relief_against_savings_and_dividends(Variable): + value_type = float + entity = Person + label = "Trade loss relief deducted from savings and dividend income" + documentation = ( + "The part of trade loss relief against general income left after " + "non-savings income is used up; it joins the allowances applied to " + "savings income and then to dividends. From 2027-28 ITA 2007 s.25(3A) " + "requires reliefs to come off income other than property, savings and " + "dividend income first. Before then s.25(2) asks for the order giving " + "the greatest reduction in liability, which the model takes to be the " + "same order, as it does for the Personal Allowance: non-savings income " + "(property last), then savings, then dividends." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Income Tax Act 2007 s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ) + + def formula(person, period, parameters): + components = parameters( + period + ).gov.hmrc.income_tax.adjusted_net_income_components + non_savings_income = ( + add(person, period, components) + - person("taxable_savings_interest_income", period) + - person("taxable_dividend_income", period) + ) + relief = person("trade_loss_relief_against_general_income", period) + return max_(0, relief - max_(0, non_savings_income)) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_2/ni_class_2.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_2/ni_class_2.py index a36358bb4d..164402c8ed 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_2/ni_class_2.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_2/ni_class_2.py @@ -13,6 +13,8 @@ class ni_class_2(Variable): def formula(person, period, parameters): class_2 = parameters(period).gov.hmrc.national_insurance.class_2 - profits = person("self_employment_income", period) + # SSCBA 1992 s.11(3): "relevant profits" are the profits on which + # Class 4 is (or would be) payable, after the Schedule 2 loss reliefs. + profits = person("ni_class_4_profits", period) over_threshold = profits >= class_2.small_profits_threshold return over_threshold * class_2.flat_rate * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py index 6f43033d47..a6ecf2a361 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4.py @@ -13,8 +13,9 @@ class ni_class_4(Variable): def formula(person, period, parameters): class_4 = parameters(period).gov.hmrc.national_insurance.class_4 # Schedule 2 para 2: Class 4 is charged on the full trading profits; - # Class 1 contributions are not a deduction. - profits = person("self_employment_income", period) + # Class 1 contributions are not a deduction. Trade losses do reduce + # them (para 3), through ni_class_4_profits. + profits = person("ni_class_4_profits", period) add_rate_income = max_( profits - class_4.thresholds.upper_profits_limit, 0, diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py index 6f738716ba..74b467363e 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_main.py @@ -12,7 +12,7 @@ class ni_class_4_main(Variable): def formula(person, period, parameters): class_4 = parameters(period).gov.hmrc.national_insurance.class_4 - profits = person("self_employment_income", period) + profits = person("ni_class_4_profits", period) add_rate_income = max_( profits - class_4.thresholds.upper_profits_limit, 0, diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py index 1d9cd4e3ba..b9e61c8aad 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_maximum.py @@ -35,7 +35,7 @@ def formula(person, period, parameters): # a wrong Case 1 dropped the additional-rate band (#1878). The unused # band is exactly zero at or above the UPL, so that equality is now # decided exactly. - profits = person("self_employment_income", period) + profits = person("ni_class_4_profits", period) unused_main_band = clip(upl - profits.astype(np.float64), 0, step_1) case_1 = (step_4_raw >= 0) & ( main_rate * unused_main_band + class_2_weeks_addition diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py new file mode 100644 index 0000000000..b4d95435cc --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_profits(Variable): + value_type = float + entity = Person + label = "Profits for Class 4 NICs" + documentation = ( + "Self-employment profits after the trade loss deductions Schedule 2 " + "para 3 SSCBA 1992 carries over from Income Tax: losses brought forward " + "(ITA 2007 s.83) and the year's trade loss relief against general " + "income (s.64), which para 3(4)(a) treats as reducing the profits of " + "any of the person's trades for Class 4, whatever income it was set " + "against for Income Tax. A loss never reduces other income for Class 4 " + "and the result is never negative. Capital allowances and the trading " + "allowance are not deducted here, as before." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992 s. 15", + href="https://www.legislation.gov.uk/ukpga/1992/4/section/15", + ), + dict( + title="Social Security Contributions and Benefits Act 1992 Sch. 2 para. 3", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2/paragraph/3", + ), + ] + + def formula(person, period, parameters): + return max_( + 0, + person("self_employment_income", period) + - person("loss_relief", period) + - person("trade_loss_relief_against_general_income", period), + ) diff --git a/policyengine_uk/variables/household/income/hbai_household_net_income.py b/policyengine_uk/variables/household/income/hbai_household_net_income.py index 5061ab23c1..6edadca113 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -59,6 +59,8 @@ "income_tax", "national_insurance", "student_loan_repayments", + # Self-employment losses count as negative income in HBAI. + "trading_loss", "employee_pension_contributions", "personal_pension_contributions", "maintenance_expenses", diff --git a/policyengine_uk/variables/household/income/household_market_income.py b/policyengine_uk/variables/household/income/household_market_income.py index 3991db6b7c..ec622f987f 100644 --- a/policyengine_uk/variables/household/income/household_market_income.py +++ b/policyengine_uk/variables/household/income/household_market_income.py @@ -26,6 +26,9 @@ class household_market_income(Variable): unit = GBP def formula(person, period, parameters): - total = add(person, period, HOUSEHOLD_MARKET_INCOME_VARIABLES) + # Trading losses are negative self-employment income. + total = add(person, period, HOUSEHOLD_MARKET_INCOME_VARIABLES) - add( + person, period, ["trading_loss"] + ) contrib = parameters(period).gov.contrib.policyengine.economy.gdp_per_capita return total * (contrib + 1) diff --git a/policyengine_uk/variables/household/income/market_income.py b/policyengine_uk/variables/household/income/market_income.py index 9df8de09cf..6ba591808e 100644 --- a/policyengine_uk/variables/household/income/market_income.py +++ b/policyengine_uk/variables/household/income/market_income.py @@ -24,4 +24,8 @@ def formula(person, period, parameters): "maintenance_income", ] income = add(person, period, INCOME_VARIABLES) - return income - person("maintenance_expenses", period) + return ( + income + - person("trading_loss", period) + - person("maintenance_expenses", period) + ) diff --git a/policyengine_uk/variables/input/self_employment_income.py b/policyengine_uk/variables/input/self_employment_income.py index 5a58e87ccd..005de5be5a 100644 --- a/policyengine_uk/variables/input/self_employment_income.py +++ b/policyengine_uk/variables/input/self_employment_income.py @@ -5,7 +5,7 @@ class self_employment_income(Variable): value_type = float entity = Person label = "self-employment income" - documentation = "Income from self-employment profits, including gig work. This should be net of self-employment expenses." + documentation = "Income from self-employment profits, including gig work. This should be net of self-employment expenses and is never negative: enter a trading loss in trading_loss." definition_period = YEAR unit = GBP reference = "Income Tax (Trading and Other Income) Act 2005 s. 1(1)(a)" diff --git a/policyengine_uk/variables/input/trading_loss.py b/policyengine_uk/variables/input/trading_loss.py new file mode 100644 index 0000000000..8661050ced --- /dev/null +++ b/policyengine_uk/variables/input/trading_loss.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class trading_loss(Variable): + value_type = float + entity = Person + label = "trading loss" + documentation = ( + "Losses made in the tax year by the person's trades, professions or " + "vocations, as a positive amount. Profits of their other trades go in " + "self_employment_income, which is never negative. Each programme reads " + "the loss by its own rule: Income Tax deducts it from general income " + "(ITA 2007 s.64, within the s.24A cap); Class 4 NICs deduct it from " + "trading profits only (SSCBA 1992 Sch 2 para 3); tax credits deduct it " + "from the claimants' income (SI 2002/2006 reg 3(1) Step 4); means-tested " + "benefits never offset it against other earnings; HBAI household income " + "counts it as negative income. Losses brought forward from earlier " + "years go in loss_relief." + ) + definition_period = YEAR + unit = GBP + quantity_type = FLOW + uprating = "gov.economic_assumptions.indices.obr.per_capita.mixed_income" + reference = dict( + title="Income Tax Act 2007 s. 64", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/64", + ) From 905b41aefe3fa36006c3a0d80aff67da8ba025f7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 23:14:23 -0400 Subject: [PATCH 02/11] Apply review r1: s.24A base, UC trade netting, claimant-only tax credit loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adjusted_total_income follows s.24A(8): Step 1 total income (taxable components, losses brought forward added back) less own pension contributions given relief, up to the greater of £3,600 and relevant UK earnings (FA 2004 s.190) and none from age 75 (s.188(3)(a)). - net_income_before_trade_loss_relief is the one base for adjusted net income, the relief limit and the savings/dividends part; it counts basic income where a reform makes it taxable. - UC sets a trading loss against the person's other trades' profits before the minimum income floor, never against employed earnings (UC Regs 2013 reg 57(2) Steps 1-3). - Tax credits subtract only the claimants' losses, not a child's. - New input ni_class_4_losses_brought_forward for losses carried forward for Class 4 only (SSCBA 1992 Sch 2 para 3(4)(b)). - The savings-then-dividends order is the model's allowance order, which s.25(2) can beat for the Personal Allowance too: documented, #2106. - YAML cases for each finding; property tests cover UC netting and children's losses, with fewer examples for CI time. Co-Authored-By: Claude Opus 5.5 --- changelog.d/trading-loss-relief.added.md | 2 +- changelog.d/trading-loss-relief.changed.md | 2 +- ...redits_applicable_income_trading_loss.yaml | 20 +++ .../dwp/universal_credit/uc_trading_loss.yaml | 36 +++++ ...de_loss_relief_against_general_income.yaml | 65 +++++++++ .../class_4/ni_class_4_trading_loss.yaml | 13 ++ .../tests/test_trading_loss_properties.py | 133 ++++++++++++------ .../gov/dwp/tax_credits_applicable_income.py | 8 +- .../uc_mif_capped_earned_income.py | 12 +- .../hmrc/income_tax/adjusted_net_income.py | 26 ++-- .../reliefs/adjusted_total_income.py | 62 ++++++-- .../net_income_before_trade_loss_relief.py | 28 ++++ ...rade_loss_relief_against_general_income.py | 5 +- ...ss_relief_against_savings_and_dividends.py | 18 ++- .../ni_class_4_losses_brought_forward.py | 21 +++ .../class_4/ni_class_4_profits.py | 12 +- .../variables/input/trading_loss.py | 10 +- 17 files changed, 369 insertions(+), 104 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/reliefs/net_income_before_trade_loss_relief.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py diff --git a/changelog.d/trading-loss-relief.added.md b/changelog.d/trading-loss-relief.added.md index fdd287f220..05460b68df 100644 --- a/changelog.d/trading-loss-relief.added.md +++ b/changelog.d/trading-loss-relief.added.md @@ -1 +1 @@ -Trade loss relief against general income (ITA 2007 s.64): a trading loss (`trading_loss`) now reduces net income for the loss-making year, within the s.24A cap of the greater of £50,000 and 25% of adjusted total income, taking non-savings income first, then savings, then dividends. New variables `trade_loss_relief_against_general_income`, `trade_loss_relief_against_savings_and_dividends`, `income_tax_relief_cap`, `adjusted_total_income` and `ni_class_4_profits`. +Trade loss relief against general income (ITA 2007 s.64): a trading loss (`trading_loss`) now reduces net income for the loss-making year, within the s.24A cap of the greater of £50,000 and 25% of adjusted total income (s.24A(8): total income less relieved pension contributions). Beyond non-savings income it comes off savings, then dividends, in the model's allowance order (#2106). New variables `net_income_before_trade_loss_relief`, `trade_loss_relief_against_general_income`, `trade_loss_relief_against_savings_and_dividends`, `income_tax_relief_cap`, `adjusted_total_income`, `ni_class_4_profits` and the input `ni_class_4_losses_brought_forward` (SSCBA 1992 Sch 2 para 3(4)(b)). diff --git a/changelog.d/trading-loss-relief.changed.md b/changelog.d/trading-loss-relief.changed.md index 8b519a9743..37d675777c 100644 --- a/changelog.d/trading-loss-relief.changed.md +++ b/changelog.d/trading-loss-relief.changed.md @@ -1 +1 @@ -`trading_loss` is now an uprated input read by each programme's own rule: Class 4 and Class 2 NICs deduct the relief from trading profits only (SSCBA 1992 Sch 2 para 3, s.11(3)), tax credit income deducts it (SI 2002/2006 reg 3(1) Step 4), market income and HBAI household income count it as negative income, and means tests never offset it against other earnings. `loss_relief` now holds only losses brought forward (s.83); it no longer adds the previous year's `trading_loss`, which relief against general income may already have used. +`trading_loss` is now an uprated input read by each programme's own rule: Class 4 and Class 2 NICs deduct the relief from trading profits only (SSCBA 1992 Sch 2 para 3, s.11(3)); tax credit income deducts the claimants' losses (SI 2002/2006 reg 3(1) Step 4); UC sets a loss against the person's other trades' profits but never employed earnings (UC Regs 2013 reg 57(2)); the other means tests never offset it; market income and HBAI household income count it as negative income. `loss_relief` now holds only losses brought forward (s.83); it no longer adds the previous year's `trading_loss`, which relief against general income may already have used. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/tax_credits_applicable_income_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/tax_credits_applicable_income_trading_loss.yaml index e968e3d542..ad854d8bf6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/tax_credits_applicable_income_trading_loss.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/tax_credits_applicable_income_trading_loss.yaml @@ -56,3 +56,23 @@ members: [claimant] output: tax_credits_applicable_income: 0 + +- name: A child's trading loss does not reduce the claimants' income + period: 2024 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + employment_income: 20_000 + child: + age: 15 + trading_loss: 5_000 + benunits: + benunit: + members: [claimant, child] + households: + household: + members: [claimant, child] + output: + tax_credits_applicable_income: 20_000 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 new file mode 100644 index 0000000000..ed4c51df9c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml @@ -0,0 +1,36 @@ +# UC Regs 2013 reg 57(2): Steps 1-2 add the profit or loss of each trade and +# Step 3 makes a negative total nil, so one trade's loss reduces another +# trade's profits but never employed earnings. + +- name: A loss in one trade reduces another trade's profits for UC + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + self_employment_income: 30_000 + trading_loss: 10_000 + uc_is_in_startup_period: true + output: + uc_mif_capped_earned_income: 20_000 + +- name: A loss never reduces employed earnings for UC + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + employment_income: 20_000 + trading_loss: 10_000 + output: + uc_mif_capped_earned_income: 20_000 + +- name: Self-employed earnings for UC are never below nil + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + employment_income: 20_000 + self_employment_income: 5_000 + trading_loss: 10_000 + uc_is_in_startup_period: true + output: + uc_mif_capped_earned_income: 20_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml index fa6c2ecb71..01196a03b2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml @@ -111,3 +111,68 @@ received_allowances_dividend_income: 17_570 # £25,000 of dividends is left: (25,000 - 12,570 - 500) x 10.75%. income_tax: 1_282.48 + +- name: Adjusted total income is total income after employment expenses + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 300_000 + employment_expenses: 100_000 + trading_loss: 100_000 + output: + adjusted_total_income: 200_000 + income_tax_relief_cap: 50_000 + trade_loss_relief_against_general_income: 50_000 + +- name: Contributions from age 75 get no relief, so they stay in adjusted total income + period: 2026 + absolute_error_margin: 0 + input: + age: 76 + employment_income: 300_000 + personal_pension_contributions: 40_000 + trading_loss: 100_000 + state_pension: 0 + output: + adjusted_total_income: 300_000 + income_tax_relief_cap: 75_000 + trade_loss_relief_against_general_income: 75_000 + +- name: Losses brought forward are added back to total income + period: 2026 + absolute_error_margin: 0 + input: + self_employment_income: 250_000 + loss_relief: 50_000 + trading_loss: 100_000 + output: + # Step 1 total income is the £250,000 of profits: s.83 relief is a Step 2 + # deduction, made inside taxable self-employment income in the model. + adjusted_total_income: 250_000 + income_tax_relief_cap: 62_500 + +- name: Taxable basic income is general income the relief can be set against + period: 2026 + absolute_error_margin: 0.01 + input: + gov.contrib.ubi_center.basic_income.interactions.include_in_taxable_income: true + basic_income: 20_000 + trading_loss: 20_000 + output: + net_income_before_trade_loss_relief: 20_000 + trade_loss_relief_against_general_income: 20_000 + adjusted_net_income: 0 + income_tax: 0 + +- name: Only contributions up to relevant UK earnings, or £3,600, come out of adjusted total income + period: 2026 + absolute_error_margin: 0 + input: + dividend_income: 300_000 + personal_pension_contributions: 40_000 + trading_loss: 200_000 + output: + # No relevant UK earnings, so relief is on £3,600 at most (FA 2004 s.190). + adjusted_total_income: 296_400 + income_tax_relief_cap: 74_100 + trade_loss_relief_against_general_income: 74_100 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml index 50e49f65b2..9628f99574 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml @@ -44,3 +44,16 @@ output: ni_class_4_profits: 0 ni_class_4: 0 + +- name: Class 4 carries forward a loss Income Tax set against other income + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 50_000 + ni_class_4_losses_brought_forward: 20_000 + output: + ni_class_4_profits: 30_000 + # (30,000 - 12,570) x 6%. + ni_class_4: 1_045.8 + # Income Tax is unaffected: £50,000 less the £12,570 Personal Allowance at 20%. + income_tax: 7_486 diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 5513a6804d..7f0abaf006 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -6,30 +6,36 @@ 1. Relief definition (ITA 2007 s.64, s.24A): trade loss relief against general income equals min(loss, max(£50,000, 25% of adjusted total - income), net income), where net income is the adjusted net income of the - same person without the loss (a separate simulation). -2. Differential (s.25 order): 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. + income), net income). Net income is the adjusted net income of the same + person without the loss (a separate simulation); adjusted total income is + that less the pension contributions given relief (own contributions up to + the greater of £3,600 and pay plus profits, none from age 75). +2. Differential: a person's Income Tax with a loss equals the Income Tax of + the same person without the loss and with their income cut by the relief, + taken from non-savings income first, then savings, then dividends: the + model's fixed allowance order (s.25(2) asks for the order giving the + greatest reduction; #2106). 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). -4. Means tests never offset the loss against other income: with Income Tax - and NI held at the values the loss gives, every means-tested benefit and - means-test income is what it is without the loss. (The model's means tests - deduct the year's Income Tax liability, so the loss reaches them only - through that tax.) -5. Tax credits (SI 2002/2006 reg 3(1) Step 4): the claimants' losses come off - their applicable income, never below zero, and the award never falls. +4. Means tests never set the loss against employed earnings or other + income: with Income Tax and NI held at the values the loss gives, every + means-tested benefit and means-test income is what it is without the loss, + except that UC sets it against the person's other trades' profits (UC Regs + 2013 reg 57(2)), so UC equals the same family with those profits cut by + the loss. (The model's means tests deduct the year's Income Tax liability, + so otherwise the loss reaches them only through that tax.) +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. 6. Household income: market income falls by exactly the household's losses, and HBAI net income moves only through the loss itself, taxes and means-tested or passported benefits; every other component is unchanged. 7. Class 4 (SSCBA 1992 Sch 2 para 3): Class 4 profits are self-employment - profits less losses brought forward and the year's relief, never below - zero, so Class 4 never rises with the loss and someone with no profits - pays none whatever their loss. + profits less losses brought forward (for both taxes and for Class 4 only) + and the year's relief, never below zero, so Class 4 never rises with the + loss and someone with no profits pays none whatever their loss. 8. The s.24A cap starts in 2013-14. Comparisons allow float32 rounding: the model stores values as float32. @@ -47,27 +53,26 @@ YEAR = 2026 PROPERTY_SETTINGS = settings( - max_examples=12, + max_examples=6, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) REGIONS = ["LONDON", "NORTH_EAST", "SCOTLAND", "WALES"] +# Means tests other than UC, compared with UC held (CTR counts UC as income). MEANS_TESTED = [ - "universal_credit", "housing_benefit", "council_tax_reduction", "pension_credit", "income_support", "jsa_income", "esa_income", - "uc_earned_income", "housing_benefit_applicable_income", "income_support_applicable_income", "council_tax_reduction_applicable_income", "pension_credit_income", - "benefit_cap_reduction", ] +UC_TESTS = ["uc_earned_income", "universal_credit_pre_benefit_cap"] # HBAI components the loss may move: the loss itself, taxes, and benefits # that are means-tested or passported from a means-tested benefit. HBAI_CHANNELS = { @@ -111,7 +116,7 @@ def tolerance(*amounts): def taxpayers(draw): """One working-age person with income of every taxed kind and a loss.""" return { - "age": draw(st.integers(18, 64)), + "age": draw(st.integers(18, 80)), "employment_income": draw(money(250_000)), "private_pension_income": draw(money(60_000)), "savings_interest_income": draw(money(40_000)), @@ -151,16 +156,15 @@ def test_relief_is_the_loss_within_net_income_and_the_cap(people, regions): without = [{**p, "trading_loss": 0.0} for p in people] a = calculate( single_people(people, regions), - [ - "trade_loss_relief_against_general_income", - "adjusted_net_income", - "total_income", - "pension_contributions", - ], + ["trade_loss_relief_against_general_income", "adjusted_net_income"], ) b = calculate(single_people(without, regions), ["adjusted_net_income"]) for i, person in enumerate(people): - ati = max(0.0, a["total_income"][i] - a["pension_contributions"][i]) + earnings = person["employment_income"] + person["self_employment_income"] + relieved = (person["age"] < 75) * min( + person["personal_pension_contributions"], max(3_600.0, earnings) + ) + ati = max(0.0, b["adjusted_net_income"][i] - relieved) cap = max(50_000.0, 0.25 * ati) expected = min(person["trading_loss"], cap, b["adjusted_net_income"][i]) relief = a["trade_loss_relief_against_general_income"][i] @@ -273,7 +277,10 @@ def families(draw): for age in adults ] for _ in range(draw(st.integers(0, 3))): - people.append({"age": draw(st.integers(0, 15))}) + # A child's own trading loss is never the claimants'. + people.append( + {"age": draw(st.integers(0, 15)), "trading_loss": draw(money(5_000))} + ) household = { "rent": draw(money(15_000)), "council_tax": draw(money(3_000)), @@ -283,7 +290,7 @@ def families(draw): return people, household -def family_situation(units, overrides=None): +def family_situation(units, overrides=None, benunit_overrides=None): situation = {"people": {}, "benunits": {}, "households": {}} for i, (people, household) in enumerate(units): names = [] @@ -294,7 +301,13 @@ def family_situation(units, overrides=None): values.update(overrides.get(name, {})) situation["people"][name] = {k: {YEAR: v} for k, v in values.items()} names.append(name) - situation["benunits"][f"b{i}"] = {"members": names} + situation["benunits"][f"b{i}"] = { + "members": names, + **{ + k: {YEAR: v} + for k, v in (benunit_overrides or {}).get(f"b{i}", {}).items() + }, + } situation["households"][f"h{i}"] = { "members": names, **{k: {YEAR: v} for k, v in household.items()}, @@ -303,31 +316,61 @@ def family_situation(units, overrides=None): @PROPERTY_SETTINGS -@given(st.lists(families(), min_size=1, max_size=5)) +@given(st.lists(families(), min_size=1, max_size=4)) def test_means_tests_see_the_loss_only_through_tax(units): with_loss = Simulation(situation=family_situation(units)) + + def values(sim, variable): + return np.asarray(sim.calculate(variable, YEAR), dtype=float) + names = with_loss.populations["person"].ids - tax = { - v: np.asarray(with_loss.calculate(v, YEAR), dtype=float) - for v in ["income_tax", "national_insurance"] - } - overrides = { - name: { + benunits = with_loss.populations["benunit"].ids + tax = {v: values(with_loss, v) for v in ["income_tax", "national_insurance"]} + loss = values(with_loss, "trading_loss") + profits = values(with_loss, "self_employment_income") + mif = np.asarray(with_loss.calculate("uc_mif_applies", YEAR)) + uc = values(with_loss, "universal_credit") + + def held(k, **extra): + return { "trading_loss": 0.0, "income_tax": float(tax["income_tax"][k]), "national_insurance": float(tax["national_insurance"][k]), + **extra, } - for k, name in enumerate(names) - } - held = Simulation(situation=family_situation(units, overrides)) + + # Every means test but UC: the loss reaches them only through the tax. + no_loss = Simulation( + situation=family_situation( + units, + {name: held(k) for k, name in enumerate(names)}, + {b: {"universal_credit": float(uc[k])} for k, b in enumerate(benunits)}, + ) + ) for variable in MEANS_TESTED: - a = np.asarray(with_loss.calculate(variable, YEAR), dtype=float) - b = np.asarray(held.calculate(variable, YEAR), dtype=float) + a, b = values(with_loss, variable), values(no_loss, variable) + assert np.allclose(a, b, atol=0.5), (variable, a, b, units) + # UC: as if the loss had cut the person's other trades' profits. + netted = Simulation( + situation=family_situation( + units, + { + name: held( + k, + self_employment_income=float(profits[k] - min(loss[k], profits[k])), + uc_mif_applies=bool(mif[k]), + ) + for k, name in enumerate(names) + }, + ) + ) + for variable in UC_TESTS: + a, b = values(with_loss, variable), values(netted, variable) assert np.allclose(a, b, atol=0.5), (variable, a, b, units) @PROPERTY_SETTINGS -@given(st.lists(families(), min_size=1, max_size=5)) +@given(st.lists(families(), min_size=1, max_size=4)) def test_tax_credit_income_falls_by_the_claimants_losses(units): year = 2024 without = [ @@ -365,7 +408,7 @@ def run(us): @PROPERTY_SETTINGS -@given(st.lists(families(), min_size=1, max_size=5)) +@given(st.lists(families(), min_size=1, max_size=4)) def test_household_income_moves_only_through_its_channels(units): without = [ ([{**p, "trading_loss": 0.0} if "trading_loss" in p else p for p in people], h) diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index e76a16aeed..61acfe6b6d 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -39,10 +39,12 @@ def formula(benunit, period, parameters): if bi.interactions.include_in_means_tests: STEP_2_COMPONENTS.append("basic_income") income += add_for_members(benunit, period, STEP_2_COMPONENTS, members) - # Step 4: a trading loss in the year is subtracted from the total of - # the other steps, so one partner's loss reduces the joint income. + # Step 4: the claimants' trading loss in the year is subtracted from + # the total of the other steps, so one partner's loss reduces the + # joint income. A child's loss is not the claimants'. + claimants = person("is_claimant_or_partner", period) income = max_( - 0, income - add_for_members(benunit, period, ["trading_loss"], members) + 0, income - add_for_members(benunit, period, ["trading_loss"], claimants) ) EXEMPT_BENEFITS = ["income_support", "esa_income", "jsa_income"] on_exempt_benefits = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py index 03c7deb25c..b08f3f8626 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py @@ -18,7 +18,17 @@ def formula(person, period, parameters): bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.include_in_means_tests: INCOME_COMPONENTS.append("basic_income") - personal_gross_earned_income = add(person, period, INCOME_COMPONENTS) + # UC Regs 2013 reg 57(2) Steps 1-2 add together the profit or loss of + # each of the person's trades, and Step 3 makes a negative total nil: + # one trade's loss reduces another trade's profits but never employed + # earnings. Over a year with both spread evenly, the loss offsets + # profits up to their amount. + losses_set_against_profits = min_( + person("trading_loss", period), person("self_employment_income", period) + ) + personal_gross_earned_income = ( + add(person, period, INCOME_COMPONENTS) - losses_set_against_profits + ) floor = where( person("uc_mif_applies", period), person("uc_minimum_income_floor", period), diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py index 481f5ed35c..ed81043887 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py @@ -13,22 +13,12 @@ class adjusted_net_income(Variable): unit = GBP def formula(person, period, parameters): - adjusted_net_income_components = parameters( - period - ).gov.hmrc.income_tax.adjusted_net_income_components - - # Find adjusted net income: net income, after trade loss relief - # against general income (ITA 2007 s.24(1), s.64), which reduces it - # before the Personal Allowance taper and every other ANI test. - ani = add(person, period, adjusted_net_income_components) - person( - "trade_loss_relief_against_general_income", period + # Net income after trade loss relief against general income + # (ITA 2007 s.24(1), s.64), which reduces it before the Personal + # Allowance taper and every other adjusted net income test. Includes + # basic income where a reform makes it taxable. + return max_( + 0, + person("net_income_before_trade_loss_relief", period) + - person("trade_loss_relief_against_general_income", period), ) - - # For basic income contributions, add basic income - # Modifying param list directly is mutative, hence two-step process - if parameters( - period - ).gov.contrib.ubi_center.basic_income.interactions.include_in_taxable_income: - ani += person("basic_income", period) - - return max_(0, ani) 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 3aab95d882..15a0c78eff 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py @@ -7,22 +7,58 @@ 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)): total income, plus payroll giving, less pension " - "contributions given relief at source or under net pay or claim " - "(FA 2004 ss.192, 193(4), 194(1)). The model deducts the person's own " - "pension contributions as made, because its relief variable depends " - "on adjusted net income through the tapered annual allowance, and " - "does not model payroll giving." + "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 the greater of " + "their relevant UK earnings and the basic amount (s.190), none 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." ) definition_period = YEAR unit = GBP - reference = dict( - title="Income Tax Act 2007 s. 24A", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/24A", - ) + reference = [ + dict( + title="Income Tax Act 2007 s. 24A(8)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/24A", + ), + dict( + title="Finance Act 2004 ss. 188-190", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/190", + ), + ] def formula(person, period, parameters): - return max_( - 0, - person("total_income", period) - person("pension_contributions", period), + p = parameters(period).gov.hmrc + profits = person("self_employment_income", period) + other_deductions = [ + deduction + for deduction in p.income_tax.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 + ) + total_income = ( + person("net_income_before_trade_loss_relief", period) + + losses_brought_forward_deducted + ) + relevant_earnings = max_( + 0, add(person, period, ["employment_income", "self_employment_income"]) + ) + relief_limit = max_( + p.income_tax.reliefs.pension_contribution.basic_amount, relevant_earnings + ) + under_age_limit = ( + person("age", period) < p.pensions.pension_contributions_relief_age_limit + ) + relieved_contributions = ( + min_(person("pension_contributions", period), relief_limit) + * under_age_limit ) + return max_(0, total_income - relieved_contributions) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/net_income_before_trade_loss_relief.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/net_income_before_trade_loss_relief.py new file mode 100644 index 0000000000..3631f4c369 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/net_income_before_trade_loss_relief.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * + + +class net_income_before_trade_loss_relief(Variable): + value_type = float + entity = Person + label = "Net income before trade loss relief against general income" + documentation = ( + "The components of income left after the Step 2 reliefs the model " + "deducts inside them (ITA 2007 s.23), before trade loss relief against " + "general income: the income that relief can be deducted from " + "(s.25(4)). Includes basic income where a reform makes it taxable." + ) + 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) + income = add( + person, period, p.gov.hmrc.income_tax.adjusted_net_income_components + ) + if p.gov.contrib.ubi_center.basic_income.interactions.include_in_taxable_income: + income += person("basic_income", period) + return income diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py index c181a4cd08..7c2aa67c83 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py @@ -30,10 +30,7 @@ class trade_loss_relief_against_general_income(Variable): ] def formula(person, period, parameters): - components = parameters( - period - ).gov.hmrc.income_tax.adjusted_net_income_components - net_income = max_(0, add(person, period, components)) + net_income = max_(0, person("net_income_before_trade_loss_relief", period)) return min_( person("trading_loss", period), min_(person("income_tax_relief_cap", period), net_income), diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py index 63e7301177..201cc012df 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py @@ -8,12 +8,13 @@ class trade_loss_relief_against_savings_and_dividends(Variable): documentation = ( "The part of trade loss relief against general income left after " "non-savings income is used up; it joins the allowances applied to " - "savings income and then to dividends. From 2027-28 ITA 2007 s.25(3A) " - "requires reliefs to come off income other than property, savings and " - "dividend income first. Before then s.25(2) asks for the order giving " - "the greatest reduction in liability, which the model takes to be the " - "same order, as it does for the Personal Allowance: non-savings income " - "(property last), then savings, then dividends." + "savings income and then to dividends. ITA 2007 s.25(2) deducts " + "reliefs and allowances in the order giving the greatest reduction in " + "liability, and from 2027-28 s.25(3A) puts income other than property, " + "savings and dividends first. The model uses its fixed allowance order " + "(non-savings income, property last, then savings, then dividends), " + "which can overcharge where savings are taxed at 0%; that is model-wide " + "and tracked in #2106." ) definition_period = YEAR unit = GBP @@ -23,11 +24,8 @@ class trade_loss_relief_against_savings_and_dividends(Variable): ) def formula(person, period, parameters): - components = parameters( - period - ).gov.hmrc.income_tax.adjusted_net_income_components non_savings_income = ( - add(person, period, components) + person("net_income_before_trade_loss_relief", period) - person("taxable_savings_interest_income", period) - person("taxable_dividend_income", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py new file mode 100644 index 0000000000..701873abc1 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_losses_brought_forward.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class ni_class_4_losses_brought_forward(Variable): + value_type = float + entity = Person + label = "Class 4 losses brought forward" + documentation = ( + "Trade losses of earlier years that reduce this year's Class 4 profits " + "but not Income Tax: the excess that SSCBA 1992 Sch 2 para 3(4)(b) " + "carries forward where Income Tax set an earlier year's loss against " + "income other than the trade's profits. Losses brought forward for " + "both Income Tax and Class 4 go in loss_relief instead." + ) + definition_period = YEAR + unit = GBP + quantity_type = FLOW + reference = dict( + title="Social Security Contributions and Benefits Act 1992 Sch. 2 para. 3(4)(b)", + href="https://www.legislation.gov.uk/ukpga/1992/4/schedule/2/paragraph/3", + ) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py index b4d95435cc..e31a7f3908 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_profits.py @@ -11,9 +11,12 @@ class ni_class_4_profits(Variable): "(ITA 2007 s.83) and the year's trade loss relief against general " "income (s.64), which para 3(4)(a) treats as reducing the profits of " "any of the person's trades for Class 4, whatever income it was set " - "against for Income Tax. A loss never reduces other income for Class 4 " - "and the result is never negative. Capital allowances and the trading " - "allowance are not deducted here, as before." + "against for Income Tax. Para 3(4)(b) carries forward, for Class 4 " + "only, the part of an earlier loss that Income Tax set against other " + "income: ni_class_4_losses_brought_forward. A loss never reduces other " + "income for Class 4 and the result is never negative. Capital " + "allowances and the trading allowance are not deducted here, as " + "before (#1916)." ) definition_period = YEAR unit = GBP @@ -33,5 +36,6 @@ def formula(person, period, parameters): 0, person("self_employment_income", period) - person("loss_relief", period) - - person("trade_loss_relief_against_general_income", period), + - person("trade_loss_relief_against_general_income", period) + - person("ni_class_4_losses_brought_forward", period), ) diff --git a/policyengine_uk/variables/input/trading_loss.py b/policyengine_uk/variables/input/trading_loss.py index 8661050ced..bc43e15fbb 100644 --- a/policyengine_uk/variables/input/trading_loss.py +++ b/policyengine_uk/variables/input/trading_loss.py @@ -12,10 +12,12 @@ class trading_loss(Variable): "the loss by its own rule: Income Tax deducts it from general income " "(ITA 2007 s.64, within the s.24A cap); Class 4 NICs deduct it from " "trading profits only (SSCBA 1992 Sch 2 para 3); tax credits deduct it " - "from the claimants' income (SI 2002/2006 reg 3(1) Step 4); means-tested " - "benefits never offset it against other earnings; HBAI household income " - "counts it as negative income. Losses brought forward from earlier " - "years go in loss_relief." + "from the claimants' income (SI 2002/2006 reg 3(1) Step 4); means tests " + "never set it against employed earnings or other income: UC sets it " + "against the person's other trades' profits (UC Regs 2013 reg 57(2)), " + "and HB, CTR, Pension Credit and the legacy benefits not even that (HB " + "Regs 2006 reg 38(10)); HBAI household income counts it as negative " + "income. Losses brought forward from earlier years go in loss_relief." ) definition_period = YEAR unit = GBP From 65735bb91a56a1f2fe3a2ed36822be0e5409a886 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:37:42 -0400 Subject: [PATCH 03/11] Apply review r2: net trades in the cap earnings test; relevant UK earnings - uc_trading_losses_set_against_profits (UC Regs 2013 reg 57(2)) is the one netting UC earnings and the reg 82 earnings tests in is_benefit_cap_exempt_earnings and is_benefit_cap_exempt_health_disability deduct, so a loss-maker's cap matches the same household with netted profits. - adjusted_total_income takes relevant UK earnings (FA 2004 s.189(2)) as taxable employment income (incl. taxable benefits and statutory pay) plus chargeable trading profits. - YAML for both review cases; the property test's netted twin compares the earnings test and the capped award; property tests run 3 examples per property with larger batches to cut CI time (#2099). Co-Authored-By: Claude Opus 5.5 --- .../dwp/universal_credit/uc_trading_loss.yaml | 36 ++++++++++++ ...de_loss_relief_against_general_income.yaml | 14 +++++ .../tests/test_trading_loss_properties.py | 55 +++++++++++++------ .../gov/dwp/is_benefit_cap_exempt_earnings.py | 3 + ...is_benefit_cap_exempt_health_disability.py | 3 + .../uc_mif_capped_earned_income.py | 12 +--- .../uc_trading_losses_set_against_profits.py | 26 +++++++++ .../reliefs/adjusted_total_income.py | 11 +++- 8 files changed, 129 insertions(+), 31 deletions(-) create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/income/uc_trading_losses_set_against_profits.py 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 ed4c51df9c..3209c128a6 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 @@ -34,3 +34,39 @@ uc_is_in_startup_period: true output: uc_mif_capped_earned_income: 20_000 + +- name: The benefit cap earnings test nets trades too (reg 82 with reg 57(2)) + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + self_employment_income: 12_000 + trading_loss: 5_000 + uc_is_in_startup_period: true + income_tax: 0 + national_insurance: 0 + child_1: + age: 1 + child_2: + age: 4 + child_3: + age: 7 + child_4: + age: 10 + benunits: + benunit: + members: [parent, child_1, child_2, child_3, child_4] + households: + household: + members: [parent, child_1, child_2, child_3, child_4] + region: LONDON + rent: 24_000 + tenure_type: RENT_PRIVATELY + output: + uc_trading_losses_set_against_profits: [5_000, 0, 0, 0, 0] + # £7,000 of earnings, below the reg 82 threshold, so the cap applies, + # exactly as for £7,000 of profits and no loss. + is_benefit_cap_exempt_earnings: false + universal_credit: 21_124 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml index 01196a03b2..bd5f40143d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml @@ -176,3 +176,17 @@ adjusted_total_income: 296_400 income_tax_relief_cap: 74_100 trade_loss_relief_against_general_income: 74_100 + +- name: Statutory Maternity Pay is relevant UK earnings for pension relief + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 10_000 + statutory_maternity_pay: 30_000 + dividend_income: 300_000 + personal_pension_contributions: 40_000 + trading_loss: 100_000 + output: + # £40,000 of relevant UK earnings covers the £40,000 contribution. + adjusted_total_income: 300_000 + income_tax_relief_cap: 75_000 diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 7f0abaf006..c8daf8b838 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -23,8 +23,8 @@ income: with Income Tax and NI held at the values the loss gives, every means-tested benefit and means-test income is what it is without the loss, except that UC sets it against the person's other trades' profits (UC Regs - 2013 reg 57(2)), so UC equals the same family with those profits cut by - the loss. (The model's means tests deduct the year's Income Tax liability, + 2013 reg 57(2)), so UC, its benefit cap earnings test and the capped + award equal the same family with those profits cut by the loss. (The model's means tests deduct the year's Income Tax liability, so otherwise the loss reaches them only through that tax.) 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 @@ -53,7 +53,7 @@ YEAR = 2026 PROPERTY_SETTINGS = settings( - max_examples=6, + max_examples=3, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], @@ -72,7 +72,12 @@ "council_tax_reduction_applicable_income", "pension_credit_income", ] -UC_TESTS = ["uc_earned_income", "universal_credit_pre_benefit_cap"] +UC_TESTS = [ + "uc_earned_income", + "universal_credit_pre_benefit_cap", + "is_benefit_cap_exempt_earnings", + "universal_credit", +] # HBAI components the loss may move: the loss itself, taxes, and benefits # that are means-tested or passported from a means-tested benefit. HBAI_CHANNELS = { @@ -149,8 +154,8 @@ def calculate(situation, variables, year=YEAR): @PROPERTY_SETTINGS @given( - st.lists(taxpayers(), min_size=1, max_size=8), - st.lists(st.sampled_from(REGIONS), min_size=8, max_size=8), + st.lists(taxpayers(), min_size=8, max_size=16), + st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), ) def test_relief_is_the_loss_within_net_income_and_the_cap(people, regions): without = [{**p, "trading_loss": 0.0} for p in people] @@ -158,9 +163,19 @@ def test_relief_is_the_loss_within_net_income_and_the_cap(people, regions): single_people(people, regions), ["trade_loss_relief_against_general_income", "adjusted_net_income"], ) - b = calculate(single_people(without, regions), ["adjusted_net_income"]) + b = calculate( + single_people(without, regions), + [ + "adjusted_net_income", + "taxable_employment_income", + "taxable_self_employment_income", + ], + ) for i, person in enumerate(people): - earnings = person["employment_income"] + person["self_employment_income"] + # Relevant UK earnings (FA 2004 s.189(2)): taxable pay and profits. + earnings = ( + b["taxable_employment_income"][i] + b["taxable_self_employment_income"][i] + ) relieved = (person["age"] < 75) * min( person["personal_pension_contributions"], max(3_600.0, earnings) ) @@ -215,8 +230,8 @@ def reduced(person): @PROPERTY_SETTINGS @given( - st.lists(portfolio_holders(), min_size=1, max_size=8), - st.lists(st.sampled_from(REGIONS), min_size=8, max_size=8), + st.lists(portfolio_holders(), min_size=8, max_size=16), + st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), ) def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): twins = [reduced(p)[0] for p in people] @@ -235,8 +250,8 @@ def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): @PROPERTY_SETTINGS @given( - st.lists(taxpayers(), min_size=1, max_size=8), - st.lists(st.sampled_from(REGIONS), min_size=8, max_size=8), + st.lists(taxpayers(), min_size=8, max_size=16), + st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), st.floats(0, 1), ) def test_income_tax_falls_with_the_loss_by_at_most_the_relief(people, regions, share): @@ -316,7 +331,7 @@ def family_situation(units, overrides=None, benunit_overrides=None): @PROPERTY_SETTINGS -@given(st.lists(families(), min_size=1, max_size=4)) +@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)) @@ -330,6 +345,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") + hb = values(with_loss, "housing_benefit") def held(k, **extra): return { @@ -362,6 +378,9 @@ def held(k, **extra): ) for k, name in enumerate(names) }, + # The cap counts Housing Benefit, which reads profits before any + # loss: hold it at the with-loss value. + {b: {"housing_benefit": float(hb[k])} for k, b in enumerate(benunits)}, ) ) for variable in UC_TESTS: @@ -370,7 +389,7 @@ def held(k, **extra): @PROPERTY_SETTINGS -@given(st.lists(families(), min_size=1, max_size=4)) +@given(st.lists(families(), min_size=4, max_size=10)) def test_tax_credit_income_falls_by_the_claimants_losses(units): year = 2024 without = [ @@ -408,7 +427,7 @@ def run(us): @PROPERTY_SETTINGS -@given(st.lists(families(), min_size=1, max_size=4)) +@given(st.lists(families(), min_size=4, max_size=10)) def test_household_income_moves_only_through_its_channels(units): without = [ ([{**p, "trading_loss": 0.0} if "trading_loss" in p else p for p in people], h) @@ -451,9 +470,9 @@ def household_values(sim, variable): @PROPERTY_SETTINGS @given( - st.lists(taxpayers(), min_size=1, max_size=8), - st.lists(st.sampled_from(REGIONS), min_size=8, max_size=8), - st.lists(money(20_000), min_size=8, max_size=8), + st.lists(taxpayers(), min_size=8, max_size=16), + st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), + st.lists(money(20_000), min_size=16, max_size=16), ) def test_class_4_profits_net_of_the_relief(people, regions, brought_forward): people = [{**p, "loss_relief": b} for p, b in zip(people, brought_forward)] diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py index c27e13c39d..4ecc408af0 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_earnings.py @@ -26,6 +26,9 @@ def formula(benunit, period, parameters): uc_earned = benunit.sum( benunit.members("employment_income", period) + benunit.members("self_employment_income", period) + # Reg 82 reads earned income as UC calculates it: a trading loss + # nets against other trades' profits (reg 57(2)). + - benunit.members("uc_trading_losses_set_against_profits", period) - benunit.members("income_tax", period) - benunit.members("national_insurance", period) ) diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py index 69d9205c9a..47500d0240 100644 --- a/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py +++ b/policyengine_uk/variables/gov/dwp/is_benefit_cap_exempt_health_disability.py @@ -26,6 +26,9 @@ def formula(benunit, period, parameters): uc_earned = benunit.sum( benunit.members("employment_income", period) + benunit.members("self_employment_income", period) + # Reg 82 reads earned income as UC calculates it: a trading loss + # nets against other trades' profits (reg 57(2)). + - benunit.members("uc_trading_losses_set_against_profits", period) - benunit.members("income_tax", period) - benunit.members("national_insurance", period) ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py index b08f3f8626..7e7760bf63 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py @@ -18,16 +18,8 @@ def formula(person, period, parameters): bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.include_in_means_tests: INCOME_COMPONENTS.append("basic_income") - # UC Regs 2013 reg 57(2) Steps 1-2 add together the profit or loss of - # each of the person's trades, and Step 3 makes a negative total nil: - # one trade's loss reduces another trade's profits but never employed - # earnings. Over a year with both spread evenly, the loss offsets - # profits up to their amount. - losses_set_against_profits = min_( - person("trading_loss", period), person("self_employment_income", period) - ) - personal_gross_earned_income = ( - add(person, period, INCOME_COMPONENTS) - losses_set_against_profits + personal_gross_earned_income = add(person, period, INCOME_COMPONENTS) - person( + "uc_trading_losses_set_against_profits", period ) floor = where( person("uc_mif_applies", period), diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_trading_losses_set_against_profits.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_trading_losses_set_against_profits.py new file mode 100644 index 0000000000..191debaee3 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_trading_losses_set_against_profits.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class uc_trading_losses_set_against_profits(Variable): + value_type = float + entity = Person + label = "Trading losses set against other trades' profits for Universal Credit" + documentation = ( + "UC Regs 2013 reg 57(2) Steps 1-2 add together the profit or loss of " + "each of the person's trades, and Step 3 makes a negative total nil: " + "one trade's loss reduces another trade's profits but never employed " + "earnings. Over a year, with profits and losses spread evenly, that is " + "the loss up to the profits. UC earnings and the benefit cap earnings " + "test (reg 82) both deduct it." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Universal Credit Regulations 2013 reg. 57(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ) + + def formula(person, period, parameters): + return min_( + person("trading_loss", period), person("self_employment_income", period) + ) 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 15a0c78eff..959562f123 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 @@ -12,7 +12,9 @@ class adjusted_total_income(Variable): "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 the greater of " - "their relevant UK earnings and the basic amount (s.190), none from " + "their relevant UK earnings (s.189(2): taxable employment income, " + "including benefits and statutory pay, and trading profits) and the " + "basic amount (s.190), none from " "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." @@ -48,8 +50,11 @@ def formula(person, period, parameters): person("net_income_before_trade_loss_relief", period) + losses_brought_forward_deducted ) - relevant_earnings = max_( - 0, add(person, period, ["employment_income", "self_employment_income"]) + # Relevant UK earnings (FA 2004 s.189(2)): employment income chargeable + # to tax, including taxable benefits and statutory payments, and + # trading profits chargeable to tax. + relevant_earnings = ( + person("taxable_employment_income", period) + profits_before_carry_forward ) relief_limit = max_( p.income_tax.reliefs.pension_contribution.basic_amount, relevant_earnings From f658a972904ac4a165e30d5a4f3cce35393ef749 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 07:54:09 -0400 Subject: [PATCH 04/11] Pin the key cases in every trading loss property test With 3 generated examples per property (to keep CI time down, #2099), a run could miss the cases that matter, and a mutant that stopped limiting relief to net income survived. Each property now also checks pinned inputs: relief limited by net income, both caps, contributions above relevant earnings, no relief from age 75, profits beside a loss, income spilling into savings and dividends, the benefit-cap household, a child's loss and a loss beside pay. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_trading_loss_properties.py | 150 +++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index c8daf8b838..4475c4e228 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -42,7 +42,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import CountryTaxBenefitSystem, Simulation @@ -152,7 +152,56 @@ def calculate(situation, variables, year=YEAR): return {v: np.asarray(sim.calculate(v, year), dtype=float) for v in variables} +def taxpayer(**inputs): + """A taxpayer with every taxpayers() field, zero unless given.""" + base = { + "age": 40, + "employment_income": 0.0, + "private_pension_income": 0.0, + "savings_interest_income": 0.0, + "dividend_income": 0.0, + "property_income": 0.0, + "self_employment_income": 0.0, + "personal_pension_contributions": 0.0, + "trading_loss": 0.0, + "state_pension": 0.0, + } + return {**base, **inputs} + + +# Cases every run checks, alongside the generated ones: relief limited by net +# income, the 25% and £50,000 caps, contributions above relevant earnings, +# no relief from age 75, profits and a loss together, and income spilling +# into savings and dividends. +PINNED_TAXPAYERS = [ + taxpayer(employment_income=20_000, trading_loss=50_000), + taxpayer(employment_income=400_000, trading_loss=300_000), + taxpayer(employment_income=150_000, trading_loss=80_000), + taxpayer( + dividend_income=300_000, + personal_pension_contributions=40_000, + trading_loss=200_000, + ), + taxpayer( + age=78, + employment_income=300_000, + personal_pension_contributions=40_000, + trading_loss=100_000, + ), + taxpayer(self_employment_income=30_000, trading_loss=10_000), + taxpayer( + employment_income=5_000, + savings_interest_income=10_000, + dividend_income=30_000, + trading_loss=20_000, + ), + taxpayer(employment_income=110_000, trading_loss=20_000), +] +PINNED_REGIONS = ["LONDON"] * 8 + ["SCOTLAND"] * 8 + + @PROPERTY_SETTINGS +@example(PINNED_TAXPAYERS, PINNED_REGIONS) @given( st.lists(taxpayers(), min_size=8, max_size=16), st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), @@ -228,7 +277,48 @@ def reduced(person): return {**person, **out, "trading_loss": 0.0}, relief +PINNED_PORTFOLIOS = [ + { + "age": 40, + "employment_income": 5_000.0, + "private_pension_income": 0.0, + "savings_interest_income": 10_000.0, + "dividend_income": 30_000.0, + "trading_loss": 20_000.0, + "state_pension": 0.0, + }, + { + "age": 40, + "employment_income": 0.0, + "private_pension_income": 0.0, + "savings_interest_income": 0.0, + "dividend_income": 60_000.0, + "trading_loss": 20_000.0, + "state_pension": 0.0, + }, + { + "age": 40, + "employment_income": 300_000.0, + "private_pension_income": 0.0, + "savings_interest_income": 0.0, + "dividend_income": 0.0, + "trading_loss": 200_000.0, + "state_pension": 0.0, + }, + { + "age": 40, + "employment_income": 8_000.0, + "private_pension_income": 12_000.0, + "savings_interest_income": 2_000.0, + "dividend_income": 0.0, + "trading_loss": 15_000.0, + "state_pension": 0.0, + }, +] * 2 + + @PROPERTY_SETTINGS +@example(PINNED_PORTFOLIOS, PINNED_REGIONS) @given( st.lists(portfolio_holders(), min_size=8, max_size=16), st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), @@ -249,6 +339,7 @@ def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): @PROPERTY_SETTINGS +@example(PINNED_TAXPAYERS, PINNED_REGIONS, 0.5) @given( st.lists(taxpayers(), min_size=8, max_size=16), st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), @@ -330,7 +421,61 @@ def family_situation(units, overrides=None, benunit_overrides=None): return situation +def adult(**inputs): + base = { + "age": 40, + "employment_income": 0.0, + "self_employment_income": 0.0, + "private_pension_income": 0.0, + "savings_interest_income": 0.0, + "trading_loss": 0.0, + "hours_worked": 35.0, + } + return {**base, **inputs} + + +def home(rent, region="LONDON"): + return { + "rent": rent, + "council_tax": 2_000.0, + "tenure_type": "RENT_PRIVATELY", + "region": region, + } + + +# Families every run checks: profits and another trade's loss under the +# benefit cap, a loss beside pay only, a child's loss, and a loss-making +# couple with pay. +PINNED_FAMILIES = [ + ( + [adult(self_employment_income=12_000.0, trading_loss=5_000.0)] + + [{"age": a, "trading_loss": 0.0} for a in (1, 4, 7, 10)], + home(24_000.0), + ), + ( + [adult(employment_income=15_000.0, trading_loss=10_000.0)], + home(9_000.0, "NORTH_EAST"), + ), + ( + [adult(employment_income=20_000.0), {"age": 12, "trading_loss": 4_000.0}], + home(8_000.0, "WALES"), + ), + ( + [ + adult( + employment_income=12_000.0, + self_employment_income=3_000.0, + trading_loss=8_000.0, + ), + adult(age=38, employment_income=9_000.0, trading_loss=2_000.0), + ], + home(11_000.0, "SCOTLAND"), + ), +] + + @PROPERTY_SETTINGS +@example(PINNED_FAMILIES) @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)) @@ -389,6 +534,7 @@ def held(k, **extra): @PROPERTY_SETTINGS +@example(PINNED_FAMILIES) @given(st.lists(families(), min_size=4, max_size=10)) def test_tax_credit_income_falls_by_the_claimants_losses(units): year = 2024 @@ -427,6 +573,7 @@ def run(us): @PROPERTY_SETTINGS +@example(PINNED_FAMILIES) @given(st.lists(families(), min_size=4, max_size=10)) def test_household_income_moves_only_through_its_channels(units): without = [ @@ -469,6 +616,7 @@ def household_values(sim, variable): @PROPERTY_SETTINGS +@example(PINNED_TAXPAYERS, PINNED_REGIONS, [5_000.0] * 16) @given( st.lists(taxpayers(), min_size=8, max_size=16), st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), From b5d58e4a114d50b5fa40bea09a16cd80dadc3430 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 10:58:07 -0400 Subject: [PATCH 05/11] Pin zero-profit loss invariants and document programme limits --- changelog.d/trading-loss-relief.added.md | 2 +- changelog.d/trading-loss-relief.changed.md | 2 +- .../housing_benefit_trading_loss.yaml | 94 +++++++++++ .../dwp/universal_credit/uc_trading_loss.yaml | 4 +- .../tests/test_trading_loss_properties.py | 149 ++++++++++++++++-- .../uc_trading_losses_set_against_profits.py | 18 ++- .../variables/input/trading_loss.py | 6 +- 7 files changed, 248 insertions(+), 27 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_trading_loss.yaml diff --git a/changelog.d/trading-loss-relief.added.md b/changelog.d/trading-loss-relief.added.md index 05460b68df..1f81ae4aca 100644 --- a/changelog.d/trading-loss-relief.added.md +++ b/changelog.d/trading-loss-relief.added.md @@ -1 +1 @@ -Trade loss relief against general income (ITA 2007 s.64): a trading loss (`trading_loss`) now reduces net income for the loss-making year, within the s.24A cap of the greater of £50,000 and 25% of adjusted total income (s.24A(8): total income less relieved pension contributions). Beyond non-savings income it comes off savings, then dividends, in the model's allowance order (#2106). New variables `net_income_before_trade_loss_relief`, `trade_loss_relief_against_general_income`, `trade_loss_relief_against_savings_and_dividends`, `income_tax_relief_cap`, `adjusted_total_income`, `ni_class_4_profits` and the input `ni_class_4_losses_brought_forward` (SSCBA 1992 Sch 2 para 3(4)(b)). +- Added Income Tax trade loss relief against general income under ITA 2007 s.64, subject to available net income and the s.24A relief cap. diff --git a/changelog.d/trading-loss-relief.changed.md b/changelog.d/trading-loss-relief.changed.md index 37d675777c..126cfcb53b 100644 --- a/changelog.d/trading-loss-relief.changed.md +++ b/changelog.d/trading-loss-relief.changed.md @@ -1 +1 @@ -`trading_loss` is now an uprated input read by each programme's own rule: Class 4 and Class 2 NICs deduct the relief from trading profits only (SSCBA 1992 Sch 2 para 3, s.11(3)); tax credit income deducts the claimants' losses (SI 2002/2006 reg 3(1) Step 4); UC sets a loss against the person's other trades' profits but never employed earnings (UC Regs 2013 reg 57(2)); the other means tests never offset it; market income and HBAI household income count it as negative income. `loss_relief` now holds only losses brought forward (s.83); it no longer adds the previous year's `trading_loss`, which relief against general income may already have used. +- Uprated trading losses separately from profits, applied programme-specific UC and tax-credit income rules, and included losses as negative market and HBAI income. Income Tax carry-forward now uses only explicitly supplied unrelieved losses; Class 4 retains its separate automatic loss history. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_trading_loss.yaml new file mode 100644 index 0000000000..7018140c9c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_trading_loss.yaml @@ -0,0 +1,94 @@ +# HB Regs 2006 reg 38(10) prohibits offsetting a loss against another +# self-employed or employed job. Regs 36(3) and 39 specify separate tax/NI +# deductions; hold these and other benefit receipts at nil to isolate the +# trading-loss rule from annual-tax and other-benefit interactions. +# https://www.legislation.gov.uk/uksi/2006/213/regulation/38 +# https://www.legislation.gov.uk/uksi/2006/213/regulation/36 +# https://www.legislation.gov.uk/uksi/2006/213/regulation/39 + +- name: A trading loss does not offset another trade's Housing Benefit income + period: 2024 + absolute_error_margin: 0.01 + input: + age: 40 + would_claim_uc: false + self_employment_income: 12_000 + trading_loss: 8_000 + income_tax: 0 + national_insurance: 0 + tax_credits: 0 + working_tax_credit: 0 + child_tax_credit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_contributions: 0 + housing_benefit_tariff_income: 0 + housing_benefit_applicable_income_disregard: 0 + housing_benefit_applicable_income_childcare_element: 0 + housing_benefit_applicable_amount: 10_000 + housing_benefit_non_dep_deductions: 0 + LHA_eligible: false + rent: 9_000 + output: + housing_benefit_applicable_income: 12_000 + # Eligible rent 9,000 less 65% of (12,000 - 10,000) = 7,700. + housing_benefit_entitlement: 7_700 + +- name: A loss-making trade does not offset employed Housing Benefit income + period: 2024 + absolute_error_margin: 0.01 + input: + age: 40 + would_claim_uc: false + employment_income: 12_000 + self_employment_income: 0 + trading_loss: 20_000 + income_tax: 0 + national_insurance: 0 + tax_credits: 0 + working_tax_credit: 0 + child_tax_credit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_contributions: 0 + housing_benefit_tariff_income: 0 + housing_benefit_applicable_income_disregard: 0 + housing_benefit_applicable_income_childcare_element: 0 + housing_benefit_applicable_amount: 10_000 + housing_benefit_non_dep_deductions: 0 + LHA_eligible: false + rent: 9_000 + output: + housing_benefit_applicable_income: 12_000 + housing_benefit_entitlement: 7_700 + +- name: The same Housing Benefit claimant with a zero-profit trade + period: 2024 + absolute_error_margin: 0.01 + input: + age: 40 + would_claim_uc: false + employment_income: 12_000 + self_employment_income: 0 + trading_loss: 0 + income_tax: 0 + national_insurance: 0 + tax_credits: 0 + working_tax_credit: 0 + child_tax_credit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_contributions: 0 + housing_benefit_tariff_income: 0 + housing_benefit_applicable_income_disregard: 0 + housing_benefit_applicable_income_childcare_element: 0 + housing_benefit_applicable_amount: 10_000 + housing_benefit_non_dep_deductions: 0 + LHA_eligible: false + rent: 9_000 + output: + housing_benefit_applicable_income: 12_000 + housing_benefit_entitlement: 7_700 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 5b3544cce0..82e86781f4 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 @@ -51,8 +51,8 @@ self_employment_income: 12_000 trading_loss: 5_000 uc_is_in_startup_period: true - uc_income_tax_on_earnings: 0 - uc_national_insurance_on_earnings: 0 + uc_income_tax_on_earnings: 0 + uc_national_insurance_on_earnings: 0 income_tax: 0 national_insurance: 0 child_1: diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 4475c4e228..6d5ceea6bb 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -24,8 +24,10 @@ means-tested benefit and means-test income is what it is without the loss, except that UC sets it against the person's other trades' profits (UC Regs 2013 reg 57(2)), so UC, its benefit cap earnings test and the capped - award equal the same family with those profits cut by the loss. (The model's means tests deduct the year's Income Tax liability, - so otherwise the loss reaches them only through that tax.) + award equal the same family with those profits cut by the loss. The + model's means tests use annual tax deductions, so otherwise the loss + reaches them through those deductions. UC's own deductions for tax and + NI on earnings are held alongside Income Tax and NI. 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. @@ -33,15 +35,24 @@ and HBAI net income moves only through the loss itself, taxes and means-tested or passported benefits; every other component is unchanged. 7. Class 4 (SSCBA 1992 Sch 2 para 3): Class 4 profits are self-employment - profits less losses brought forward (for both taxes and for Class 4 only) - and the year's relief, never below zero, so Class 4 never rises with the - loss and someone with no profits pays none whatever their loss. + profits after capital allowances and the trading allowance, less the + separate Class 4 loss balance and the year's loss, never below zero. + The Income Tax cap and relief against other income do not limit this + deduction. Class 4 never rises with a loss; a person with no profits + 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 + 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 + lower an individual award (the tax-payment timing gap is #2108). Comparisons allow float32 rounding: the model stores values as float32. """ import numpy as np +import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st @@ -51,6 +62,8 @@ HBAI_HOUSEHOLD_NET_INCOME_SUBTRACTS, ) +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEAR = 2026 PROPERTY_SETTINGS = settings( max_examples=3, @@ -396,7 +409,7 @@ def families(draw): return people, household -def family_situation(units, overrides=None, benunit_overrides=None): +def family_situation(units, overrides=None, benunit_overrides=None, year=YEAR): situation = {"people": {}, "benunits": {}, "households": {}} for i, (people, household) in enumerate(units): names = [] @@ -405,18 +418,18 @@ def family_situation(units, overrides=None, benunit_overrides=None): values = dict(inputs) if overrides is not None: values.update(overrides.get(name, {})) - situation["people"][name] = {k: {YEAR: v} for k, v in values.items()} + situation["people"][name] = {k: {year: v} for k, v in values.items()} names.append(name) situation["benunits"][f"b{i}"] = { "members": names, **{ - k: {YEAR: v} + k: {year: v} for k, v in (benunit_overrides or {}).get(f"b{i}", {}).items() }, } situation["households"][f"h{i}"] = { "members": names, - **{k: {YEAR: v} for k, v in household.items()}, + **{k: {year: v} for k, v in household.items()}, } return situation @@ -474,6 +487,93 @@ def home(rent, region="LONDON"): ] +@pytest.mark.parametrize( + ("programme", "year"), + [("universal_credit", 2026), ("housing_benefit", 2024), ("tax_credits", 2024)], +) +@PROPERTY_SETTINGS +@example(units=PINNED_FAMILIES) +@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 + 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. + No trade has concurrent profits, so a loss cannot switch the UC benefit + cap earnings exemption through reg. 82. + """ + loss_making = [ + ([{**person, "self_employment_income": 0.0} for person in people], household) + for people, household in units + ] + zero_profit = [ + ([{**person, "trading_loss": 0.0} for person in people], household) + for people, household in loss_making + ] + benunit_inputs = { + f"b{i}": { + "would_claim_uc": programme == "universal_credit", + "would_claim_housing_benefit": programme == "housing_benefit", + "would_claim_WTC": programme == "tax_credits", + "would_claim_CTC": programme == "tax_credits", + } + for i in range(len(units)) + } + if programme == "housing_benefit": + for inputs in benunit_inputs.values(): + inputs.update( + tax_credits=0.0, + working_tax_credit=0.0, + child_tax_credit=0.0, + ) + person_inputs = ( + {f"p{i}_0": {"housing_benefit_reported": 1_000.0} for i in range(len(units))} + if programme == "housing_benefit" + else {} + ) + + def simulation(population, inputs): + return Simulation( + situation=family_situation(population, inputs, benunit_inputs, year=year) + ) + + def values(sim, variable): + return np.asarray(sim.calculate(variable, year), dtype=float) + + 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") + assert np.all(actual_tax <= baseline_tax + 0.5), (actual_tax, baseline_tax, units) + + deductions = [ + "income_tax", + "national_insurance", + "uc_income_tax_on_earnings", + "uc_national_insurance_on_earnings", + ] + held = {variable: values(baseline, variable) for variable in deductions} + mif = np.asarray(baseline.calculate("uc_mif_applies", year), dtype=bool) + for k, name in enumerate(baseline.populations["person"].ids): + person_inputs.setdefault(name, {}).update( + {variable: float(held[variable][k]) for variable in deductions} + ) + person_inputs[name]["uc_mif_applies"] = bool(mif[k]) + + loss_award = values(simulation(loss_making, person_inputs), programme) + zero_award = values(simulation(zero_profit, person_inputs), programme) + assert np.all(loss_award >= zero_award - 0.5), ( + programme, + loss_award, + zero_award, + units, + ) + + @PROPERTY_SETTINGS @example(PINNED_FAMILIES) @given(st.lists(families(), min_size=4, max_size=10)) @@ -485,7 +585,13 @@ def values(sim, variable): names = with_loss.populations["person"].ids benunits = with_loss.populations["benunit"].ids - tax = {v: values(with_loss, v) for v in ["income_tax", "national_insurance"]} + deductions = [ + "income_tax", + "national_insurance", + "uc_income_tax_on_earnings", + "uc_national_insurance_on_earnings", + ] + tax = {v: values(with_loss, v) for v in deductions} loss = values(with_loss, "trading_loss") profits = values(with_loss, "self_employment_income") mif = np.asarray(with_loss.calculate("uc_mif_applies", YEAR)) @@ -495,8 +601,7 @@ def values(sim, variable): def held(k, **extra): return { "trading_loss": 0.0, - "income_tax": float(tax["income_tax"][k]), - "national_insurance": float(tax["national_insurance"][k]), + **{v: float(tax[v][k]) for v in deductions}, **extra, } @@ -622,13 +727,21 @@ def household_values(sim, variable): st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), st.lists(money(20_000), min_size=16, max_size=16), ) -def test_class_4_profits_net_of_the_relief(people, regions, brought_forward): - people = [{**p, "loss_relief": b} for p, b in zip(people, brought_forward)] +def test_class_4_profits_net_of_the_loss_balance(people, regions, brought_forward): + people = [ + { + **p, + "loss_relief": b, + "ni_class_4_losses_brought_forward": b, + "capital_allowances": 0.0, + "trading_allowance_deduction": 0.0, + } + for p, b in zip(people, brought_forward) + ] without = [{**p, "trading_loss": 0.0} for p in people] variables = [ "ni_class_4_profits", "ni_class_4", - "trade_loss_relief_against_general_income", ] a = calculate(single_people(people, regions), variables) b = calculate(single_people(without, regions), variables) @@ -636,8 +749,10 @@ def test_class_4_profits_net_of_the_relief(people, regions, brought_forward): expected = max( 0.0, person["self_employment_income"] - - person["loss_relief"] - - a["trade_loss_relief_against_general_income"][i], + - person["capital_allowances"] + - person["trading_allowance_deduction"] + - person["trading_loss"] + - person["ni_class_4_losses_brought_forward"], ) assert abs(a["ni_class_4_profits"][i] - expected) <= tolerance( person["self_employment_income"] diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_trading_losses_set_against_profits.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_trading_losses_set_against_profits.py index 191debaee3..033cb9906f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_trading_losses_set_against_profits.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_trading_losses_set_against_profits.py @@ -11,14 +11,22 @@ class uc_trading_losses_set_against_profits(Variable): "one trade's loss reduces another trade's profits but never employed " "earnings. Over a year, with profits and losses spread evenly, that is " "the loss up to the profits. UC earnings and the benefit cap earnings " - "test (reg 82) both deduct it." + "test (reg 82) both deduct it. This annual approximation does not " + "track unused losses by assessment period under reg 57A; prior-period " + "losses must not be entered as a fresh current-year trading_loss." ) definition_period = YEAR unit = GBP - reference = dict( - title="Universal Credit Regulations 2013 reg. 57(2)", - href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", - ) + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 57(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), + dict( + title="Universal Credit Regulations 2013 reg. 57A (assessment-period losses)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57A", + ), + ] def formula(person, period, parameters): return min_( diff --git a/policyengine_uk/variables/input/trading_loss.py b/policyengine_uk/variables/input/trading_loss.py index bc43e15fbb..8d62b5b60b 100644 --- a/policyengine_uk/variables/input/trading_loss.py +++ b/policyengine_uk/variables/input/trading_loss.py @@ -17,7 +17,11 @@ class trading_loss(Variable): "against the person's other trades' profits (UC Regs 2013 reg 57(2)), " "and HB, CTR, Pension Credit and the legacy benefits not even that (HB " "Regs 2006 reg 38(10)); HBAI household income counts it as negative " - "income. Losses brought forward from earlier years go in loss_relief." + "income. Income Tax losses brought forward go in loss_relief; Class 4 " + "tracks its own unrelieved balance in ni_class_4_losses_brought_forward. " + "For a one-off loss, set subsequent years explicitly to zero for " + "Income Tax and benefits; Class 4 counts only explicitly supplied " + "loss years and carries forward the unused balance automatically." ) definition_period = YEAR unit = GBP From 31a877b7a15b9128d606ea3d5caab99960432316 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 04:47:38 -0400 Subject: [PATCH 06/11] Coordinate trading allowance and loss elections and pin review regressions --- changelog.d/trading-loss-relief.changed.md | 1 + ...de_loss_relief_against_general_income.yaml | 111 ++++++++++++++++++ .../tests/test_trading_loss_properties.py | 74 ++++++++++-- .../allowances/trading_allowance_deduction.py | 30 ++++- ...rade_loss_relief_against_general_income.py | 15 ++- .../variables/input/trading_loss.py | 4 +- 6 files changed, 221 insertions(+), 14 deletions(-) diff --git a/changelog.d/trading-loss-relief.changed.md b/changelog.d/trading-loss-relief.changed.md index 126cfcb53b..9289d4d735 100644 --- a/changelog.d/trading-loss-relief.changed.md +++ b/changelog.d/trading-loss-relief.changed.md @@ -1 +1,2 @@ - Uprated trading losses separately from profits, applied programme-specific UC and tax-credit income rules, and included losses as negative market and HBAI income. Income Tax carry-forward now uses only explicitly supplied unrelieved losses; Class 4 retains its separate automatic loss history. +- Coordinated the taxpayer's trading allowance election with current-year loss relief, retaining whichever gives the greater current-year income deduction. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml index 2d5fa940b7..a7365d8d5e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml @@ -207,3 +207,114 @@ # £40,000 of relevant UK earnings covers the £40,000 contribution. adjusted_total_income: 300_000 income_tax_relief_cap: 75_000 + +# The election covers all relevant trades in the taxpayer's year, not each +# trade independently (ITTOIA 2005 ss.783AF and 783AL; HMRC BIM86076). +- name: Giving up full trading allowance relief permits another trade's loss + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 40 + employment_income: 40_000 + self_employment_income: 800 + self_employment_gross_receipts: 900 + trading_loss: 2_000 + capital_allowances: 0 + benunits: + family: + members: [taxpayer] + households: + household: + members: [taxpayer] + region: LONDON + output: + trading_allowance_deduction: 0 + taxable_self_employment_income: 800 + trade_loss_relief_against_general_income: 2_000 + net_income: 38_800 + # (40,000 + 800 - 2,000 - 12,570) x 20%. + income_tax: 5_246 + +- name: Full allowance relief is retained when it beats claiming the current loss + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + self_employment_income: 800 + self_employment_gross_receipts: 900 + trading_loss: 200 + output: + trading_allowance_deduction: 800 + trade_loss_relief_against_general_income: 0 + taxable_self_employment_income: 0 + net_income: 40_000 + income_tax: 5_486 + +- name: Partial allowance relief replaces expenses of the losing trade too + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + self_employment_income: 4_800 + self_employment_gross_receipts: 5_000 + trading_loss: 500 + output: + trading_allowance_deduction: 800 + trade_loss_relief_against_general_income: 0 + taxable_self_employment_income: 4_000 + net_income: 44_000 + income_tax: 6_286 + +- name: A larger current loss beats partial trading allowance relief + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + self_employment_income: 4_800 + self_employment_gross_receipts: 5_000 + trading_loss: 900 + output: + trading_allowance_deduction: 0 + trade_loss_relief_against_general_income: 900 + taxable_self_employment_income: 4_800 + net_income: 43_900 + income_tax: 6_266 + +- name: Prior Income Tax losses remain usable with partial allowance relief + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + self_employment_income: 4_800 + self_employment_gross_receipts: 5_000 + trading_loss: 500 + loss_relief: 1_000 + output: + trading_allowance_deduction: 800 + trade_loss_relief_against_general_income: 0 + taxable_self_employment_income: 3_000 + net_income: 43_000 + income_tax: 6_086 + +- name: Prior losses removing profits make a current loss claim the better choice + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + self_employment_income: 800 + self_employment_gross_receipts: 900 + trading_loss: 200 + loss_relief: 800 + output: + trading_allowance_deduction: 0 + trade_loss_relief_against_general_income: 200 + taxable_self_employment_income: 0 + net_income: 39_800 + income_tax: 5_446 diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 6d5ceea6bb..f2360db709 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -6,8 +6,10 @@ 1. Relief definition (ITA 2007 s.64, s.24A): trade loss relief against general income equals min(loss, max(£50,000, 25% of adjusted total - income), net income). Net income is the adjusted net income of the same - person without the loss (a separate simulation); adjusted total income is + income), net income), if ordinary expenses and losses beat the trading + allowance. Otherwise the allowance applies and current loss relief is nil. + Net income is the adjusted net income under ordinary expense treatment + without the loss (a separate simulation); adjusted total income is that less the pension contributions given relief (own contributions up to the greater of £3,600 and pay plus profits, none from age 75). 2. Differential: a person's Income Tax with a loss equals the Income Tax of @@ -111,6 +113,7 @@ "free_school_meals", "free_school_fruit_veg", "free_school_milk", + "free_tv_licence_value", "healthy_start_vouchers", "scottish_child_payment", "cost_of_living_support_payment", @@ -185,7 +188,7 @@ def taxpayer(**inputs): # Cases every run checks, alongside the generated ones: relief limited by net # income, the 25% and £50,000 caps, contributions above relevant earnings, # no relief from age 75, profits and a loss together, and income spilling -# into savings and dividends. +# into savings and dividends. Repeat the cases for England and Scotland. PINNED_TAXPAYERS = [ taxpayer(employment_income=20_000, trading_loss=50_000), taxpayer(employment_income=400_000, trading_loss=300_000), @@ -209,7 +212,7 @@ def taxpayer(**inputs): trading_loss=20_000, ), taxpayer(employment_income=110_000, trading_loss=20_000), -] +] * 2 PINNED_REGIONS = ["LONDON"] * 8 + ["SCOTLAND"] * 8 @@ -220,7 +223,9 @@ def taxpayer(**inputs): st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), ) def test_relief_is_the_loss_within_net_income_and_the_cap(people, regions): - without = [{**p, "trading_loss": 0.0} for p in people] + without = [ + {**p, "trading_loss": 0.0, "trading_allowance_deduction": 0.0} for p in people + ] a = calculate( single_people(people, regions), ["trade_loss_relief_against_general_income", "adjusted_net_income"], @@ -243,12 +248,26 @@ def test_relief_is_the_loss_within_net_income_and_the_cap(people, regions): ) ati = max(0.0, b["adjusted_net_income"][i] - relieved) cap = max(50_000.0, 0.25 * ati) - expected = min(person["trading_loss"], cap, b["adjusted_net_income"][i]) + # Generated taxpayers have no receipts, capital allowances or prior + # losses. With unknown receipts, allowance treatment fully exempts a + # profit within £1,000; ordinary treatment wins if the loss is larger. + profit = person["self_employment_income"] + allowance = profit if profit <= 1_000 else 0.0 + ordinary = person["trading_loss"] > allowance + expected = ( + min(person["trading_loss"], cap, b["adjusted_net_income"][i]) + if ordinary + else 0.0 + ) + allowance_kept = 0.0 if ordinary else allowance relief = a["trade_loss_relief_against_general_income"][i] assert abs(relief - expected) <= tolerance(expected, ati), (person, relief) - # Net income falls by exactly the relief. + # Ordinary net income falls by the chosen relief or allowance. assert abs( - b["adjusted_net_income"][i] - relief - a["adjusted_net_income"][i] + b["adjusted_net_income"][i] + - allowance_kept + - relief + - a["adjusted_net_income"][i] ) <= tolerance(b["adjusted_net_income"][i]), person @@ -327,7 +346,7 @@ def reduced(person): "trading_loss": 15_000.0, "state_pension": 0.0, }, -] * 2 +] * 4 # Eight cases for each of the two regions in PINNED_REGIONS. @PROPERTY_SETTINGS @@ -456,6 +475,25 @@ def home(rent, region="LONDON"): } +PINNED_PENSION_CREDIT_PASSPORT_FAMILY = ( + [ + adult( + age=76, + private_pension_income=19_500.0, + trading_loss=10_000.0, + state_pension=0.0, + hours_worked=0.0, + ), + {"age": 1, "trading_loss": 0.0}, + ], + { + **home(0.0), + "household_owns_tv": True, + "would_evade_tv_licence_fee": False, + }, +) + + # Families every run checks: profits and another trade's loss under the # benefit cap, a loss beside pay only, a child's loss, and a loss-making # couple with pay. @@ -679,6 +717,7 @@ def run(us): @PROPERTY_SETTINGS @example(PINNED_FAMILIES) +@example([PINNED_PENSION_CREDIT_PASSPORT_FAMILY]) @given(st.lists(families(), min_size=4, max_size=10)) def test_household_income_moves_only_through_its_channels(units): without = [ @@ -720,6 +759,23 @@ def household_values(sim, variable): ) +def test_loss_can_remove_pension_credit_and_its_tv_licence_passport(): + """The annual tax deduction can change Pension Credit and its passport.""" + people, household = PINNED_PENSION_CREDIT_PASSPORT_FAMILY + without = ([{**p, "trading_loss": 0.0} for p in people], household) + variables = ["income_tax", "pension_credit", "free_tv_licence_value"] + loss = calculate( + family_situation([PINNED_PENSION_CREDIT_PASSPORT_FAMILY]), variables + ) + zero = calculate(family_situation([without]), variables) + assert zero["income_tax"][0] == pytest.approx(1_386.0, abs=0.01) + assert loss["income_tax"][0] == 0 + assert zero["pension_credit"][0] > 0 + assert loss["pension_credit"][0] == 0 + assert zero["free_tv_licence_value"][0] > 0 + assert loss["free_tv_licence_value"][0] == 0 + + @PROPERTY_SETTINGS @example(PINNED_TAXPAYERS, PINNED_REGIONS, [5_000.0] * 16) @given( diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py index 6fe3203318..1a0b855385 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py @@ -17,7 +17,13 @@ class trading_allowance_deduction(Variable): "it, and a larger profit as already reflecting the better of actual " "expenses and the allowance, so taxable profit jumps from nil to the " "full profit just above the allowance. Receipts below profit are " - "inconsistent and treated as unknown." + "inconsistent and treated as unknown. The model chooses the greater " + "current-year income deduction: ordinary expenses with a current-year " + "loss claim when that loss exceeds the allowance's effective saving " + "after other deductions; allowance treatment otherwise, including " + "ties. This election covers all the individual's relevant trades " + "for the tax year. Allowance treatment makes their current losses " + "nil for Income Tax. It does not optimise future loss use or benefits." ) definition_period = YEAR reference = [ @@ -33,6 +39,14 @@ class trading_allowance_deduction(Variable): title="HMRC Business Income Manual BIM86050", href="https://www.gov.uk/hmrc-internal-manuals/business-income-manual/bim86050", ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783AL (election)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AL", + ), + dict( + title="HMRC Business Income Manual BIM86076 (loss election)", + href="https://www.gov.uk/hmrc-internal-manuals/business-income-manual/bim86076", + ), ] unit = GBP @@ -53,4 +67,16 @@ def formula(person, period, parameters): profit <= allowance, profit - capital_allowances, 0 ) relief = where(receipts_known, relief_with_receipts, relief_without_receipts) - return max_(relief, 0) + relief = max_(relief, 0) + # Full relief makes every relevant trade's current profit AND loss + # nil (s.783AF); the opt-out is for the individual/year (s.783AL). + # Partial relief also replaces every trade's actual expenses. Choose + # ordinary treatment if the current loss saves more income than the + # allowance. The statutory £50,000 cap exceeds the £1,000 allowance. + # Prior s.83 losses remain separate and may follow partial relief. + profit_after_other_deductions = max_( + 0, profit - capital_allowances - person("loss_relief", period) + ) + allowance_saving = min_(relief, profit_after_other_deductions) + claim_current_loss = person("trading_loss", period) > allowance_saving + return where(claim_current_loss, 0, relief) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py index 7c2aa67c83..1639637052 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py @@ -14,7 +14,11 @@ class trade_loss_relief_against_general_income(Variable): "use. The model assumes the trade is commercial (s.66), that the person " "claims, and that the claim is for the loss-making year rather than " "the year before (s.64(2)(b)); the part it cannot use is carried " - "forward only by entering it in loss_relief in later years (s.83)." + "forward only by entering it in loss_relief in later years (s.83). " + "A current loss is recognised only under ordinary expense treatment; " + "the trading allowance election chooses the greater current-year " + "income deduction for all relevant trades together (ITTOIA 2005 " + "ss.783AF and 783AL; HMRC BIM86076)." ) definition_period = YEAR unit = GBP @@ -31,7 +35,14 @@ class trade_loss_relief_against_general_income(Variable): def formula(person, period, parameters): net_income = max_(0, person("net_income_before_trade_loss_relief", period)) - return min_( + # Allowance treatment cannot retain a current expense-generated loss + # on another trade in the same individual's tax year. + loss = where( + person("trading_allowance_deduction", period) > 0, + 0, person("trading_loss", period), + ) + return min_( + loss, min_(person("income_tax_relief_cap", period), net_income), ) diff --git a/policyengine_uk/variables/input/trading_loss.py b/policyengine_uk/variables/input/trading_loss.py index 8d62b5b60b..8d6ca14528 100644 --- a/policyengine_uk/variables/input/trading_loss.py +++ b/policyengine_uk/variables/input/trading_loss.py @@ -10,7 +10,9 @@ class trading_loss(Variable): "vocations, as a positive amount. Profits of their other trades go in " "self_employment_income, which is never negative. Each programme reads " "the loss by its own rule: Income Tax deducts it from general income " - "(ITA 2007 s.64, within the s.24A cap); Class 4 NICs deduct it from " + "(ITA 2007 s.64, within the s.24A cap), if ordinary expense treatment " + "beats the trading allowance for the individual's year; Class 4 NICs " + "deduct it from " "trading profits only (SSCBA 1992 Sch 2 para 3); tax credits deduct it " "from the claimants' income (SI 2002/2006 reg 3(1) Step 4); means tests " "never set it against employed earnings or other income: UC sets it " From e1debd40ecc20194bd1eec7446c3f95c08ae32f4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 08:45:58 -0400 Subject: [PATCH 07/11] Apply trading allowance election to annual Class 4 losses --- changelog.d/trading-loss-relief.changed.md | 2 +- .../class_4/ni_class_4_trading_loss.yaml | 114 ++++++++++ .../test_ni_class_4_profits_properties.py | 199 ++++++++++++------ .../allowances/trading_allowance_deduction.py | 3 +- .../class_4/ni_class_4_trading_loss.py | 31 ++- 5 files changed, 285 insertions(+), 64 deletions(-) diff --git a/changelog.d/trading-loss-relief.changed.md b/changelog.d/trading-loss-relief.changed.md index 9289d4d735..1d819f428e 100644 --- a/changelog.d/trading-loss-relief.changed.md +++ b/changelog.d/trading-loss-relief.changed.md @@ -1,2 +1,2 @@ - Uprated trading losses separately from profits, applied programme-specific UC and tax-credit income rules, and included losses as negative market and HBAI income. Income Tax carry-forward now uses only explicitly supplied unrelieved losses; Class 4 retains its separate automatic loss history. -- Coordinated the taxpayer's trading allowance election with current-year loss relief, retaining whichever gives the greater current-year income deduction. +- Coordinated the taxpayer's trading allowance election with current-year Income Tax and Class 4 loss relief, retaining whichever gives the greater current-year income deduction. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml index a3fac598ea..d60186a916 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml @@ -59,6 +59,120 @@ # Income Tax is unaffected: £50,000 less the £12,570 Personal Allowance at 20%. income_tax: 7_486 +- name: Partial allowance excludes the year's ordinary Class 4 loss + period: 2026 + absolute_error_margin: 0.01 + input: + # Trade A receipts 20,200 less expenses 200 give profit 20,000. + # Trade B receipts 100 less expenses 799 give loss 699. + # Total receipts 20,300 less the allowance give profits 19,300, + # better than ordinary net profits 19,301. B's loss is not also relieved. + self_employment_income: 20_000 + self_employment_gross_receipts: 20_300 + trading_loss: 699 + output: + trading_allowance_deduction: 700 + ni_class_4_profits_before_losses: 19_300 + ni_class_4_trading_loss: 0 + ni_class_4_loss_relief: 0 + ni_class_4_losses_carried_forward: 0 + ni_class_4_profits: 19_300 + ni_class_4: 403.80 + +- name: Equal current loss and allowance saving choose the allowance + period: 2026 + absolute_error_margin: 0.01 + input: + # The same trades, with B expenses 800 and loss 700. + self_employment_income: 20_000 + self_employment_gross_receipts: 20_300 + trading_loss: 700 + output: + trading_allowance_deduction: 700 + ni_class_4_trading_loss: 0 + ni_class_4_losses_carried_forward: 0 + ni_class_4_profits: 19_300 + ni_class_4: 403.80 + +- name: Ordinary loss relief beats the partial allowance without increasing Class 4 + period: 2026 + absolute_error_margin: 0.01 + input: + # The same trades, with B expenses 801 and loss 701: ordinary net + # profits 19,299 beat allowance profits 19,300. + self_employment_income: 20_000 + self_employment_gross_receipts: 20_300 + trading_loss: 701 + output: + trading_allowance_deduction: 0 + ni_class_4_profits_before_losses: 20_000 + ni_class_4_trading_loss: 701 + ni_class_4_loss_relief: 701 + ni_class_4_losses_carried_forward: 0 + ni_class_4_profits: 19_299 + ni_class_4: 403.74 + +- name: Full allowance makes current Class 4 profits and losses nil + period: 2026 + absolute_error_margin: 0 + input: + # Trade A profit and receipts 800; B receipts 100 and expenses 799. + # Full relief beats ordinary net profit 101 and makes both trades nil. + self_employment_income: 800 + self_employment_gross_receipts: 900 + trading_loss: 699 + output: + trading_allowance_deduction: 800 + ni_class_4_profits_before_losses: 0 + ni_class_4_trading_loss: 0 + ni_class_4_losses_carried_forward: 0 + ni_class_4_profits: 0 + ni_class_4: 0 + +- name: Earlier nominal Class 4 balance survives this year's allowance election + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: + 2025: 50_000 + 2026: 0 + 2027: 0 + self_employment_income: + 2025: 0 + 2026: 20_000 + 2027: 30_000 + self_employment_gross_receipts: + 2025: 0 + 2026: 20_300 + 2027: 0 + trading_loss: + 2025: 20_000 + 2026: 699 + 2027: 0 + output: + # The 2025 loss was relieved against pay for Income Tax but remains + # available for Class 4 at its nominal amount. In 2026 the trades above + # elect the allowance, so their new 699 loss never enters the balance. + trading_allowance_deduction: + 2026: 700 + 2027: 0 + ni_class_4_trading_loss: + 2025: 20_000 + 2026: 0 + 2027: 0 + ni_class_4_losses_brought_forward: + 2026: 20_000 + 2027: 700 + ni_class_4_losses_carried_forward: + 2026: 700 + 2027: 0 + ni_class_4_profits: + 2026: 0 + 2027: 29_300 + ni_class_4: + 2026: 0 + 2027: 1_003.80 + - name: A sideways loss used for Income Tax still carries forward for Class 4 period: 2026 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index 635c946d2e..cfd70589b5 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -10,12 +10,13 @@ 1. Differential: ni_class_4_profits_before_losses, ni_class_4_loss_relief, ni_class_4_losses_brought_forward, ni_class_4_losses_carried_forward and ni_class_4_profits equal an independent exact-rational reference: the - better of actual deductions and the allowance where receipts are known, - and losses set against each year's profits, earliest first, with the - rest carried forward without limit. -2. Accounting: trading_loss + losses brought forward = loss relief + losses - carried forward; losses brought forward equal last year's losses carried - forward; 0 <= loss relief <= profits before losses. + better of ordinary expenses with current loss relief and the allowance + where receipts are known, and eligible losses set against each year's + profits, earliest first, with the rest carried forward without limit. +2. Accounting: eligible current loss + losses brought forward = loss relief + + losses carried forward; supplied losses excluded by allowance treatment + receive no Class 4 relief; losses brought forward equal last year's losses + carried forward; 0 <= loss relief <= profits before losses. 3. Bound: 0 <= ni_class_4 <= the s. 15(3) amount on the reference Class 4 profits <= the s. 15(3) amount on the unrelieved profit. Where regulation 100 cannot apply (no Class 1 and, from April 2024, Class 2 irrelevant), @@ -31,7 +32,8 @@ model matches a reference in which a year without an entry makes no new loss, though the engine carries the last entry into it. Total relief never exceeds the losses entered (relief plus the final carry-forward - equals them), Class 4 profits are never negative, and a year with no + plus losses excluded by allowance treatment equals them), Class 4 profits + are never negative, and a year with no loss entered and none brought forward has no relief. Calculating income tax's loss relief or the carried-over trading_loss first, or calculating on a branch, changes none of this. @@ -116,23 +118,62 @@ def reference_allowance(simulation, year): ) -def reference_profits_before_losses(profit, receipts, capital_allowances, allowance): - """ITTOIA 2005 Part 2 Chapter 2 profits, after capital allowances and - the trading allowance, before loss relief.""" +def reference_trade( + profit, receipts, capital_allowances, allowance, current_loss, supplied_loss=None +): + """Independent profits and eligible annual Class 4 loss. + + Compare ordinary-expense profit less the current loss with allowance + profit, which excludes that loss (ITTOIA ss. 783AF and 783AI). Ties use + the allowance. Only a loss supplied for this year can enter the separate + Class 4 balance, even if the engine fills in a current Income Tax loss + from an earlier input. + """ + ordinary_profit = max(Fraction(0), profit - capital_allowances) if receipts > 0 and receipts >= profit: if receipts <= allowance: # s. 783AF: the trade's profits are nil. - return Fraction(0) - # The better of actual deductions and the allowance (s. 783AI); - # capital allowances are a deduction (CAA 2001 s. 247) that the - # allowance replaces, so the two never stack. - actual_deductions = receipts - profit + capital_allowances - return max(Fraction(0), receipts - max(actual_deductions, allowance)) - # Receipts unknown: a profit within the allowance is taken to come from - # receipts within it; a larger one already reflects the better deduction. - if profit <= allowance: - return Fraction(0) - return max(Fraction(0), profit - capital_allowances) + allowance_profit = Fraction(0) + else: + # Capital allowances are expenses (CAA 2001 s. 247), replaced + # rather than added to by the trading allowance. + actual_deductions = receipts - profit + capital_allowances + allowance_profit = max( + Fraction(0), receipts - max(actual_deductions, allowance) + ) + else: + # With unknown receipts, the model uses full relief only for a + # profit within the allowance; larger profits are reported net. + allowance_profit = Fraction(0) if profit <= allowance else ordinary_profit + annual_loss = max( + current_loss if supplied_loss is None else supplied_loss, Fraction(0) + ) + if current_loss > ordinary_profit - allowance_profit: + return ordinary_profit, annual_loss + if allowance_profit < ordinary_profit: + return allowance_profit, Fraction(0) + # No allowance deduction was available, so ordinary losses remain. + return ordinary_profit, annual_loss + + +def reference_uprated_loss(simulation, amount, input_year, target_year): + """An income input uprates; an unrelieved Class 4 balance stays nominal.""" + parameters = simulation.tax_benefit_system.parameters + + def index(year): + return exact( + parameters( + f"{year}-01-01" + ).gov.economic_assumptions.indices.obr.per_capita.mixed_income + ) + + return exact(np.float32(amount)) * index(target_year) / index(input_year) + + +def assert_uprated_loss_cache(simulation, target_year): + expected = reference_uprated_loss(simulation, 10_000, 2025, target_year) + actual = float(simulation.calculate("trading_loss", target_year)[0]) + assert abs(actual - float(expected)) <= tolerance(actual, expected) def reference_losses(years, supplied=None): @@ -255,6 +296,7 @@ def calculate(variable, period): receipts=calculate("self_employment_gross_receipts", period), capital_allowances=calculate("capital_allowances", period), trading_loss=calculate("trading_loss", period), + effective_trading_loss=calculate("ni_class_4_trading_loss", period), **{ name: calculate(f"ni_class_4_{name}", period) for name in [ @@ -275,19 +317,21 @@ def calculate(variable, period): def reference(values, i): years = [values[period] for period in sorted(values)] - profits = [ - reference_profits_before_losses( + trades = [ + reference_trade( exact(v["profit"][i]), exact(v["receipts"][i]), exact(v["capital_allowances"][i]), v["allowance"], + exact(v["trading_loss"][i]), ) for v in years ] - losses = reference_losses( - [(p, exact(v["trading_loss"][i])) for p, v in zip(profits, years)] - ) - return [dict(profits_before_losses=p, **l) for p, l in zip(profits, losses)] + losses = reference_losses(trades) + return [ + dict(profits_before_losses=p, effective_trading_loss=loss, **l) + for (p, loss), l in zip(trades, losses) + ] def unchanged(profit, expenses, allowances, loss, mode, share, variation): @@ -325,7 +369,7 @@ def test_class_4_profits_match_statutory_reference(sim, drawn): ) assert ( abs( - max(model["trading_loss"][i], 0) + model["effective_trading_loss"][i] + model["losses_brought_forward"][i] - model["loss_relief"][i] - model["losses_carried_forward"][i] @@ -401,6 +445,22 @@ def test_class_4_is_non_decreasing_in_profit(sim, drawn): @PROPERTY_SETTINGS @given(cases, st.sampled_from([more_capital_allowances, more_trading_loss])) +@example( + drawn=[ + ( + (0.0, 0.0, 0.0, 0.0, "unknown", 0.5), + # Trade A: receipts 20,200, expenses 200, profit 20,000. + # Trade B: receipts 100, expenses 799, loss 699. Combined + # receipts are 20,300; increasing B's expenses by 2 makes + # ordinary treatment beat the allowance's 700 saving. + (20_000.0, 300.0, 0.0, 699.0, "consistent", 0.5), + 0.0, + 2.0, + ) + ] + * CASES, + vary=more_trading_loss, +) def test_class_4_is_non_increasing_in_reliefs(sim, drawn, vary): values = run(sim, drawn, vary) current = values[sim[1]] @@ -464,13 +524,11 @@ def test_losses_carry_forward_over_many_years(years, opening_balance, random): ) expected = reference_losses( [ - ( - reference_profits_before_losses( - exact(sim.calculate("self_employment_income", period)[0]), - Fraction(0), - Fraction(0), - reference_allowance(sim, period), - ), + reference_trade( + exact(sim.calculate("self_employment_income", period)[0]), + Fraction(0), + Fraction(0), + reference_allowance(sim, period), exact(sim.calculate("trading_loss", period)[0]), ) for period in periods @@ -544,12 +602,13 @@ def test_pre_allowance_profits_exhaust_the_loss_in_the_historical_reference(): ) periods = (2015, 2016) profits = [ - reference_profits_before_losses( + reference_trade( Fraction(500), Fraction(0), Fraction(0), reference_allowance(sim, period), - ) + Fraction(1_000 if period == 2015 else 0), + )[0] for period in periods ] assert profits == [500, 500] @@ -616,21 +675,31 @@ def test_a_loss_entered_for_some_years_is_relieved_once(years, random): stored_losses = { period: exact(np.float32(loss)) for period, loss in entered.items() } - expected = reference_losses( - [ - ( - reference_profits_before_losses( - exact(np.float32(profit)), - Fraction(0), - Fraction(0), - reference_allowance(sim, period), - ), + trades = [] + for period, (profit, _, _) in zip(periods, years): + earlier_inputs = [year for year in stored_losses if year < period] + if period in stored_losses: + tax_loss = stored_losses[period] + elif earlier_inputs: + input_year = max(earlier_inputs) + tax_loss = reference_uprated_loss( + sim, stored_losses[input_year], input_year, period + ) + else: + tax_loss = Fraction(0) + trades.append( + reference_trade( + exact(np.float32(profit)), + Fraction(0), + Fraction(0), + reference_allowance(sim, period), + tax_loss, stored_losses.get(period, Fraction(0)), ) - for period, (profit, _, _) in zip(periods, years) - ] - ) + ) + expected = reference_losses(trades) names = [ + "trading_loss", "losses_brought_forward", "loss_relief", "losses_carried_forward", @@ -644,10 +713,15 @@ def test_a_loss_entered_for_some_years_is_relieved_once(years, random): model[period][name] = float(sim.calculate(f"ni_class_4_{name}", period)[0]) total_entered = float(sum(stored_losses.values())) + total_eligible = float(sum(loss for _, loss in trades)) + excluded_by_allowance = total_entered - total_eligible tol = tolerance(total_entered, *(profit for profit, _, _ in years)) for period in periods: reference_year = expected[period - first_year] + reference_profit, reference_loss = trades[period - first_year] values = model[period] + assert abs(values["trading_loss"] - float(reference_loss)) <= tol + assert abs(values["profits_before_losses"] - float(reference_profit)) <= tol for name, reference_name in [ ("losses_brought_forward", "brought_forward"), ("loss_relief", "relief"), @@ -666,12 +740,18 @@ def test_a_loss_entered_for_some_years_is_relieved_once(years, random): # No relief without a loss entered for the year or brought forward. if stored_losses.get(period, 0) == 0 and values["losses_brought_forward"] == 0: assert values["loss_relief"] == 0, (period, values) - # Conservation: each loss entered is relieved at most once, and what is - # not relieved is still being carried forward at the end. + # Conservation: each eligible loss is relieved at most once. The rest + # is carried forward; current losses excluded by the allowance receive + # neither relief nor a carried balance. total_relief = sum(model[period]["loss_relief"] for period in periods) assert total_relief <= total_entered + tol assert ( - abs(total_relief + model[last_year]["losses_carried_forward"] - total_entered) + abs( + total_relief + + model[last_year]["losses_carried_forward"] + + excluded_by_allowance + - total_entered + ) <= tol ) @@ -689,8 +769,9 @@ def test_class_4_counts_only_supplied_losses_on_branches(): ) branch = sim.get_branch("extra_2026_loss") branch.set_input("trading_loss", 2026, np.array([5_000.0])) - # The engine carries the 2025 loss into 2027, but it is not a new loss. - assert sim.calculate("trading_loss", 2027)[0] == 10_000 + # The engine uprates the 2025 income input into 2027, but it is not a + # new loss. The separate Class 4 balance stays at its nominal amount. + assert_uprated_loss_cache(sim, 2027) assert sim.calculate("ni_class_4_profits", 2026)[0] == 30_000 assert sim.calculate("ni_class_4_profits", 2027)[0] == 40_000 # On the branch, 2026 relieves its own 5,000 and the 10,000 from 2025. @@ -712,8 +793,8 @@ def test_class_4_counts_only_supplied_losses_on_plain_clones(): clone = sim.clone() clone.set_input("trading_loss", 2026, np.array([5_000.0])) # Warm the original's carry-over cache after the clone records its input. - # A shared provenance set would count this cached 10,000 as a new loss. - assert sim.calculate("trading_loss", 2026)[0] == 10_000 + # A shared provenance set would count this uprated cache as a new loss. + assert_uprated_loss_cache(sim, 2026) observed = ( float(sim.calculate("ni_class_4_profits", 2026)[0]), round(float(sim.calculate("ni_class_4", 2026)[0]), 2), @@ -740,7 +821,7 @@ def test_class_4_does_not_count_a_deleted_loss_after_recalculation(on_branch): sim.delete_arrays("trading_loss", 2026) # Deletion leaves the earlier loss available for engine carry-over, but # that replacement cache must not retain the deleted input's provenance. - assert sim.calculate("trading_loss", 2026)[0] == 10_000 + assert_uprated_loss_cache(sim, 2026) observed = ( float(sim.calculate("ni_class_4_profits", 2026)[0]), round(float(sim.calculate("ni_class_4", 2026)[0]), 2), @@ -782,7 +863,7 @@ def test_class_4_does_not_count_a_loss_deleted_from_its_holder(on_branch, delete # as a second loss (profits of 20,000 and contributions of 445.80), also # when the deleted loss was 10,000 too. sim.get_holder("trading_loss").delete_arrays(period_(2026), sim.branch_name) - assert sim.calculate("trading_loss", 2026)[0] == 10_000 + assert_uprated_loss_cache(sim, 2026) population = sim.get_variable_population("trading_loss") assert supplied_input(population, "trading_loss", period_(2026)) is None assert supplied_input_periods(population, "trading_loss") == [period_(2025)] @@ -815,7 +896,7 @@ def situation(losses): # 2025 loss into 2026; with the stale record that 10,000 would count as a # second loss (profits of 20,000 and contributions of 445.80). sim.build_from_situation(situation({2025: 10_000})) - assert sim.calculate("trading_loss", 2026)[0] == 10_000 + assert_uprated_loss_cache(sim, 2026) population = sim.get_variable_population("trading_loss") assert supplied_input(population, "trading_loss", period_(2026)) is None assert supplied_input_periods(population, "trading_loss") == [period_(2025)] diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py index 1a0b855385..45e5e6f177 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py @@ -23,7 +23,8 @@ class trading_allowance_deduction(Variable): "after other deductions; allowance treatment otherwise, including " "ties. This election covers all the individual's relevant trades " "for the tax year. Allowance treatment makes their current losses " - "nil for Income Tax. It does not optimise future loss use or benefits." + "nil for Income Tax and Class 4. It does not optimise future loss use " + "or benefits." ) definition_period = YEAR reference = [ diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py index fed2f5323d..5bda122912 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py @@ -7,8 +7,12 @@ class ni_class_4_trading_loss(Variable): entity = Person label = "Trading losses made in the year, for Class 4" documentation = ( - "The year's trading_loss where one is supplied for the year, and nil " - "otherwise. Like other inputs, trading_loss carries an earlier " + "The year's trading_loss where one is supplied for the year and " + "ordinary expense treatment is chosen, and nil otherwise. The " + "trading allowance recomputes every relevant trade's profits and " + "losses for Income Tax and Class 4, so current losses cannot also " + "be claimed. Earlier unrelieved Class 4 balances remain separate. " + "Like other inputs, trading_loss carries an earlier " "year's value into later years that are not set, but for Class 4 a " "loss is relieved once: it reduces the profits of the year it is " "made, and only the excess reduces the profits of later years " @@ -26,6 +30,22 @@ class ni_class_4_trading_loss(Variable): title="Income Tax Act 2007, s. 83(2)", href="https://www.legislation.gov.uk/ukpga/2007/3/section/83", ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783AF", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AF", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783AI", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AI", + ), + dict( + title="HMRC Business Income Manual BIM86052", + href="https://www.gov.uk/hmrc-internal-manuals/business-income-manual/bim86052", + ), + dict( + title="HMRC National Insurance Manual NIM24610", + href="https://www.gov.uk/hmrc-internal-manuals/national-insurance-manual/nim24610", + ), ] def formula(person, period, parameters): @@ -35,4 +55,9 @@ def formula(person, period, parameters): loss = supplied_input(person, "trading_loss", period) if loss is None: return person.empty_array() - return max_(loss, 0) + # Class 4 uses the same year's recomputed trade profit or loss as + # Income Tax. A positive allowance deduction means the taxpayer + # chose allowance treatment and gave up ordinary current losses. + # This leaves independently carried earlier Class 4 losses intact. + allowance_chosen = person("trading_allowance_deduction", period) > 0 + return where(allowance_chosen, 0, max_(loss, 0)) From 5c0d13b44d53514d3d2d2b764bda7acd85f73655 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 09:15:50 -0400 Subject: [PATCH 08/11] Preserve DOB property coverage across host generation stalls --- changelog.d/date-of-birth-properties.fixed.md | 1 + policyengine_uk/tests/test_date_of_birth.py | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 changelog.d/date-of-birth-properties.fixed.md diff --git a/changelog.d/date-of-birth-properties.fixed.md b/changelog.d/date-of-birth-properties.fixed.md new file mode 100644 index 0000000000..439c72f952 --- /dev/null +++ b/changelog.d/date-of-birth-properties.fixed.md @@ -0,0 +1 @@ +- Made date-of-birth property tests tolerate host scheduling and garbage-collection stalls during bounded input generation, preserving their assertions, examples and other health checks. diff --git a/policyengine_uk/tests/test_date_of_birth.py b/policyengine_uk/tests/test_date_of_birth.py index dc300f06a8..9212a05a97 100644 --- a/policyengine_uk/tests/test_date_of_birth.py +++ b/policyengine_uk/tests/test_date_of_birth.py @@ -12,7 +12,7 @@ import numpy as np import pandas as pd import pytest -from hypothesis import example, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -81,7 +81,10 @@ def test_household_situations_match_the_birth_year_rule(): assert np.array_equal(birth_year, [year - age for age in ages]), year -@settings(max_examples=30, deadline=None) +# Bounded primitive draws can include scheduling/GC stalls after expensive +# model runs. Reproduced generation-timing failures gave no model counterexample; +# suppress only that timing check, preserving examples and all other checks. +@settings(max_examples=30, deadline=None, suppress_health_check=[HealthCheck.too_slow]) @given( cases=st.lists( st.tuples( @@ -122,7 +125,7 @@ def test_cutoffs_follow_the_date_of_birth(cases, year): assert ctc[i] == (expected < CUTOFF), (year, age, months) -@settings(max_examples=25, deadline=None) +@settings(max_examples=25, deadline=None, suppress_health_check=[HealthCheck.too_slow]) @given( children=st.lists( st.tuples( @@ -262,7 +265,7 @@ def births_and_year(draw): return births, year -@settings(max_examples=25, deadline=None) +@settings(max_examples=25, deadline=None, suppress_health_check=[HealthCheck.too_slow]) @given(case=births_and_year()) # Transitional cohorts in the year they reach the qualifying age, where a # wrong birth instant changes has_attained_state_pension_credit_qualifying_age From 2d408a2af17e6380d1fcea0ee303cb7e8168e107 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 14:43:34 -0400 Subject: [PATCH 09/11] Use supplied annual losses for the trading allowance election --- .../trading-loss-allowance-election.fixed.md | 1 + .../class_4/ni_class_4_trading_loss.yaml | 28 +++++++++++++++++++ .../test_ni_class_4_profits_properties.py | 25 ++++------------- .../allowances/trading_allowance_deduction.py | 27 ++++++++++++------ ...rade_loss_relief_against_general_income.py | 10 +++++-- 5 files changed, 60 insertions(+), 31 deletions(-) create mode 100644 changelog.d/trading-loss-allowance-election.fixed.md diff --git a/changelog.d/trading-loss-allowance-election.fixed.md b/changelog.d/trading-loss-allowance-election.fixed.md new file mode 100644 index 0000000000..780af04eb7 --- /dev/null +++ b/changelog.d/trading-loss-allowance-election.fixed.md @@ -0,0 +1 @@ +- Base the trading allowance election on losses supplied for that tax year, preserving earlier Class 4 loss balances when later loss inputs are unset. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml index d60186a916..322b36dda7 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml @@ -196,3 +196,31 @@ ni_class_4: 1_045.8 # The loss was used for Income Tax in 2025, so cannot reduce it again. income_tax: 7_486 + +- name: An unset current loss preserves the allowance and earlier Class 4 balance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: + 2025: 50_000 + 2026: 0 + self_employment_income: + 2025: 0 + 2026: 900 + self_employment_gross_receipts: + 2025: 0 + 2026: 900 + trading_loss: + 2025: 10_000 + # Deliberately no 2026 entry: an earlier Income Tax input can uprate + # into this year but is not a current loss for the allowance election. + output: + trading_allowance_deduction: 900 + ni_class_4_profits_before_losses: 0 + ni_class_4_trading_loss: 0 + ni_class_4_losses_brought_forward: 10_000 + ni_class_4_loss_relief: 0 + ni_class_4_losses_carried_forward: 10_000 + ni_class_4_profits: 0 + ni_class_4: 0 diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index cfd70589b5..5980c7ba22 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -118,16 +118,14 @@ def reference_allowance(simulation, year): ) -def reference_trade( - profit, receipts, capital_allowances, allowance, current_loss, supplied_loss=None -): +def reference_trade(profit, receipts, capital_allowances, allowance, current_loss): """Independent profits and eligible annual Class 4 loss. Compare ordinary-expense profit less the current loss with allowance profit, which excludes that loss (ITTOIA ss. 783AF and 783AI). Ties use - the allowance. Only a loss supplied for this year can enter the separate - Class 4 balance, even if the engine fills in a current Income Tax loss - from an earlier input. + the allowance. Only a loss supplied for this year affects the allowance + election or enters the separate Class 4 balance, even if the engine + fills in a current Income Tax loss from an earlier input. """ ordinary_profit = max(Fraction(0), profit - capital_allowances) if receipts > 0 and receipts >= profit: @@ -145,9 +143,7 @@ def reference_trade( # With unknown receipts, the model uses full relief only for a # profit within the allowance; larger profits are reported net. allowance_profit = Fraction(0) if profit <= allowance else ordinary_profit - annual_loss = max( - current_loss if supplied_loss is None else supplied_loss, Fraction(0) - ) + annual_loss = max(current_loss, Fraction(0)) if current_loss > ordinary_profit - allowance_profit: return ordinary_profit, annual_loss if allowance_profit < ordinary_profit: @@ -677,23 +673,12 @@ def test_a_loss_entered_for_some_years_is_relieved_once(years, random): } trades = [] for period, (profit, _, _) in zip(periods, years): - earlier_inputs = [year for year in stored_losses if year < period] - if period in stored_losses: - tax_loss = stored_losses[period] - elif earlier_inputs: - input_year = max(earlier_inputs) - tax_loss = reference_uprated_loss( - sim, stored_losses[input_year], input_year, period - ) - else: - tax_loss = Fraction(0) trades.append( reference_trade( exact(np.float32(profit)), Fraction(0), Fraction(0), reference_allowance(sim, period), - tax_loss, stored_losses.get(period, Fraction(0)), ) ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py index 45e5e6f177..25554afc7f 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import supplied_input class trading_allowance_deduction(Variable): @@ -17,11 +18,15 @@ class trading_allowance_deduction(Variable): "it, and a larger profit as already reflecting the better of actual " "expenses and the allowance, so taxable profit jumps from nil to the " "full profit just above the allowance. Receipts below profit are " - "inconsistent and treated as unknown. The model chooses the greater " - "current-year income deduction: ordinary expenses with a current-year " - "loss claim when that loss exceeds the allowance's effective saving " - "after other deductions; allowance treatment otherwise, including " - "ties. This election covers all the individual's relevant trades " + "inconsistent and treated as unknown. The model compares the raw loss " + "supplied for this tax year, before net-income and relief-cap limits, " + "with the allowance's effective saving after other deductions. " + "Ordinary expenses with a loss claim win when that raw loss is " + "larger; allowance treatment otherwise, including ties. Comparing " + "the raw loss can preserve an unused carry-forward even when both " + "choices give nil current net income. Earlier inputs carried and " + "uprated by the engine do not affect this election. It covers all " + "the individual's relevant trades " "for the tax year. Allowance treatment makes their current losses " "nil for Income Tax and Class 4. It does not optimise future loss use " "or benefits." @@ -72,12 +77,18 @@ def formula(person, period, parameters): # Full relief makes every relevant trade's current profit AND loss # nil (s.783AF); the opt-out is for the individual/year (s.783AL). # Partial relief also replaces every trade's actual expenses. Choose - # ordinary treatment if the current loss saves more income than the - # allowance. The statutory £50,000 cap exceeds the £1,000 allowance. + # ordinary treatment if the raw supplied current loss exceeds the + # allowance saving. Before applying net-income and relief-cap limits, + # this also preserves an unused loss if both choices give nil income. # Prior s.83 losses remain separate and may follow partial relief. profit_after_other_deductions = max_( 0, profit - capital_allowances - person("loss_relief", period) ) allowance_saving = min_(relief, profit_after_other_deductions) - claim_current_loss = person("trading_loss", period) > allowance_saving + # An earlier input carried/uprated into this year is not a new loss + # for the election, just as it is not a new Class 4 loss. + current_loss = supplied_input(person, "trading_loss", period) + if current_loss is None: + current_loss = person.empty_array() + claim_current_loss = current_loss > allowance_saving return where(claim_current_loss, 0, relief) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py index 1639637052..5f45fcbdef 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py @@ -16,9 +16,13 @@ class trade_loss_relief_against_general_income(Variable): "the year before (s.64(2)(b)); the part it cannot use is carried " "forward only by entering it in loss_relief in later years (s.83). " "A current loss is recognised only under ordinary expense treatment; " - "the trading allowance election chooses the greater current-year " - "income deduction for all relevant trades together (ITTOIA 2005 " - "ss.783AF and 783AL; HMRC BIM86076)." + "the trading allowance election compares the raw loss supplied for " + "the tax year, before net-income and relief-cap limits, with the " + "allowance's effective saving for all relevant trades together. " + "Ordinary expenses win when the raw loss is larger; ties take the " + "allowance. Comparing the raw loss can preserve an unused " + "carry-forward when both choices give nil current net income " + "(ITTOIA 2005 ss.783AF and 783AL; HMRC BIM86076)." ) definition_period = YEAR unit = GBP From 60ae5a5ae6821adfcd80deec3cc6b8d35f0afb21 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 16:19:36 -0400 Subject: [PATCH 10/11] Keep the UC deduction regression above changing cap thresholds --- .../baseline/gov/dwp/universal_credit/uc_trading_loss.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 7668190ee9..a48790bd0f 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 @@ -153,7 +153,7 @@ absolute_error_margin: 0 input: age: 40 - employment_income: 12_000 + employment_income: 13_000 self_employment_income: 20_000 stands_as_sole_owner_or_partner_of_company: true owned_company_carries_on_trade: true @@ -167,5 +167,5 @@ uc_national_insurance_on_trade: 500 output: uc_trading_losses_set_against_profits: 1_000 - uc_individual_earned_income_before_mif: 10_500 + uc_individual_earned_income_before_mif: 11_500 is_benefit_cap_exempt_earnings: true From 4c85dfa0d6023f3f6cffdf10d947313013cfab6d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 04:11:54 -0400 Subject: [PATCH 11/11] Align trading-loss release notes with merged main --- changelog.d/date-of-birth-properties.fixed.md | 1 - changelog.d/trading-loss-company-merge.fixed.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 changelog.d/date-of-birth-properties.fixed.md diff --git a/changelog.d/date-of-birth-properties.fixed.md b/changelog.d/date-of-birth-properties.fixed.md deleted file mode 100644 index 439c72f952..0000000000 --- a/changelog.d/date-of-birth-properties.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- Made date-of-birth property tests tolerate host scheduling and garbage-collection stalls during bounded input generation, preserving their assertions, examples and other health checks. diff --git a/changelog.d/trading-loss-company-merge.fixed.md b/changelog.d/trading-loss-company-merge.fixed.md index 9fab01c64b..b2247addb1 100644 --- a/changelog.d/trading-loss-company-merge.fixed.md +++ b/changelog.d/trading-loss-company-merge.fixed.md @@ -1 +1 @@ -- Fixed Universal Credit trading-loss netting to include company trade results and apply the same actual earnings before the minimum income floor in the benefit cap earnings test. +- Fixed Universal Credit trading-loss netting to include company trade results.