From 291d29300d9ba14a61160baf142b0b1cb7690661 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 13:06:06 -0400 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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),