diff --git a/changelog.d/trading-loss-allowance-election.fixed.md b/changelog.d/trading-loss-allowance-election.fixed.md new file mode 100644 index 0000000000..780af04eb7 --- /dev/null +++ b/changelog.d/trading-loss-allowance-election.fixed.md @@ -0,0 +1 @@ +- Base the trading allowance election on losses supplied for that tax year, preserving earlier Class 4 loss balances when later loss inputs are unset. diff --git a/changelog.d/trading-loss-company-merge.fixed.md b/changelog.d/trading-loss-company-merge.fixed.md new file mode 100644 index 0000000000..b2247addb1 --- /dev/null +++ b/changelog.d/trading-loss-company-merge.fixed.md @@ -0,0 +1 @@ +- Fixed Universal Credit trading-loss netting to include company trade results. diff --git a/changelog.d/trading-loss-relief.added.md b/changelog.d/trading-loss-relief.added.md new file mode 100644 index 0000000000..1f81ae4aca --- /dev/null +++ b/changelog.d/trading-loss-relief.added.md @@ -0,0 +1 @@ +- Added Income Tax trade loss relief against general income under ITA 2007 s.64, subject to available net income and the s.24A relief cap. diff --git a/changelog.d/trading-loss-relief.changed.md b/changelog.d/trading-loss-relief.changed.md new file mode 100644 index 0000000000..1d819f428e --- /dev/null +++ b/changelog.d/trading-loss-relief.changed.md @@ -0,0 +1,2 @@ +- Uprated trading losses separately from profits, applied programme-specific UC and tax-credit income rules, and included losses as negative market and HBAI income. Income Tax carry-forward now uses only explicitly supplied unrelieved losses; Class 4 retains its separate automatic loss history. +- Coordinated the taxpayer's trading allowance election with current-year Income Tax and Class 4 loss relief, retaining whichever gives the greater current-year income deduction. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index c54e526726..7ba6c9431d 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -125,6 +125,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - uc_reported_capital 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/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_trading_loss.yaml new file mode 100644 index 0000000000..7018140c9c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_trading_loss.yaml @@ -0,0 +1,94 @@ +# HB Regs 2006 reg 38(10) prohibits offsetting a loss against another +# self-employed or employed job. Regs 36(3) and 39 specify separate tax/NI +# deductions; hold these and other benefit receipts at nil to isolate the +# trading-loss rule from annual-tax and other-benefit interactions. +# https://www.legislation.gov.uk/uksi/2006/213/regulation/38 +# https://www.legislation.gov.uk/uksi/2006/213/regulation/36 +# https://www.legislation.gov.uk/uksi/2006/213/regulation/39 + +- name: A trading loss does not offset another trade's Housing Benefit income + period: 2024 + absolute_error_margin: 0.01 + input: + age: 40 + would_claim_uc: false + self_employment_income: 12_000 + trading_loss: 8_000 + income_tax: 0 + national_insurance: 0 + tax_credits: 0 + working_tax_credit: 0 + child_tax_credit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_contributions: 0 + housing_benefit_tariff_income: 0 + housing_benefit_applicable_income_disregard: 0 + housing_benefit_applicable_income_childcare_element: 0 + housing_benefit_applicable_amount: 10_000 + housing_benefit_non_dep_deductions: 0 + LHA_eligible: false + rent: 9_000 + output: + housing_benefit_applicable_income: 12_000 + # Eligible rent 9,000 less 65% of (12,000 - 10,000) = 7,700. + housing_benefit_entitlement: 7_700 + +- name: A loss-making trade does not offset employed Housing Benefit income + period: 2024 + absolute_error_margin: 0.01 + input: + age: 40 + would_claim_uc: false + employment_income: 12_000 + self_employment_income: 0 + trading_loss: 20_000 + income_tax: 0 + national_insurance: 0 + tax_credits: 0 + working_tax_credit: 0 + child_tax_credit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_contributions: 0 + housing_benefit_tariff_income: 0 + housing_benefit_applicable_income_disregard: 0 + housing_benefit_applicable_income_childcare_element: 0 + housing_benefit_applicable_amount: 10_000 + housing_benefit_non_dep_deductions: 0 + LHA_eligible: false + rent: 9_000 + output: + housing_benefit_applicable_income: 12_000 + housing_benefit_entitlement: 7_700 + +- name: The same Housing Benefit claimant with a zero-profit trade + period: 2024 + absolute_error_margin: 0.01 + input: + age: 40 + would_claim_uc: false + employment_income: 12_000 + self_employment_income: 0 + trading_loss: 0 + income_tax: 0 + national_insurance: 0 + tax_credits: 0 + working_tax_credit: 0 + child_tax_credit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_contributions: 0 + housing_benefit_tariff_income: 0 + housing_benefit_applicable_income_disregard: 0 + housing_benefit_applicable_income_childcare_element: 0 + housing_benefit_applicable_amount: 10_000 + housing_benefit_non_dep_deductions: 0 + LHA_eligible: false + rent: 9_000 + output: + housing_benefit_applicable_income: 12_000 + housing_benefit_entitlement: 7_700 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/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..ad854d8bf6 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/tax_credits_applicable_income_trading_loss.yaml @@ -0,0 +1,78 @@ +# 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 + +- 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..a48790bd0f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml @@ -0,0 +1,171 @@ +# 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 + uc_income_tax_on_earnings: 0 + uc_national_insurance_on_earnings: 0 + output: + uc_individual_earned_income_before_mif: 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 + uc_income_tax_on_earnings: 0 + uc_national_insurance_on_earnings: 0 + output: + uc_individual_earned_income_before_mif: 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 + uc_income_tax_on_earnings: 0 + uc_national_insurance_on_earnings: 0 + output: + uc_individual_earned_income_before_mif: 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 + uc_income_tax_on_earnings: 0 + uc_national_insurance_on_earnings: 0 + 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 + +# Reg. 77(3)(b) sends the owner's company trade to reg. 57; reg. 57(2) +# nets all concurrent trade results before taking trade-only tax/NI and the +# nil floor. Reg. 82(1)(a) uses those actual earnings, before the MIF. +# Expected values below are direct arithmetic from those provisions. + +- name: A sole trader loss reduces company earnings before the cap earnings test + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_is_main_employment: true + owned_company_income_share: 12_000 + trading_loss: 5_000 + uc_income_tax_on_earnings: 0 + uc_national_insurance_on_earnings: 0 + uc_income_tax_on_trade: 0 + uc_national_insurance_on_trade: 0 + uc_minimum_income_floor: 20_000 + output: + uc_company_self_employed_earnings: 12_000 + uc_trading_losses_set_against_profits: 5_000 + uc_individual_earned_income_before_mif: 7_000 + uc_individual_earned_income: 20_000 + # Actual £7,000 is below £10,152, even though the MIF is £20,000. + is_benefit_cap_exempt_earnings: false + +- name: Company earnings without the loss exceed the cap threshold with the same deductions + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_is_main_employment: true + owned_company_income_share: 12_000 + trading_loss: 0 + uc_income_tax_on_earnings: 0 + uc_national_insurance_on_earnings: 0 + uc_income_tax_on_trade: 0 + uc_national_insurance_on_trade: 0 + uc_minimum_income_floor: 20_000 + output: + uc_trading_losses_set_against_profits: 0 + uc_individual_earned_income_before_mif: 12_000 + uc_individual_earned_income: 20_000 + is_benefit_cap_exempt_earnings: true + +- name: A company loss and sole trader loss never reduce employed earnings + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + employment_income: 12_000 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_income_share: -10_000 + trading_loss: 5_000 + uc_income_tax_on_earnings: 0 + uc_national_insurance_on_earnings: 0 + uc_income_tax_on_trade: 0 + uc_national_insurance_on_trade: 0 + output: + uc_company_self_employed_earnings: -10_000 + uc_trading_losses_set_against_profits: 0 + uc_individual_earned_income_before_mif: 12_000 + is_benefit_cap_exempt_earnings: true + +- name: Tax and NI on trades cancelled by losses never reduce employed earnings + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + employment_income: 13_000 + self_employment_income: 20_000 + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_income_share: -19_000 + trading_loss: 1_000 + # £1,000 employment tax and £500 employment NI are deducted from pay. + # The other £1,000 tax and £500 NI are deducted only inside the nil trade. + uc_income_tax_on_earnings: 2_000 + uc_national_insurance_on_earnings: 1_000 + uc_income_tax_on_trade: 1_000 + uc_national_insurance_on_trade: 500 + output: + uc_trading_losses_set_against_profits: 1_000 + uc_individual_earned_income_before_mif: 11_500 + is_benefit_cap_exempt_earnings: true 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..a7365d8d5e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml @@ -0,0 +1,320 @@ +# 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: Trade loss relief reduces taxable income before the separate Gift Aid adjustment + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: 60_000 + trading_loss: 10_000 + gift_aid: 8_000 + output: + trade_loss_relief_against_general_income: 10_000 + net_income: 50_000 + adjusted_net_income: 40_000 + personal_allowance: 12_570 + # ITA ss.64 and 58: loss reduces net income, while the gross gift + # reduces ANI and extends rate limits (s.414), not taxable income. + # (50,000 - 12,570) x 20%, below the extended £47,700 basic limit. + income_tax: 7_486 + +- 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 + +- 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 + +- 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 + +# The election covers all relevant trades in the taxpayer's year, not each +# trade independently (ITTOIA 2005 ss.783AF and 783AL; HMRC BIM86076). +- name: Giving up full trading allowance relief permits another trade's loss + period: 2026 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 40 + employment_income: 40_000 + self_employment_income: 800 + self_employment_gross_receipts: 900 + trading_loss: 2_000 + capital_allowances: 0 + benunits: + family: + members: [taxpayer] + households: + household: + members: [taxpayer] + region: LONDON + output: + trading_allowance_deduction: 0 + taxable_self_employment_income: 800 + trade_loss_relief_against_general_income: 2_000 + net_income: 38_800 + # (40,000 + 800 - 2,000 - 12,570) x 20%. + income_tax: 5_246 + +- name: Full allowance relief is retained when it beats claiming the current loss + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + self_employment_income: 800 + self_employment_gross_receipts: 900 + trading_loss: 200 + output: + trading_allowance_deduction: 800 + trade_loss_relief_against_general_income: 0 + taxable_self_employment_income: 0 + net_income: 40_000 + income_tax: 5_486 + +- name: Partial allowance relief replaces expenses of the losing trade too + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + self_employment_income: 4_800 + self_employment_gross_receipts: 5_000 + trading_loss: 500 + output: + trading_allowance_deduction: 800 + trade_loss_relief_against_general_income: 0 + taxable_self_employment_income: 4_000 + net_income: 44_000 + income_tax: 6_286 + +- name: A larger current loss beats partial trading allowance relief + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + self_employment_income: 4_800 + self_employment_gross_receipts: 5_000 + trading_loss: 900 + output: + trading_allowance_deduction: 0 + trade_loss_relief_against_general_income: 900 + taxable_self_employment_income: 4_800 + net_income: 43_900 + income_tax: 6_266 + +- name: Prior Income Tax losses remain usable with partial allowance relief + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + self_employment_income: 4_800 + self_employment_gross_receipts: 5_000 + trading_loss: 500 + loss_relief: 1_000 + output: + trading_allowance_deduction: 800 + trade_loss_relief_against_general_income: 0 + taxable_self_employment_income: 3_000 + net_income: 43_000 + income_tax: 6_086 + +- name: Prior losses removing profits make a current loss claim the better choice + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + self_employment_income: 800 + self_employment_gross_receipts: 900 + trading_loss: 200 + loss_relief: 800 + output: + trading_allowance_deduction: 0 + trade_loss_relief_against_general_income: 200 + taxable_self_employment_income: 0 + net_income: 39_800 + income_tax: 5_446 diff --git a/policyengine_uk/tests/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..322b36dda7 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.yaml @@ -0,0 +1,226 @@ +# 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 + ni_class_4_losses_brought_forward: 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 + +- 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 + +- name: Partial allowance excludes the year's ordinary Class 4 loss + period: 2026 + absolute_error_margin: 0.01 + input: + # Trade A receipts 20,200 less expenses 200 give profit 20,000. + # Trade B receipts 100 less expenses 799 give loss 699. + # Total receipts 20,300 less the allowance give profits 19,300, + # better than ordinary net profits 19,301. B's loss is not also relieved. + self_employment_income: 20_000 + self_employment_gross_receipts: 20_300 + trading_loss: 699 + output: + trading_allowance_deduction: 700 + ni_class_4_profits_before_losses: 19_300 + ni_class_4_trading_loss: 0 + ni_class_4_loss_relief: 0 + ni_class_4_losses_carried_forward: 0 + ni_class_4_profits: 19_300 + ni_class_4: 403.80 + +- name: Equal current loss and allowance saving choose the allowance + period: 2026 + absolute_error_margin: 0.01 + input: + # The same trades, with B expenses 800 and loss 700. + self_employment_income: 20_000 + self_employment_gross_receipts: 20_300 + trading_loss: 700 + output: + trading_allowance_deduction: 700 + ni_class_4_trading_loss: 0 + ni_class_4_losses_carried_forward: 0 + ni_class_4_profits: 19_300 + ni_class_4: 403.80 + +- name: Ordinary loss relief beats the partial allowance without increasing Class 4 + period: 2026 + absolute_error_margin: 0.01 + input: + # The same trades, with B expenses 801 and loss 701: ordinary net + # profits 19,299 beat allowance profits 19,300. + self_employment_income: 20_000 + self_employment_gross_receipts: 20_300 + trading_loss: 701 + output: + trading_allowance_deduction: 0 + ni_class_4_profits_before_losses: 20_000 + ni_class_4_trading_loss: 701 + ni_class_4_loss_relief: 701 + ni_class_4_losses_carried_forward: 0 + ni_class_4_profits: 19_299 + ni_class_4: 403.74 + +- name: Full allowance makes current Class 4 profits and losses nil + period: 2026 + absolute_error_margin: 0 + input: + # Trade A profit and receipts 800; B receipts 100 and expenses 799. + # Full relief beats ordinary net profit 101 and makes both trades nil. + self_employment_income: 800 + self_employment_gross_receipts: 900 + trading_loss: 699 + output: + trading_allowance_deduction: 800 + ni_class_4_profits_before_losses: 0 + ni_class_4_trading_loss: 0 + ni_class_4_losses_carried_forward: 0 + ni_class_4_profits: 0 + ni_class_4: 0 + +- name: Earlier nominal Class 4 balance survives this year's allowance election + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: + 2025: 50_000 + 2026: 0 + 2027: 0 + self_employment_income: + 2025: 0 + 2026: 20_000 + 2027: 30_000 + self_employment_gross_receipts: + 2025: 0 + 2026: 20_300 + 2027: 0 + trading_loss: + 2025: 20_000 + 2026: 699 + 2027: 0 + output: + # The 2025 loss was relieved against pay for Income Tax but remains + # available for Class 4 at its nominal amount. In 2026 the trades above + # elect the allowance, so their new 699 loss never enters the balance. + trading_allowance_deduction: + 2026: 700 + 2027: 0 + ni_class_4_trading_loss: + 2025: 20_000 + 2026: 0 + 2027: 0 + ni_class_4_losses_brought_forward: + 2026: 20_000 + 2027: 700 + ni_class_4_losses_carried_forward: + 2026: 700 + 2027: 0 + ni_class_4_profits: + 2026: 0 + 2027: 29_300 + ni_class_4: + 2026: 0 + 2027: 1_003.80 + +- name: A sideways loss used for Income Tax still carries forward for Class 4 + period: 2026 + absolute_error_margin: 0.01 + input: + employment_income: + 2025: 50_000 + 2026: 0 + self_employment_income: + 2025: 0 + 2026: 50_000 + trading_loss: + 2025: 20_000 + 2026: 0 + loss_relief: 0 + output: + trade_loss_relief_against_general_income: + 2025: 20_000 + 2026: 0 + ni_class_4_losses_brought_forward: 20_000 + ni_class_4_profits: 30_000 + ni_class_4: 1_045.8 + # The loss was used for Income Tax in 2025, so cannot reduce it again. + income_tax: 7_486 + +- name: An unset current loss preserves the allowance and earlier Class 4 balance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: + 2025: 50_000 + 2026: 0 + self_employment_income: + 2025: 0 + 2026: 900 + self_employment_gross_receipts: + 2025: 0 + 2026: 900 + trading_loss: + 2025: 10_000 + # Deliberately no 2026 entry: an earlier Income Tax input can uprate + # into this year but is not a current loss for the allowance election. + output: + trading_allowance_deduction: 900 + ni_class_4_profits_before_losses: 0 + ni_class_4_trading_loss: 0 + ni_class_4_losses_brought_forward: 10_000 + ni_class_4_loss_relief: 0 + ni_class_4_losses_carried_forward: 10_000 + ni_class_4_profits: 0 + ni_class_4: 0 diff --git a/policyengine_uk/tests/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_date_of_birth.py b/policyengine_uk/tests/test_date_of_birth.py index dc300f06a8..9212a05a97 100644 --- a/policyengine_uk/tests/test_date_of_birth.py +++ b/policyengine_uk/tests/test_date_of_birth.py @@ -12,7 +12,7 @@ import numpy as np import pandas as pd import pytest -from hypothesis import example, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -81,7 +81,10 @@ def test_household_situations_match_the_birth_year_rule(): assert np.array_equal(birth_year, [year - age for age in ages]), year -@settings(max_examples=30, deadline=None) +# Bounded primitive draws can include scheduling/GC stalls after expensive +# model runs. Reproduced generation-timing failures gave no model counterexample; +# suppress only that timing check, preserving examples and all other checks. +@settings(max_examples=30, deadline=None, suppress_health_check=[HealthCheck.too_slow]) @given( cases=st.lists( st.tuples( @@ -122,7 +125,7 @@ def test_cutoffs_follow_the_date_of_birth(cases, year): assert ctc[i] == (expected < CUTOFF), (year, age, months) -@settings(max_examples=25, deadline=None) +@settings(max_examples=25, deadline=None, suppress_health_check=[HealthCheck.too_slow]) @given( children=st.lists( st.tuples( @@ -262,7 +265,7 @@ def births_and_year(draw): return births, year -@settings(max_examples=25, deadline=None) +@settings(max_examples=25, deadline=None, suppress_health_check=[HealthCheck.too_slow]) @given(case=births_and_year()) # Transitional cohorts in the year they reach the qualifying age, where a # wrong birth instant changes has_attained_state_pension_credit_qualifying_age diff --git a/policyengine_uk/tests/test_ni_class_4_profits_properties.py b/policyengine_uk/tests/test_ni_class_4_profits_properties.py index 635c946d2e..5980c7ba22 100644 --- a/policyengine_uk/tests/test_ni_class_4_profits_properties.py +++ b/policyengine_uk/tests/test_ni_class_4_profits_properties.py @@ -10,12 +10,13 @@ 1. Differential: ni_class_4_profits_before_losses, ni_class_4_loss_relief, ni_class_4_losses_brought_forward, ni_class_4_losses_carried_forward and ni_class_4_profits equal an independent exact-rational reference: the - better of actual deductions and the allowance where receipts are known, - and losses set against each year's profits, earliest first, with the - rest carried forward without limit. -2. Accounting: trading_loss + losses brought forward = loss relief + losses - carried forward; losses brought forward equal last year's losses carried - forward; 0 <= loss relief <= profits before losses. + better of ordinary expenses with current loss relief and the allowance + where receipts are known, and eligible losses set against each year's + profits, earliest first, with the rest carried forward without limit. +2. Accounting: eligible current loss + losses brought forward = loss relief + + losses carried forward; supplied losses excluded by allowance treatment + receive no Class 4 relief; losses brought forward equal last year's losses + carried forward; 0 <= loss relief <= profits before losses. 3. Bound: 0 <= ni_class_4 <= the s. 15(3) amount on the reference Class 4 profits <= the s. 15(3) amount on the unrelieved profit. Where regulation 100 cannot apply (no Class 1 and, from April 2024, Class 2 irrelevant), @@ -31,7 +32,8 @@ model matches a reference in which a year without an entry makes no new loss, though the engine carries the last entry into it. Total relief never exceeds the losses entered (relief plus the final carry-forward - equals them), Class 4 profits are never negative, and a year with no + plus losses excluded by allowance treatment equals them), Class 4 profits + are never negative, and a year with no loss entered and none brought forward has no relief. Calculating income tax's loss relief or the carried-over trading_loss first, or calculating on a branch, changes none of this. @@ -116,23 +118,58 @@ def reference_allowance(simulation, year): ) -def reference_profits_before_losses(profit, receipts, capital_allowances, allowance): - """ITTOIA 2005 Part 2 Chapter 2 profits, after capital allowances and - the trading allowance, before loss relief.""" +def reference_trade(profit, receipts, capital_allowances, allowance, current_loss): + """Independent profits and eligible annual Class 4 loss. + + Compare ordinary-expense profit less the current loss with allowance + profit, which excludes that loss (ITTOIA ss. 783AF and 783AI). Ties use + the allowance. Only a loss supplied for this year affects the allowance + election or enters the separate Class 4 balance, even if the engine + fills in a current Income Tax loss from an earlier input. + """ + ordinary_profit = max(Fraction(0), profit - capital_allowances) if receipts > 0 and receipts >= profit: if receipts <= allowance: # s. 783AF: the trade's profits are nil. - return Fraction(0) - # The better of actual deductions and the allowance (s. 783AI); - # capital allowances are a deduction (CAA 2001 s. 247) that the - # allowance replaces, so the two never stack. - actual_deductions = receipts - profit + capital_allowances - return max(Fraction(0), receipts - max(actual_deductions, allowance)) - # Receipts unknown: a profit within the allowance is taken to come from - # receipts within it; a larger one already reflects the better deduction. - if profit <= allowance: - return Fraction(0) - return max(Fraction(0), profit - capital_allowances) + allowance_profit = Fraction(0) + else: + # Capital allowances are expenses (CAA 2001 s. 247), replaced + # rather than added to by the trading allowance. + actual_deductions = receipts - profit + capital_allowances + allowance_profit = max( + Fraction(0), receipts - max(actual_deductions, allowance) + ) + else: + # With unknown receipts, the model uses full relief only for a + # profit within the allowance; larger profits are reported net. + allowance_profit = Fraction(0) if profit <= allowance else ordinary_profit + annual_loss = max(current_loss, Fraction(0)) + if current_loss > ordinary_profit - allowance_profit: + return ordinary_profit, annual_loss + if allowance_profit < ordinary_profit: + return allowance_profit, Fraction(0) + # No allowance deduction was available, so ordinary losses remain. + return ordinary_profit, annual_loss + + +def reference_uprated_loss(simulation, amount, input_year, target_year): + """An income input uprates; an unrelieved Class 4 balance stays nominal.""" + parameters = simulation.tax_benefit_system.parameters + + def index(year): + return exact( + parameters( + f"{year}-01-01" + ).gov.economic_assumptions.indices.obr.per_capita.mixed_income + ) + + return exact(np.float32(amount)) * index(target_year) / index(input_year) + + +def assert_uprated_loss_cache(simulation, target_year): + expected = reference_uprated_loss(simulation, 10_000, 2025, target_year) + actual = float(simulation.calculate("trading_loss", target_year)[0]) + assert abs(actual - float(expected)) <= tolerance(actual, expected) def reference_losses(years, supplied=None): @@ -255,6 +292,7 @@ def calculate(variable, period): receipts=calculate("self_employment_gross_receipts", period), capital_allowances=calculate("capital_allowances", period), trading_loss=calculate("trading_loss", period), + effective_trading_loss=calculate("ni_class_4_trading_loss", period), **{ name: calculate(f"ni_class_4_{name}", period) for name in [ @@ -275,19 +313,21 @@ def calculate(variable, period): def reference(values, i): years = [values[period] for period in sorted(values)] - profits = [ - reference_profits_before_losses( + trades = [ + reference_trade( exact(v["profit"][i]), exact(v["receipts"][i]), exact(v["capital_allowances"][i]), v["allowance"], + exact(v["trading_loss"][i]), ) for v in years ] - losses = reference_losses( - [(p, exact(v["trading_loss"][i])) for p, v in zip(profits, years)] - ) - return [dict(profits_before_losses=p, **l) for p, l in zip(profits, losses)] + losses = reference_losses(trades) + return [ + dict(profits_before_losses=p, effective_trading_loss=loss, **l) + for (p, loss), l in zip(trades, losses) + ] def unchanged(profit, expenses, allowances, loss, mode, share, variation): @@ -325,7 +365,7 @@ def test_class_4_profits_match_statutory_reference(sim, drawn): ) assert ( abs( - max(model["trading_loss"][i], 0) + model["effective_trading_loss"][i] + model["losses_brought_forward"][i] - model["loss_relief"][i] - model["losses_carried_forward"][i] @@ -401,6 +441,22 @@ def test_class_4_is_non_decreasing_in_profit(sim, drawn): @PROPERTY_SETTINGS @given(cases, st.sampled_from([more_capital_allowances, more_trading_loss])) +@example( + drawn=[ + ( + (0.0, 0.0, 0.0, 0.0, "unknown", 0.5), + # Trade A: receipts 20,200, expenses 200, profit 20,000. + # Trade B: receipts 100, expenses 799, loss 699. Combined + # receipts are 20,300; increasing B's expenses by 2 makes + # ordinary treatment beat the allowance's 700 saving. + (20_000.0, 300.0, 0.0, 699.0, "consistent", 0.5), + 0.0, + 2.0, + ) + ] + * CASES, + vary=more_trading_loss, +) def test_class_4_is_non_increasing_in_reliefs(sim, drawn, vary): values = run(sim, drawn, vary) current = values[sim[1]] @@ -464,13 +520,11 @@ def test_losses_carry_forward_over_many_years(years, opening_balance, random): ) expected = reference_losses( [ - ( - reference_profits_before_losses( - exact(sim.calculate("self_employment_income", period)[0]), - Fraction(0), - Fraction(0), - reference_allowance(sim, period), - ), + reference_trade( + exact(sim.calculate("self_employment_income", period)[0]), + Fraction(0), + Fraction(0), + reference_allowance(sim, period), exact(sim.calculate("trading_loss", period)[0]), ) for period in periods @@ -544,12 +598,13 @@ def test_pre_allowance_profits_exhaust_the_loss_in_the_historical_reference(): ) periods = (2015, 2016) profits = [ - reference_profits_before_losses( + reference_trade( Fraction(500), Fraction(0), Fraction(0), reference_allowance(sim, period), - ) + Fraction(1_000 if period == 2015 else 0), + )[0] for period in periods ] assert profits == [500, 500] @@ -616,21 +671,20 @@ def test_a_loss_entered_for_some_years_is_relieved_once(years, random): stored_losses = { period: exact(np.float32(loss)) for period, loss in entered.items() } - expected = reference_losses( - [ - ( - reference_profits_before_losses( - exact(np.float32(profit)), - Fraction(0), - Fraction(0), - reference_allowance(sim, period), - ), + trades = [] + for period, (profit, _, _) in zip(periods, years): + trades.append( + reference_trade( + exact(np.float32(profit)), + Fraction(0), + Fraction(0), + reference_allowance(sim, period), stored_losses.get(period, Fraction(0)), ) - for period, (profit, _, _) in zip(periods, years) - ] - ) + ) + expected = reference_losses(trades) names = [ + "trading_loss", "losses_brought_forward", "loss_relief", "losses_carried_forward", @@ -644,10 +698,15 @@ def test_a_loss_entered_for_some_years_is_relieved_once(years, random): model[period][name] = float(sim.calculate(f"ni_class_4_{name}", period)[0]) total_entered = float(sum(stored_losses.values())) + total_eligible = float(sum(loss for _, loss in trades)) + excluded_by_allowance = total_entered - total_eligible tol = tolerance(total_entered, *(profit for profit, _, _ in years)) for period in periods: reference_year = expected[period - first_year] + reference_profit, reference_loss = trades[period - first_year] values = model[period] + assert abs(values["trading_loss"] - float(reference_loss)) <= tol + assert abs(values["profits_before_losses"] - float(reference_profit)) <= tol for name, reference_name in [ ("losses_brought_forward", "brought_forward"), ("loss_relief", "relief"), @@ -666,12 +725,18 @@ def test_a_loss_entered_for_some_years_is_relieved_once(years, random): # No relief without a loss entered for the year or brought forward. if stored_losses.get(period, 0) == 0 and values["losses_brought_forward"] == 0: assert values["loss_relief"] == 0, (period, values) - # Conservation: each loss entered is relieved at most once, and what is - # not relieved is still being carried forward at the end. + # Conservation: each eligible loss is relieved at most once. The rest + # is carried forward; current losses excluded by the allowance receive + # neither relief nor a carried balance. total_relief = sum(model[period]["loss_relief"] for period in periods) assert total_relief <= total_entered + tol assert ( - abs(total_relief + model[last_year]["losses_carried_forward"] - total_entered) + abs( + total_relief + + model[last_year]["losses_carried_forward"] + + excluded_by_allowance + - total_entered + ) <= tol ) @@ -689,8 +754,9 @@ def test_class_4_counts_only_supplied_losses_on_branches(): ) branch = sim.get_branch("extra_2026_loss") branch.set_input("trading_loss", 2026, np.array([5_000.0])) - # The engine carries the 2025 loss into 2027, but it is not a new loss. - assert sim.calculate("trading_loss", 2027)[0] == 10_000 + # The engine uprates the 2025 income input into 2027, but it is not a + # new loss. The separate Class 4 balance stays at its nominal amount. + assert_uprated_loss_cache(sim, 2027) assert sim.calculate("ni_class_4_profits", 2026)[0] == 30_000 assert sim.calculate("ni_class_4_profits", 2027)[0] == 40_000 # On the branch, 2026 relieves its own 5,000 and the 10,000 from 2025. @@ -712,8 +778,8 @@ def test_class_4_counts_only_supplied_losses_on_plain_clones(): clone = sim.clone() clone.set_input("trading_loss", 2026, np.array([5_000.0])) # Warm the original's carry-over cache after the clone records its input. - # A shared provenance set would count this cached 10,000 as a new loss. - assert sim.calculate("trading_loss", 2026)[0] == 10_000 + # A shared provenance set would count this uprated cache as a new loss. + assert_uprated_loss_cache(sim, 2026) observed = ( float(sim.calculate("ni_class_4_profits", 2026)[0]), round(float(sim.calculate("ni_class_4", 2026)[0]), 2), @@ -740,7 +806,7 @@ def test_class_4_does_not_count_a_deleted_loss_after_recalculation(on_branch): sim.delete_arrays("trading_loss", 2026) # Deletion leaves the earlier loss available for engine carry-over, but # that replacement cache must not retain the deleted input's provenance. - assert sim.calculate("trading_loss", 2026)[0] == 10_000 + assert_uprated_loss_cache(sim, 2026) observed = ( float(sim.calculate("ni_class_4_profits", 2026)[0]), round(float(sim.calculate("ni_class_4", 2026)[0]), 2), @@ -782,7 +848,7 @@ def test_class_4_does_not_count_a_loss_deleted_from_its_holder(on_branch, delete # as a second loss (profits of 20,000 and contributions of 445.80), also # when the deleted loss was 10,000 too. sim.get_holder("trading_loss").delete_arrays(period_(2026), sim.branch_name) - assert sim.calculate("trading_loss", 2026)[0] == 10_000 + assert_uprated_loss_cache(sim, 2026) population = sim.get_variable_population("trading_loss") assert supplied_input(population, "trading_loss", period_(2026)) is None assert supplied_input_periods(population, "trading_loss") == [period_(2025)] @@ -815,7 +881,7 @@ def situation(losses): # 2025 loss into 2026; with the stale record that 10,000 would count as a # second loss (profits of 20,000 and contributions of 445.80). sim.build_from_situation(situation({2025: 10_000})) - assert sim.calculate("trading_loss", 2026)[0] == 10_000 + assert_uprated_loss_cache(sim, 2026) population = sim.get_variable_population("trading_loss") assert supplied_input(population, "trading_loss", period_(2026)) is None assert supplied_input_periods(population, "trading_loss") == [period_(2025)] diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py new file mode 100644 index 0000000000..f2360db709 --- /dev/null +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -0,0 +1,827 @@ +"""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), if ordinary expenses and losses beat the trading + allowance. Otherwise the allowance applies and current loss relief is nil. + Net income is the adjusted net income under ordinary expense treatment + without the loss (a separate simulation); adjusted total income is + that less the pension contributions given relief (own contributions up to + the greater of £3,600 and pay plus profits, none from age 75). +2. Differential: a person's Income Tax with a loss equals the Income Tax of + 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 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, its benefit cap earnings test and the capped + award equal the same family with those profits cut by the loss. The + model's means tests use annual tax deductions, so otherwise the loss + reaches them through those deductions. UC's own deductions for tax and + NI on earnings are held alongside Income Tax and NI. +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 after capital allowances and the trading allowance, less the + separate Class 4 loss balance and the year's loss, never below zero. + The Income Tax cap and relief against other income do not limit this + deduction. Class 4 never rises with a loss; a person with no profits + pays none whatever their loss. +8. The s.24A cap starts in 2013-14. +9. A loss-making trade, compared with the same trade making zero profit and + no loss, never increases Income Tax. With paid tax and NI deductions and + other benefit receipts held consistently, it never reduces UC, Housing + Benefit or tax credits. This isolates each programme's loss rule: the + model's annual tax deductions and changes to other benefits can otherwise + lower an individual award (the tax-payment timing gap is #2108). + +Comparisons allow float32 rounding: the model stores values as float32. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +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, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=3, + 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 = [ + "housing_benefit", + "council_tax_reduction", + "pension_credit", + "income_support", + "jsa_income", + "esa_income", + "housing_benefit_applicable_income", + "income_support_applicable_income", + "council_tax_reduction_applicable_income", + "pension_credit_income", +] +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 = { + "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", + "free_tv_licence_value", + "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, 80)), + "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} + + +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. Repeat the cases for England and Scotland. +PINNED_TAXPAYERS = [ + taxpayer(employment_income=20_000, trading_loss=50_000), + taxpayer(employment_income=400_000, trading_loss=300_000), + 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), +] * 2 +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), +) +def test_relief_is_the_loss_within_net_income_and_the_cap(people, regions): + without = [ + {**p, "trading_loss": 0.0, "trading_allowance_deduction": 0.0} for p in people + ] + a = calculate( + single_people(people, regions), + ["trade_loss_relief_against_general_income", "adjusted_net_income"], + ) + b = calculate( + single_people(without, regions), + [ + "adjusted_net_income", + "taxable_employment_income", + "taxable_self_employment_income", + ], + ) + for i, person in enumerate(people): + # 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) + ) + ati = max(0.0, b["adjusted_net_income"][i] - relieved) + cap = max(50_000.0, 0.25 * ati) + # Generated taxpayers have no receipts, capital allowances or prior + # losses. With unknown receipts, allowance treatment fully exempts a + # profit within £1,000; ordinary treatment wins if the loss is larger. + profit = person["self_employment_income"] + allowance = profit if profit <= 1_000 else 0.0 + ordinary = person["trading_loss"] > allowance + expected = ( + min(person["trading_loss"], cap, b["adjusted_net_income"][i]) + if ordinary + else 0.0 + ) + allowance_kept = 0.0 if ordinary else allowance + relief = a["trade_loss_relief_against_general_income"][i] + assert abs(relief - expected) <= tolerance(expected, ati), (person, relief) + # Ordinary net income falls by the chosen relief or allowance. + assert abs( + b["adjusted_net_income"][i] + - allowance_kept + - 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 + + +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, + }, +] * 4 # Eight cases for each of the two regions in PINNED_REGIONS. + + +@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), +) +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 +@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), + 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))): + # 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)), + "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, benunit_overrides=None, year=YEAR): + 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, + **{ + 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()}, + } + 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, + } + + +PINNED_PENSION_CREDIT_PASSPORT_FAMILY = ( + [ + adult( + age=76, + private_pension_income=19_500.0, + trading_loss=10_000.0, + state_pension=0.0, + hours_worked=0.0, + ), + {"age": 1, "trading_loss": 0.0}, + ], + { + **home(0.0), + "household_owns_tv": True, + "would_evade_tv_licence_fee": False, + }, +) + + +# Families every run checks: profits and another trade's loss under the +# benefit cap, a loss beside pay only, a child's loss, and a loss-making +# couple with pay. +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"), + ), +] + + +@pytest.mark.parametrize( + ("programme", "year"), + [("universal_credit", 2026), ("housing_benefit", 2024), ("tax_credits", 2024)], +) +@PROPERTY_SETTINGS +@example(units=PINNED_FAMILIES) +@given(units=st.lists(families(), min_size=4, max_size=10)) +def test_zero_profit_trade_loss_cannot_raise_tax_or_reduce_programme_award( + units, programme, year +): + """Compare a loss-making trade with an otherwise identical zero-profit trade. + + Tax uses each person's actual liability. Benefit comparisons hold paid + deductions at the zero-loss values, including UC's earnings-only tax and + NI. Housing Benefit is tested for continuing 2024 awards without tax + credits: a changed credit receipt can legitimately change Housing Benefit. + No trade has concurrent profits, so a loss cannot switch the UC benefit + cap earnings exemption through reg. 82. + """ + loss_making = [ + ([{**person, "self_employment_income": 0.0} for person in people], household) + for people, household in units + ] + zero_profit = [ + ([{**person, "trading_loss": 0.0} for person in people], household) + for people, household in loss_making + ] + benunit_inputs = { + f"b{i}": { + "would_claim_uc": programme == "universal_credit", + "would_claim_housing_benefit": programme == "housing_benefit", + "would_claim_WTC": programme == "tax_credits", + "would_claim_CTC": programme == "tax_credits", + } + for i in range(len(units)) + } + if programme == "housing_benefit": + for inputs in benunit_inputs.values(): + inputs.update( + tax_credits=0.0, + working_tax_credit=0.0, + child_tax_credit=0.0, + ) + person_inputs = ( + {f"p{i}_0": {"housing_benefit_reported": 1_000.0} for i in range(len(units))} + if programme == "housing_benefit" + else {} + ) + + def simulation(population, inputs): + return Simulation( + situation=family_situation(population, inputs, benunit_inputs, year=year) + ) + + def values(sim, variable): + return np.asarray(sim.calculate(variable, year), dtype=float) + + baseline = simulation(zero_profit, person_inputs) + actual_loss = simulation(loss_making, person_inputs) + actual_tax = values(actual_loss, "income_tax") + baseline_tax = values(baseline, "income_tax") + assert np.all(actual_tax <= baseline_tax + 0.5), (actual_tax, baseline_tax, units) + + deductions = [ + "income_tax", + "national_insurance", + "uc_income_tax_on_earnings", + "uc_national_insurance_on_earnings", + ] + held = {variable: values(baseline, variable) for variable in deductions} + mif = np.asarray(baseline.calculate("uc_mif_applies", year), dtype=bool) + for k, name in enumerate(baseline.populations["person"].ids): + person_inputs.setdefault(name, {}).update( + {variable: float(held[variable][k]) for variable in deductions} + ) + person_inputs[name]["uc_mif_applies"] = bool(mif[k]) + + loss_award = values(simulation(loss_making, person_inputs), programme) + zero_award = values(simulation(zero_profit, person_inputs), programme) + assert np.all(loss_award >= zero_award - 0.5), ( + programme, + loss_award, + zero_award, + units, + ) + + +@PROPERTY_SETTINGS +@example(PINNED_FAMILIES) +@given(st.lists(families(), min_size=4, max_size=10)) +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 + benunits = with_loss.populations["benunit"].ids + deductions = [ + "income_tax", + "national_insurance", + "uc_income_tax_on_earnings", + "uc_national_insurance_on_earnings", + ] + tax = {v: values(with_loss, v) for v in deductions} + loss = values(with_loss, "trading_loss") + profits = values(with_loss, "self_employment_income") + mif = np.asarray(with_loss.calculate("uc_mif_applies", YEAR)) + uc = values(with_loss, "universal_credit") + hb = values(with_loss, "housing_benefit") + + def held(k, **extra): + return { + "trading_loss": 0.0, + **{v: float(tax[v][k]) for v in deductions}, + **extra, + } + + # 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, 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) + }, + # 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: + a, b = values(with_loss, variable), values(netted, variable) + assert np.allclose(a, b, atol=0.5), (variable, a, b, units) + + +@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 + 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 +@example(PINNED_FAMILIES) +@example([PINNED_PENSION_CREDIT_PASSPORT_FAMILY]) +@given(st.lists(families(), min_size=4, max_size=10)) +def test_household_income_moves_only_through_its_channels(units): + without = [ + ([{**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, + ) + + +def test_loss_can_remove_pension_credit_and_its_tv_licence_passport(): + """The annual tax deduction can change Pension Credit and its passport.""" + people, household = PINNED_PENSION_CREDIT_PASSPORT_FAMILY + without = ([{**p, "trading_loss": 0.0} for p in people], household) + variables = ["income_tax", "pension_credit", "free_tv_licence_value"] + loss = calculate( + family_situation([PINNED_PENSION_CREDIT_PASSPORT_FAMILY]), variables + ) + zero = calculate(family_situation([without]), variables) + assert zero["income_tax"][0] == pytest.approx(1_386.0, abs=0.01) + assert loss["income_tax"][0] == 0 + assert zero["pension_credit"][0] > 0 + assert loss["pension_credit"][0] == 0 + assert zero["free_tv_licence_value"][0] > 0 + assert loss["free_tv_licence_value"][0] == 0 + + +@PROPERTY_SETTINGS +@example(PINNED_TAXPAYERS, PINNED_REGIONS, [5_000.0] * 16) +@given( + 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_loss_balance(people, regions, brought_forward): + people = [ + { + **p, + "loss_relief": b, + "ni_class_4_losses_brought_forward": b, + "capital_allowances": 0.0, + "trading_allowance_deduction": 0.0, + } + for p, b in zip(people, brought_forward) + ] + without = [{**p, "trading_loss": 0.0} for p in people] + variables = [ + "ni_class_4_profits", + "ni_class_4", + ] + 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["capital_allowances"] + - person["trading_allowance_deduction"] + - person["trading_loss"] + - person["ni_class_4_losses_brought_forward"], + ) + assert abs(a["ni_class_4_profits"][i] - expected) <= tolerance( + person["self_employment_income"] + ), 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_current_year_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_current_year_income.py index f4c7bf0564..1643dafe17 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_current_year_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_current_year_income.py @@ -48,4 +48,9 @@ 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) - return income + # SI 2002/2006 reg 3(1) Step Four: only the claimants' losses + # reduce their joint income; a child's loss is not theirs. + claimants = person("is_claimant_or_partner", period) + return max_( + 0, income - add_for_members(benunit, period, ["trading_loss"], claimants) + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py index f608b6f6bb..f0abee8de3 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_individual_earned_income_before_mif.py @@ -12,8 +12,11 @@ class uc_individual_earned_income_before_mif(Variable): "earnings, including statutory sick, maternity and paternity pay, " "less their own relievable pension contributions and their own " "income tax and National Insurance in respect of their employment " - "and self-employment. The minimum income floor compares this with " - "the net threshold. Regulation 55(4) also treats statutory adoption, " + "and self-employment. Concurrent trade results include company " + "income treated as self-employed earnings under regulation 77; " + "losses and trade-only tax and NI never reduce employed earnings. " + "The minimum income floor compares this with the net threshold. " + "Regulation 55(4) also treats statutory adoption, " "shared parental, parental bereavement and neonatal care pay as " "employed earnings; the model has no variables for those." ) @@ -28,6 +31,10 @@ class uc_individual_earned_income_before_mif(Variable): title="Universal Credit Regulations 2013 reg. 57(2)", href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", ), + dict( + title="Universal Credit Regulations 2013 reg. 77(3)(b) and (4)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), dict( title="Finance Act 2004 s. 188 (relievable pension contributions)", href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", @@ -63,14 +70,16 @@ def formula(person, period, parameters): earnings_components.append("basic_income") # Self-employed earnings follow reg. 57(2): each trade's profit or # loss is added together (step 2), including a company's income - # treated as the owner's self-employed earnings (reg. 77(3)(b)), and - # the income tax and National Insurance paid in respect of any trade - # come off that total (step 3) before a nil or negative result + # treated as the owner's self-employed earnings (reg. 77(3)(b)), + # less concurrent trading losses. The income tax and National + # Insurance paid in respect of any trade come off that total + # (step 3) before a nil or negative result # becomes nil. So a trading loss, or tax on a trade a company loss # has cancelled, is never set against employed earnings. trade = ( person("self_employment_income", period) + person("uc_company_self_employed_earnings", period) + - person("uc_trading_losses_set_against_profits", period) - person("uc_income_tax_on_trade", period) - person("uc_national_insurance_on_trade", 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..497a6426e4 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_trading_losses_set_against_profits.py @@ -0,0 +1,41 @@ +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; the final nil floor ensures that " + "one trade's loss reduces another trade's profits but never employed " + "earnings. Company earnings treated as self-employed under reg 77(3)(b) " + "join that total, including a negative company result. Over a year, " + "with profits and losses spread evenly, relief is the loss up to the " + "positive combined trade result. UC earnings and the benefit cap earnings " + "test (reg 82) both deduct it. This annual approximation does not " + "track unused losses by assessment period under reg 57A; prior-period " + "losses must not be entered as a fresh current-year trading_loss." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 57(2)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ), + dict( + title="Universal Credit Regulations 2013 reg. 77(3)(b)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/77", + ), + dict( + title="Universal Credit Regulations 2013 reg. 57A (assessment-period losses)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/57A", + ), + ] + + def formula(person, period, parameters): + combined_trade_result = person("self_employment_income", period) + person( + "uc_company_self_employed_earnings", period + ) + return min_(person("trading_loss", period), max_(0, combined_trade_result)) 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..f306c5649a 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 in the model's fixed allowance order (#2106). + 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/allowances/trading_allowance_deduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py index 6fe3203318..25554afc7f 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/trading_allowance_deduction.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import supplied_input class trading_allowance_deduction(Variable): @@ -17,7 +18,18 @@ class trading_allowance_deduction(Variable): "it, and a larger profit as already reflecting the better of actual " "expenses and the allowance, so taxable profit jumps from nil to the " "full profit just above the allowance. Receipts below profit are " - "inconsistent and treated as unknown." + "inconsistent and treated as unknown. The model compares the raw loss " + "supplied for this tax year, before net-income and relief-cap limits, " + "with the allowance's effective saving after other deductions. " + "Ordinary expenses with a loss claim win when that raw loss is " + "larger; allowance treatment otherwise, including ties. Comparing " + "the raw loss can preserve an unused carry-forward even when both " + "choices give nil current net income. Earlier inputs carried and " + "uprated by the engine do not affect this election. It covers all " + "the individual's relevant trades " + "for the tax year. Allowance treatment makes their current losses " + "nil for Income Tax and Class 4. It does not optimise future loss use " + "or benefits." ) definition_period = YEAR reference = [ @@ -33,6 +45,14 @@ class trading_allowance_deduction(Variable): title="HMRC Business Income Manual BIM86050", href="https://www.gov.uk/hmrc-internal-manuals/business-income-manual/bim86050", ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783AL (election)", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AL", + ), + dict( + title="HMRC Business Income Manual BIM86076 (loss election)", + href="https://www.gov.uk/hmrc-internal-manuals/business-income-manual/bim86076", + ), ] unit = GBP @@ -53,4 +73,22 @@ def formula(person, period, parameters): profit <= allowance, profit - capital_allowances, 0 ) relief = where(receipts_known, relief_with_receipts, relief_without_receipts) - return max_(relief, 0) + relief = max_(relief, 0) + # Full relief makes every relevant trade's current profit AND loss + # nil (s.783AF); the opt-out is for the individual/year (s.783AL). + # Partial relief also replaces every trade's actual expenses. Choose + # ordinary treatment if the raw supplied current loss exceeds the + # allowance saving. Before applying net-income and relief-cap limits, + # this also preserves an unused loss if both choices give nil income. + # Prior s.83 losses remain separate and may follow partial relief. + profit_after_other_deductions = max_( + 0, profit - capital_allowances - person("loss_relief", period) + ) + allowance_saving = min_(relief, profit_after_other_deductions) + # An earlier input carried/uprated into this year is not a new loss + # for the election, just as it is not a new Class 4 loss. + current_loss = supplied_input(person, "trading_loss", period) + if current_loss is None: + current_loss = person.empty_array() + claim_current_loss = current_loss > allowance_saving + return where(claim_current_loss, 0, relief) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py b/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py deleted file mode 100644 index c7e92feaec..0000000000 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bases/trading_loss.py +++ /dev/null @@ -1,22 +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." - documentation = ( - "Trading losses made in the year. self_employment_income holds the " - "profits of trades that made one, so a loss here alongside a profit " - "comes from a different trade. Like other inputs, a value carries " - "into later years that are not set, and income tax's loss_relief " - "reads it there, so set those years to zero for a one-off loss. " - "Class 4 counts a loss only in the year it is set for " - "(ni_class_4_trading_loss)." - ) - 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/net_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py index d10575ae77..ef5d11154f 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py @@ -7,7 +7,8 @@ class net_income(Variable): entity = Person label = "Income before personal allowances and Gift Aid adjustments" documentation = ( - "The model's taxable income components before the Gift Aid adjustment " + "The model's taxable income components after trade loss relief " + "against general income and before the Gift Aid adjustment " "to adjusted net income. Gift Aid extends rate limits and reduces " "adjusted net income, but does not reduce the income charged to tax." ) @@ -26,12 +27,10 @@ def formula(person, period, parameters): supplied_ani = supplied_input(person, "adjusted_net_income", period) if supplied_ani is not None: return supplied_ani + person("gift_aid_grossed_up", period) - components = parameters( - period - ).gov.hmrc.income_tax.adjusted_net_income_components - income = add(person, period, components) - if parameters( - period - ).gov.contrib.ubi_center.basic_income.interactions.include_in_taxable_income: - income += person("basic_income", period) - return max_(0, income) + # ITA 2007 ss.23 and 64 deduct trade loss relief at Step 2, before + # personal allowances, Gift Aid's ANI adjustment and tax rate limits. + return max_( + 0, + person("net_income_before_trade_loss_relief", period) + - person("trade_loss_relief_against_general_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 new file mode 100644 index 0000000000..959562f123 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py @@ -0,0 +1,69 @@ +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)). 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 (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." + ) + definition_period = YEAR + unit = GBP + 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): + 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 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 + ) + 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/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/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 new file mode 100644 index 0000000000..5f45fcbdef --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.py @@ -0,0 +1,52 @@ +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). " + "A current loss is recognised only under ordinary expense treatment; " + "the trading allowance election compares the raw loss supplied for " + "the tax year, before net-income and relief-cap limits, with the " + "allowance's effective saving for all relevant trades together. " + "Ordinary expenses win when the raw loss is larger; ties take the " + "allowance. Comparing the raw loss can preserve an unused " + "carry-forward when both choices give nil current net income " + "(ITTOIA 2005 ss.783AF and 783AL; HMRC BIM86076)." + ) + definition_period = YEAR + unit = GBP + 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): + net_income = max_(0, person("net_income_before_trade_loss_relief", period)) + # Allowance treatment cannot retain a current expense-generated loss + # on another trade in the same individual's tax year. + loss = where( + person("trading_allowance_deduction", period) > 0, + 0, + person("trading_loss", period), + ) + return min_( + loss, + min_(person("income_tax_relief_cap", period), net_income), + ) diff --git a/policyengine_uk/variables/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..201cc012df --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py @@ -0,0 +1,33 @@ +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. 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 + 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): + non_savings_income = ( + person("net_income_before_trade_loss_relief", period) + - 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_4/ni_class_4_trading_loss.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py index fed2f5323d..5bda122912 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_4/ni_class_4_trading_loss.py @@ -7,8 +7,12 @@ class ni_class_4_trading_loss(Variable): entity = Person label = "Trading losses made in the year, for Class 4" documentation = ( - "The year's trading_loss where one is supplied for the year, and nil " - "otherwise. Like other inputs, trading_loss carries an earlier " + "The year's trading_loss where one is supplied for the year and " + "ordinary expense treatment is chosen, and nil otherwise. The " + "trading allowance recomputes every relevant trade's profits and " + "losses for Income Tax and Class 4, so current losses cannot also " + "be claimed. Earlier unrelieved Class 4 balances remain separate. " + "Like other inputs, trading_loss carries an earlier " "year's value into later years that are not set, but for Class 4 a " "loss is relieved once: it reduces the profits of the year it is " "made, and only the excess reduces the profits of later years " @@ -26,6 +30,22 @@ class ni_class_4_trading_loss(Variable): title="Income Tax Act 2007, s. 83(2)", href="https://www.legislation.gov.uk/ukpga/2007/3/section/83", ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783AF", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AF", + ), + dict( + title="Income Tax (Trading and Other Income) Act 2005, s. 783AI", + href="https://www.legislation.gov.uk/ukpga/2005/5/section/783AI", + ), + dict( + title="HMRC Business Income Manual BIM86052", + href="https://www.gov.uk/hmrc-internal-manuals/business-income-manual/bim86052", + ), + dict( + title="HMRC National Insurance Manual NIM24610", + href="https://www.gov.uk/hmrc-internal-manuals/national-insurance-manual/nim24610", + ), ] def formula(person, period, parameters): @@ -35,4 +55,9 @@ def formula(person, period, parameters): loss = supplied_input(person, "trading_loss", period) if loss is None: return person.empty_array() - return max_(loss, 0) + # Class 4 uses the same year's recomputed trade profit or loss as + # Income Tax. A positive allowance deduction means the taxpayer + # chose allowance treatment and gave up ordinary current losses. + # This leaves independently carried earlier Class 4 losses intact. + allowance_chosen = person("trading_allowance_deduction", period) > 0 + return where(allowance_chosen, 0, max_(loss, 0)) 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 0dc95db9e3..72ddf10584 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -61,6 +61,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..8d6ca14528 --- /dev/null +++ b/policyengine_uk/variables/input/trading_loss.py @@ -0,0 +1,35 @@ +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), if ordinary expense treatment " + "beats the trading allowance for the individual's year; Class 4 NICs " + "deduct it from " + "trading profits only (SSCBA 1992 Sch 2 para 3); tax credits deduct it " + "from the claimants' income (SI 2002/2006 reg 3(1) Step 4); means tests " + "never set it against employed earnings or other income: UC sets it " + "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. Income Tax losses brought forward go in loss_relief; Class 4 " + "tracks its own unrelieved balance in ni_class_4_losses_brought_forward. " + "For a one-off loss, set subsequent years explicitly to zero for " + "Income Tax and benefits; Class 4 counts only explicitly supplied " + "loss years and carries forward the unused balance automatically." + ) + definition_period = YEAR + unit = GBP + 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", + )